Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
690e229
feat(studio): show clip speed in the clip name as [150%] or [ramp]
vanceingalls Sep 30, 2026
19b1561
feat(studio): one fx badge per clip replaces the per-tool and link ba…
vanceingalls Sep 30, 2026
b130e63
feat(studio): premiere-style audio gain dialog on G
vanceingalls Sep 30, 2026
0b23092
fix(studio): use a theme blue for the audio gain OK button
vanceingalls Sep 30, 2026
946e16d
fix(studio): keep shortcuts out of the audio gain modal; refuse while…
vanceingalls Sep 30, 2026
e100c02
fix(studio): offer link for any unlinked video and audio pair
vanceingalls Sep 30, 2026
53c6835
fix(studio): shift and cmd-click add timeline clips to the selection
vanceingalls Sep 30, 2026
8cf2de6
fix(studio): keep audio inside its partner video and make cmd-click a…
vanceingalls Sep 30, 2026
c36b131
fix(studio): hold the drag actor at the partner-video clamp
vanceingalls Sep 30, 2026
a329e5f
feat(studio): preview a look on hover in the clip menu before applyin…
vanceingalls Sep 30, 2026
0c4f928
refactor(studio): trim comments the clip-link names already carry
vanceingalls Sep 30, 2026
507387d
fix(studio): unlinking clears the timeline selection
vanceingalls Sep 30, 2026
e9b0cec
fix(sdk): keep a linked partner's end when a trim changes the grabbed…
vanceingalls Oct 1, 2026
644216b
fix(studio): keep every moving detached audio inside its video, after…
vanceingalls Oct 1, 2026
10a7122
fix(studio): carry the normalize refusal and save outcomes into the g…
vanceingalls Oct 1, 2026
0fdfd85
refactor(studio): trim comments the gain and multiselect layer added
vanceingalls Oct 1, 2026
3a6ef8e
refactor(sdk): split the linked partner's duration out of partnerTiming
vanceingalls Oct 1, 2026
b2c33d2
fix(sdk): refuse a linked trim whose new end would cross the partner'…
vanceingalls Oct 1, 2026
c315ba8
fix(sdk): key a linked edit's grabbed baselines by composition scope
vanceingalls Oct 1, 2026
ac1616e
test(sdk): run the two-scope grabbed-baseline cases on an inline comp…
vanceingalls Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 96 additions & 12 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
JsonPatchOp,
} from "../types.js";
import type { ParsedDocument } from "./model.js";
import { MEDIA_LINK_ATTR } from "@hyperframes/core/media-link";
import { MEDIA_LINK_ATTR, linkScopeOf } from "@hyperframes/core/media-link";
import { idsToUnlink, linkedPartnerIds } from "./linkedTiming.js";
import {
resolveScoped,
Expand Down Expand Up @@ -298,16 +298,96 @@ function applySetTiming(
const own = handleSetTiming(parsed, ids, timing);
return concatResults(own, handleSetAttribute(parsed, unlink, MEDIA_LINK_ATTR, null));
}
const partners =
op.start === undefined && op.duration === undefined
? []
: linkedPartnerIds(parsed.document, ids);
const own = handleSetTiming(parsed, ids, timing);
if (partners.length === 0) return own;
return concatResults(
own,
handleSetTiming(parsed, partners, { start: op.start, duration: op.duration }),
);
const plan = planLinkedTiming(parsed, ids, timing);
if (plan.refusal) throw new Error(plan.refusal);
let result = handleSetTiming(parsed, ids, timing);
for (const partner of plan.partners) {
result = concatResults(result, handleSetTiming(parsed, [partner.id], partner.timing));
}
return result;
}

type PartnerEdit = { id: HfId; timing: { start?: number; duration?: number } };

function planLinkedTiming(
parsed: ParsedDocument,
ids: HfId[],
timing: { start?: number; duration?: number },
): { partners: PartnerEdit[]; refusal: string | null } {
if (timing.start === undefined && timing.duration === undefined) {
return { partners: [], refusal: null };
}
const grabbedFor = grabbedBaselines(parsed, ids);
const partners: PartnerEdit[] = [];
for (const id of linkedPartnerIds(parsed.document, ids)) {
const el = resolveScoped(parsed.document, id);
const grabbed = el ? grabbedFor(el) : undefined;
if (!el || !grabbed) continue;
const partnerEdit = partnerTiming(grabbed, readClipTiming(el), timing);
if (partnerEdit.duration !== undefined && partnerEdit.duration <= 0) {
const tag = el.tagName.toLowerCase();
return {
partners: [],
refusal: `Linked ${tag} would start after the new end — unlink or trim the ${tag} first.`,
};
}
partners.push({ id, timing: partnerEdit });
}
return { partners, refusal: null };
}

type ClipWindow = { start: number | null; duration: number | null };

function grabbedBaselines(
parsed: ParsedDocument,
ids: HfId[],
): (partner: Element) => ClipWindow | undefined {
const byScope = new Map<Element | null, Map<string, ClipWindow>>();
for (const id of ids) {
const el = resolveScoped(parsed.document, id);
const link = el?.getAttribute(MEDIA_LINK_ATTR);
if (!el || !link) continue;
const byLink = byScope.get(linkScopeOf(el)) ?? new Map<string, ClipWindow>();
byLink.set(link, readClipTiming(el));
byScope.set(linkScopeOf(el), byLink);
}
return (partner) => {
const link = partner.getAttribute(MEDIA_LINK_ATTR);
return link ? byScope.get(linkScopeOf(partner))?.get(link) : undefined;
};
}
const ALIGN_EPSILON_S = 1e-3;

/**
* A partner follows the edit without resyncing: a start change shifts it by the
* same delta (keeping any offset); a duration change carries over only when the
* partner's end sat at the edited clip's end.
*/
function partnerTiming(
grabbed: ClipWindow,
partner: ClipWindow,
edit: { start?: number; duration?: number },
): { start?: number; duration?: number } {
const timing: { start?: number; duration?: number } = {};
const [gStart, pStart] = [grabbed.start ?? 0, partner.start ?? 0];
if (edit.start !== undefined) timing.start = pStart + (edit.start - gStart);
const duration = partnerDuration(grabbed, partner, edit, timing.start ?? pStart);
if (duration !== undefined) timing.duration = duration;
return timing;
}

function partnerDuration(
grabbed: ClipWindow,
partner: ClipWindow,
edit: { start?: number; duration?: number },
partnerStart: number,
): number | undefined {
if (edit.duration === undefined) return undefined;
const grabbedStart = grabbed.start ?? 0;
const grabbedEnd = grabbedStart + (grabbed.duration ?? 0);
const partnerEnd = (partner.start ?? 0) + (partner.duration ?? 0);
if (Math.abs(partnerEnd - grabbedEnd) >= ALIGN_EPSILON_S) return undefined;
return (edit.start ?? grabbedStart) + edit.duration - partnerStart;
}

export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
Expand Down Expand Up @@ -1667,7 +1747,11 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
`Element(s) not found: ${missing.join(", ")}.`,
"Verify the id against comp.getElements() or comp.find().",
);
return CAN_OK;
const refusal =
op.type === "setTiming" && op.linked !== false
? planLinkedTiming(parsed, ids, op).refusal
: null;
return refusal ? canErr("E_LINKED_PARTNER_CROSSED", refusal) : CAN_OK;
}
case "addElement": {
if (op.parent !== null && resolveScoped(parsed.document, op.parent) === null)
Expand Down
121 changes: 121 additions & 0 deletions packages/sdk/src/session.linked.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ describe("setTiming on linked clips", () => {
expect(attr(html, "hf-talk-audio", "data-duration")).toBe("6");
});

it("moves a misaligned pair by the same delta, preserving the offset", async () => {
const html = LINKED_HTML.replace(
'src="talk.mp4" data-link="lk-1" data-start="2"',
'src="talk.mp4" data-link="lk-1" data-start="3"',
);
const comp = await openComposition(html);
comp.setTiming("hf-talk", { start: 5 });
const out = comp.serialize();
expect(attr(out, "hf-talk", "data-start")).toBe("5");
expect(attr(out, "hf-talk-audio", "data-start")).toBe("6");
});

it("keeps a common end when partners start at different times", async () => {
const offset = LINKED_HTML.replace(
'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6"',
'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="3" data-duration="5"',
);
const comp = await openComposition(offset);
comp.setTiming("hf-talk", { duration: 4 });
const html = comp.serialize();
expect(attr(html, "hf-talk", "data-duration")).toBe("4");
expect(attr(html, "hf-talk-audio", "data-start")).toBe("3");
expect(attr(html, "hf-talk-audio", "data-duration")).toBe("3");
});

it("{ linked: false } edits one member and unlinks the pair", async () => {
const comp = await openComposition(LINKED_HTML);
comp.setTiming("hf-talk", { start: 5 }, { linked: false });
Expand Down Expand Up @@ -127,4 +152,100 @@ describe("setTiming on linked clips", () => {
);
expect(comp.serialize()).toBe((await openComposition(html)).serialize());
});

describe("a duration edit whose new end would cross a linked partner's start", () => {
const OFFSET_HTML = LINKED_HTML.replace(
'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6"',
'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="3" data-duration="5"',
);

it.each([1, 0.5])("refuses duration %s atomically, before any mutation", async (duration) => {
const comp = await openComposition(OFFSET_HTML);
comp.setTiming("hf-music", { start: 1 });
const before = comp.serialize();
expect(comp.can({ type: "setTiming", target: "hf-talk", duration })).toMatchObject({
ok: false,
code: "E_LINKED_PARTNER_CROSSED",
});
expect(() => comp.setTiming("hf-talk", { duration })).toThrow(
/linked audio would start after the new end/i,
);
expect(comp.serialize()).toBe(before);
comp.undo();
expect(attr(comp.serialize(), "hf-music", "data-start")).toBe("0");
expect(comp.canUndo()).toBe(false);
});

it("trims the partner to the shared end while that end stays after its start", async () => {
const comp = await openComposition(OFFSET_HTML);
expect(comp.can({ type: "setTiming", target: "hf-talk", duration: 1.5 }).ok).toBe(true);
comp.setTiming("hf-talk", { duration: 1.5 });
const html = comp.serialize();
expect(attr(html, "hf-talk", "data-duration")).toBe("1.5");
expect(attr(html, "hf-talk-audio", "data-start")).toBe("3");
expect(attr(html, "hf-talk-audio", "data-duration")).toBe("0.5");
});

it("allows the same edit once the pair is unlinked", async () => {
const comp = await openComposition(OFFSET_HTML);
comp.setTiming("hf-talk", { duration: 0.5 }, { linked: false });
const html = comp.serialize();
expect(attr(html, "hf-talk", "data-duration")).toBe("0.5");
expect(attr(html, "hf-talk-audio", "data-duration")).toBe("5");
});
});

describe.each([
{ host: 'data-composition-id="child" data-composition-file="child.html"', cv: "hf-host/hf-cv" },
{ host: 'data-composition-id="child"', cv: "hf-cv" },
])(
"one edit naming linked clips in two compositions that reuse a link id ($host)",
({ host, cv }) => {
const TWO_SCOPES_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
<video data-hf-id="hf-v" src="talk.mp4" muted data-link="lk-1" data-start="2" data-duration="6" data-track-index="0"></video>
<audio data-hf-id="hf-a" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6" data-track-index="1"></audio>
<div data-hf-id="hf-host" ${host} data-start="0" data-duration="10" data-track-index="2">
<video data-hf-id="hf-cv" src="b.mp4" muted data-link="lk-1" data-start="0" data-duration="4" data-track-index="0"></video>
<audio data-hf-id="hf-ca" src="b.mp4" data-link="lk-1" data-start="0" data-duration="4" data-track-index="1"></audio>
</div>
</div>`.trim();

it("trims each audio against its own video's baseline", async () => {
const comp = await openComposition(TWO_SCOPES_HTML);
comp.dispatch({ type: "setTiming", target: ["hf-v", cv], duration: 0.5 });
const html = comp.serialize();
expect([attr(html, "hf-v", "data-start"), attr(html, "hf-v", "data-duration")]).toEqual([
"2",
"0.5",
]);
expect([attr(html, "hf-a", "data-start"), attr(html, "hf-a", "data-duration")]).toEqual([
"2",
"0.5",
]);
expect([attr(html, "hf-cv", "data-start"), attr(html, "hf-cv", "data-duration")]).toEqual([
"0",
"0.5",
]);
expect([attr(html, "hf-ca", "data-start"), attr(html, "hf-ca", "data-duration")]).toEqual([
"0",
"0.5",
]);
});

it("refuses against the scoped baseline when one scope's audio would cross its video's end", async () => {
const crossing = TWO_SCOPES_HTML.replace(
'data-hf-id="hf-a" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6"',
'data-hf-id="hf-a" src="talk.mp4" data-link="lk-1" data-start="3" data-duration="5"',
);
const comp = await openComposition(crossing);
const before = comp.serialize();
const op = { type: "setTiming" as const, target: ["hf-v", cv], duration: 0.5 };
expect(comp.can(op)).toMatchObject({ ok: false, code: "E_LINKED_PARTNER_CROSSED" });
expect(() => comp.dispatch(op)).toThrow(/linked audio would start after the new end/i);
expect(comp.serialize()).toBe(before);
expect(comp.canUndo()).toBe(false);
});
},
);
});
25 changes: 19 additions & 6 deletions packages/studio/src/components/editor/mediaLinkEdits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
pickDetachedAudioTrack,
removeElementInSource,
setLinkInSource,
sharesSourceFile,
} from "./mediaLinkEdits";

const automation = JSON.stringify({
Expand Down Expand Up @@ -192,6 +193,15 @@ describe("linkInSource", () => {
});
});

describe("linkInSource without a sync origin", () => {
it("writes only data-link", () => {
const src = '<video id="v"></video><audio id="a"></audio>';
expect(linkInSource(src, [{ id: "v" }, { id: "a" }], { syncOrigin: false })).toBe(
'<video id="v" data-link="lk-1"></video><audio id="a" data-link="lk-1"></audio>',
);
});
});

describe("setLinkInSource", () => {
it("writes and removes data-link on every target", () => {
const src = '<video id="v"></video><audio id="a"></audio>';
Expand Down Expand Up @@ -248,9 +258,12 @@ describe("predicates", () => {
expect(findMergePair(v, [v, el("a", "audio", { src: "other.mp4" })])).toBeNull();
});

it("links exactly one video and one audio of the same file and timing", () => {
it("links exactly one unlinked video and one audio, whatever their timing or file", () => {
expect(canLinkPair([el("v", "video"), el("a", "audio")])).toBe(true);
expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3 })])).toBe(false);
expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3, duration: 2 })])).toBe(true);
expect(canLinkPair([el("v", "video"), el("a", "audio", { src: "other.mp3" })])).toBe(true);
expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio")])).toBe(false);
expect(canLinkPair([el("v", "video"), el("a", "audio", { link: "y" })])).toBe(false);
expect(canLinkPair([el("v", "video"), el("w", "video")])).toBe(false);
expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio", { link: "x" })])).toBe(
false,
Expand All @@ -266,16 +279,16 @@ describe("predicates compare the whole asset path", () => {
const v = el("v", "video", { muted: true, ...one });
const a = el("a", "audio", two);
expect(findMergePair(v, [v, a])).toBeNull();
expect(canLinkPair([el("v", "video", one), el("a", "audio", two)])).toBe(false);
expect(sharesSourceFile([el("v", "video", one), el("a", "audio", two)])).toBe(false);
});

it("resolves each src against its own source file, and links only inside one", () => {
const v = el("v", "video", { src: "../assets/talk.mp4", sourceFile: "scenes/a.html" });
const a = el("a", "audio", { src: "./assets/talk.mp4", sourceFile: "index.html" });
expect(canLinkPair([v, a])).toBe(false);
expect(
canLinkPair([v, { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" }]),
).toBe(true);
expect(sharesSourceFile([v, a])).toBe(false);
const sameFileAudio = { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" };
expect(sharesSourceFile([v, sameFileAudio])).toBe(true);
});

it("does not take a same-id link from another source file as the merge partner", () => {
Expand Down
47 changes: 34 additions & 13 deletions packages/studio/src/components/editor/mediaLinkEdits.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
import { MEDIA_LINK_ATTR, SYNC_ORIGIN_ATTR, mintLinkId } from "@hyperframes/core/media-link";
import {
MEDIA_LINK_ATTR,
SYNC_ORIGIN_ATTR,
mintLinkId,
sourceZeroTime,
} from "@hyperframes/core/media-link";
import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
import {
Expand Down Expand Up @@ -62,14 +67,17 @@ export function setLinkInSource(
);
}

/** Link the targets under one freshly minted id, which is also their sync origin. */
export function linkInSource(source: string, targets: readonly PatchTarget[]): string {
/** Link the targets under one fresh id, also their sync origin when they share a source file. */
export function linkInSource(
source: string,
targets: readonly PatchTarget[],
options: { syncOrigin: boolean } = { syncOrigin: true },
): string {
const linkId = mintLinkIdForSource(source);
return targets.reduce(
(html, target) =>
applyOps(html, target, [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]),
source,
);
const ops = options.syncOrigin
? [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]
: [dataOp(LINK_PROPERTY, linkId)];
return targets.reduce((html, target) => applyOps(html, target, ops), source);
}

/** Remove one element (open tag through its matching close tag) and its line's indentation. */
Expand Down Expand Up @@ -242,15 +250,28 @@ export function findMergePair<T extends TimedElement>(
return video.muted === true ? { video, audio } : null;
}

/** Exactly one video and one audio, same file, identical timing, not already linked together. */
/** One video and one audio in one composition, neither linked: timing and file don't matter. */
export function canLinkPair(selected: readonly TimedElement[]): boolean {
if (selected.length !== 2) return false;
const [a, b] = selected;
if (!a || !b) return false;
if (!a || !b || a.link || b.link) return false;
const tags = new Set([tagOf(a), tagOf(b)]);
if (!tags.has("video") || !tags.has("audio")) return false;
if (sharesLinkGroup(a, b)) return false;
return sameAssetInScope(a, b) && hasIdenticalTiming(a, b);
return tags.has("video") && tags.has("audio") && sameCompositionScope(a, b);
}

export function sharesSourceFile(selected: readonly TimedElement[]): boolean {
const [a, b] = selected;
return selected.length === 2 && !!a && !!b && sameAssetInScope(a, b);
}

export function isPairInSync(a: TimedElement, b: TimedElement): boolean {
const timing = (el: TimedElement) => ({
start: el.start,
mediaStart: el.playbackStart ?? 0,
playbackRate: el.playbackRate ?? 1,
});
const [x, y] = [timing(a), timing(b)];
return near(x.playbackRate, y.playbackRate) && near(sourceZeroTime(x), sourceZeroTime(y));
}

type TrackedElement = Pick<TimelineElement, "tag" | "track" | "start" | "duration"> & {
Expand Down
Loading
Loading