Skip to content

Commit c6bb2fe

Browse files
vanceingallsclaude
andcommitted
fix(sdk,studio,lint): keep link groups inside inline compositions
A link group now stays inside the nearest data-composition-id or data-composition-file ancestor. The SDK used to recognise only file hosts, and Studio compared only sourceFile, so an inline child composition that reused a link id was edited together with the root group. core/media-link linkScopeOf is the shared rule for the SDK and Studio. Lint cannot import core, so it uses the same two attributes directly. Studio rows record their compositionScope, and link and merge refuse pairs that cross it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 88255f8 commit c6bb2fe

11 files changed

Lines changed: 142 additions & 28 deletions

File tree

‎packages/core/src/mediaLink.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ export function linkTimingMismatches(members: readonly AttributeReader[]): strin
5353
).map(([, field]) => field);
5454
}
5555

56+
/** The composition a link group stays inside: the nearest inline composition or file host. */
57+
export function linkScopeOf(el: Element): Element | null {
58+
return el.parentElement?.closest("[data-composition-id], [data-composition-file]") ?? null;
59+
}
60+
5661
export function mintLinkId(taken: Iterable<string>): string {
5762
const used = new Set(taken);
5863
let n = 1;

‎packages/lint/src/rules/linkedClips.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,14 @@ describe("linked clip rules", () => {
7070
[],
7171
);
7272
});
73+
74+
it("treats an inlined file host as its own composition too", async () => {
75+
const hosted = `<div id="host" data-composition-file="child.html" data-start="0">
76+
<video ${VIDEO.replace('id="talk"', 'id="h-talk"').replace('data-start="2"', 'data-start="5"')}></video>
77+
</div>`;
78+
const findings = await linkFindings(
79+
`<video ${VIDEO}></video><audio ${AUDIO}></audio>${hosted}`,
80+
);
81+
expect(findings.map((f) => [f.code, f.elementId])).toEqual([["linked_clip_orphan", "h-talk"]]);
82+
});
7383
});

‎packages/lint/src/rules/linkedClips.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ function compositionScopeOf(tag: OpenTag, compositions: readonly OpenTag[]): num
3939
}
4040

4141
function groupByLink(tags: readonly OpenTag[]): Map<string, { link: string; members: OpenTag[] }> {
42-
const compositions = tags.filter((tag) => readAttr(tag.raw, "data-composition-id") !== null);
42+
const compositions = tags.filter((tag) =>
43+
["data-composition-id", "data-composition-file"].some(
44+
(name) => readAttr(tag.raw, name) !== null,
45+
),
46+
);
4347
const groups = new Map<string, { link: string; members: OpenTag[] }>();
4448
for (const tag of tags) {
4549
const link = readAttr(tag.raw, "data-link");

‎packages/sdk/src/engine/linkedTiming.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { MEDIA_LINK_ATTR } from "@hyperframes/core/media-link";
1+
import { MEDIA_LINK_ATTR, linkScopeOf } from "@hyperframes/core/media-link";
22
import type { HfId } from "../types.js";
3-
import { escapeHfId, isNewHostBoundary, querySelectorAllDeep, resolveScoped } from "./model.js";
3+
import { escapeHfId, querySelectorAllDeep, resolveScoped } from "./model.js";
44

55
interface LinkMember {
66
id: HfId;
@@ -12,22 +12,15 @@ function scopePrefix(id: HfId): string {
1212
return cut < 0 ? "" : id.slice(0, cut + 1);
1313
}
1414

15-
function hostScopeOf(el: Element): Element | null {
16-
for (let cur = el.parentElement; cur; cur = cur.parentElement) {
17-
if (isNewHostBoundary(cur)) return cur;
18-
}
19-
return null;
20-
}
21-
2215
/** Every element sharing `id`'s link, addressed in `id`'s scope, `id` included. */
2316
function linkGroup(document: Document, id: HfId): LinkMember[] {
2417
const el = resolveScoped(document, id);
2518
const link = el?.getAttribute(MEDIA_LINK_ATTR);
2619
if (!el || !link) return [];
27-
const scope = hostScopeOf(el);
20+
const scope = linkScopeOf(el);
2821
const prefix = scopePrefix(id);
2922
return querySelectorAllDeep(scope ?? document, `[${MEDIA_LINK_ATTR}="${escapeHfId(link)}"]`)
30-
.filter((member) => hostScopeOf(member) === scope)
23+
.filter((member) => linkScopeOf(member) === scope)
3124
.map((member) => member.getAttribute("data-hf-id"))
3225
.filter((hfId): hfId is string => Boolean(hfId))
3326
.map((hfId) => ({ id: `${prefix}${hfId}`, link }));

‎packages/sdk/src/session.linked.test.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,16 @@ describe("setTiming on linked clips", () => {
5656
expect(attr(html, "hf-talk-audio", "data-start")).toBe("4");
5757
});
5858

59-
it("keeps a link group inside its own composition when a child reuses the id", async () => {
59+
it.each([
60+
'data-composition-id="child" data-composition-file="child.html"',
61+
'data-composition-file="child.html"',
62+
])("keeps a link group inside its own composition (host %s)", async (host) => {
6063
const comp = await openComposition(
6164
`
6265
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
6366
<video data-hf-id="hf-v" src="talk.mp4" muted data-link="lk-1" data-start="0" data-duration="4" data-track-index="0"></video>
6467
<audio data-hf-id="hf-a" src="talk.mp4" data-link="lk-1" data-start="0" data-duration="4" data-track-index="1"></audio>
65-
<div data-hf-id="hf-host" data-composition-id="child" data-composition-file="child.html" data-start="0" data-duration="10" data-track-index="2">
68+
<div data-hf-id="hf-host" ${host} data-start="0" data-duration="10" data-track-index="2">
6669
<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>
6770
<audio data-hf-id="hf-ca" src="b.mp4" data-link="lk-1" data-start="0" data-duration="4" data-track-index="1"></audio>
6871
</div>
@@ -79,4 +82,32 @@ describe("setTiming on linked clips", () => {
7982
expect(attr(html, "hf-v", "data-start")).toBe("2");
8083
expect(attr(html, "hf-a", "data-start")).toBe("2");
8184
});
85+
86+
it("keeps a link group inside an inline composition that reuses the id", async () => {
87+
const comp = await openComposition(
88+
`
89+
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
90+
<video data-hf-id="hf-v" src="talk.mp4" muted data-link="lk-1" data-start="0" data-duration="4" data-track-index="0"></video>
91+
<audio data-hf-id="hf-a" src="talk.mp4" data-link="lk-1" data-start="0" data-duration="4" data-track-index="1"></audio>
92+
<div data-hf-id="hf-child" data-composition-id="child" data-start="0" data-duration="10" data-track-index="2">
93+
<video data-hf-id="hf-cv" src="b.mp4" muted data-link="lk-1" data-start="5" data-duration="4" data-track-index="0"></video>
94+
<audio data-hf-id="hf-ca" src="b.mp4" data-link="lk-1" data-start="5" data-duration="4" data-track-index="1"></audio>
95+
</div>
96+
</div>`.trim(),
97+
);
98+
comp.setTiming("hf-v", { start: 2 });
99+
let html = comp.serialize();
100+
expect(attr(html, "hf-a", "data-start")).toBe("2");
101+
expect(attr(html, "hf-cv", "data-start")).toBe("5");
102+
expect(attr(html, "hf-ca", "data-start")).toBe("5");
103+
comp.setTiming("hf-cv", { start: 6 });
104+
html = comp.serialize();
105+
expect(attr(html, "hf-ca", "data-start")).toBe("6");
106+
expect(attr(html, "hf-v", "data-start")).toBe("2");
107+
comp.setTiming("hf-ca", { start: 7 }, { linked: false });
108+
html = comp.serialize();
109+
expect(attr(html, "hf-cv", "data-link")).toBeNull();
110+
expect(attr(html, "hf-v", "data-link")).toBe("lk-1");
111+
expect(attr(html, "hf-a", "data-link")).toBe("lk-1");
112+
});
82113
});

‎packages/studio/src/components/editor/mediaLinkEdits.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,13 @@ describe("predicates compare the whole asset path", () => {
249249
expect(canLinkPair([el("v", "video", one), el("a", "audio", two)])).toBe(false);
250250
});
251251

252-
it("resolves each src against its own source file", () => {
252+
it("resolves each src against its own source file, and links only inside one", () => {
253253
const v = el("v", "video", { src: "../assets/talk.mp4", sourceFile: "scenes/a.html" });
254254
const a = el("a", "audio", { src: "./assets/talk.mp4", sourceFile: "index.html" });
255-
expect(canLinkPair([v, a])).toBe(true);
255+
expect(canLinkPair([v, a])).toBe(false);
256+
expect(
257+
canLinkPair([v, { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" }]),
258+
).toBe(true);
256259
});
257260

258261
it("does not take a same-id link from another source file as the merge partner", () => {
@@ -261,6 +264,13 @@ describe("predicates compare the whole asset path", () => {
261264
expect(findMergePair(v, [v, a])).toBeNull();
262265
});
263266

267+
it("does not link or merge a pair split across inline compositions", () => {
268+
const v = el("v", "video", { muted: true, compositionScope: "main" });
269+
const a = el("a", "audio", { compositionScope: "child" });
270+
expect(canLinkPair([v, a])).toBe(false);
271+
expect(findMergePair(v, [v, a])).toBeNull();
272+
});
273+
264274
it("does not merge a hidden audio back into a visible video", () => {
265275
const v = el("v", "video", { muted: true });
266276
expect(findMergePair(v, [v, el("a", "audio", { hidden: true })])).toBeNull();

‎packages/studio/src/components/editor/mediaLinkEdits.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
22
import { MEDIA_LINK_ATTR, mintLinkId } from "@hyperframes/core/media-link";
3-
import type { TimelineElement } from "../../player/store/timelineElement";
3+
import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
44
import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
55
import {
66
applyPatchByTarget,
@@ -180,7 +180,7 @@ type TimedElement = Pick<
180180
TimelineElement,
181181
"id" | "key" | "tag" | "src" | "start" | "duration" | "playbackStart" | "playbackRate" | "link"
182182
> &
183-
Pick<TimelineElement, "muted" | "hasAudio" | "hidden" | "sourceFile">;
183+
Pick<TimelineElement, "muted" | "hasAudio" | "hidden" | "sourceFile" | "compositionScope">;
184184

185185
const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
186186
const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
@@ -195,9 +195,9 @@ function hasIdenticalTiming(a: TimedElement, b: TimedElement): boolean {
195195
);
196196
}
197197

198-
const sameFile = (a: TimedElement, b: TimedElement) => {
198+
const sameAssetInScope = (a: TimedElement, b: TimedElement) => {
199199
const asset = mediaAssetIdentity(a);
200-
return asset !== null && asset === mediaAssetIdentity(b);
200+
return asset !== null && asset === mediaAssetIdentity(b) && sameCompositionScope(a, b);
201201
};
202202

203203
/** A video whose sound is on the video itself: what Detach audio acts on. */
@@ -217,7 +217,8 @@ export function findMergePair<T extends TimedElement>(
217217
if (!isVideo && tagOf(element) !== "audio") return null;
218218
const partnerTag = isVideo ? "audio" : "video";
219219
const candidates = elements.filter(
220-
(el) => keyOf(el) !== keyOf(element) && tagOf(el) === partnerTag && sameFile(el, element),
220+
(el) =>
221+
keyOf(el) !== keyOf(element) && tagOf(el) === partnerTag && sameAssetInScope(el, element),
221222
);
222223
const partner =
223224
candidates.find((el) => sharesLinkGroup(el, element)) ??
@@ -237,7 +238,7 @@ export function canLinkPair(selected: readonly TimedElement[]): boolean {
237238
const tags = new Set([tagOf(a), tagOf(b)]);
238239
if (!tags.has("video") || !tags.has("audio")) return false;
239240
if (sharesLinkGroup(a, b)) return false;
240-
return sameFile(a, b) && hasIdenticalTiming(a, b);
241+
return sameAssetInScope(a, b) && hasIdenticalTiming(a, b);
241242
}
242243

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

‎packages/studio/src/player/components/audioClipLink.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @vitest-environment happy-dom
12
import { describe, expect, it } from "vitest";
23
import {
34
audioPillFlags,
@@ -6,6 +7,7 @@ import {
67
linkedMembersOf,
78
mediaAssetIdentity,
89
} from "./audioClipLink";
10+
import { createTimelineElementFromManifestClip, parseTimelineFromDOM } from "../lib/timelineDOM";
911

1012
const video = { id: "talk", link: "lk-1" };
1113
const audio = { id: "talk-audio", link: "lk-1" };
@@ -65,7 +67,7 @@ describe("expandToLinkedMembers", () => {
6567
});
6668
});
6769

68-
describe("link groups stay inside their source file", () => {
70+
describe("link groups stay inside their composition", () => {
6971
const root = [
7072
{ id: "v", link: "lk-1", sourceFile: undefined },
7173
{ id: "a", link: "lk-1", sourceFile: undefined },
@@ -82,6 +84,48 @@ describe("link groups stay inside their source file", () => {
8284
new Set(["child.html#cv", "child.html#ca"]),
8385
);
8486
});
87+
88+
const media = (id: string, tag: string, start: number) =>
89+
`<${tag} id="${id}" class="clip" src="${id}.mp4" data-link="lk-1" data-start="${start}" data-duration="4"></${tag}>`;
90+
const inlineDoc = () => {
91+
const doc = document.implementation.createHTMLDocument();
92+
doc.body.innerHTML = `<div data-composition-id="main" data-duration="20">
93+
${media("v", "video", 0)}${media("a", "audio", 0)}
94+
<div id="child" data-composition-id="child" data-start="0" data-duration="10">
95+
${media("cv", "video", 5)}${media("ca", "audio", 5)}
96+
</div>
97+
</div>`;
98+
return doc;
99+
};
100+
101+
it("does not pull an inline composition's same-id link into the root group", () => {
102+
const rows = parseTimelineFromDOM(inlineDoc(), 20).filter((row) => row.link);
103+
const byId = (id: string) => rows.find((row) => row.domId === id) ?? video;
104+
expect(linkedMembersOf(byId("v"), rows).map((row) => row.domId)).toEqual(["v", "a"]);
105+
expect(linkedMembersOf(byId("cv"), rows).map((row) => row.domId)).toEqual(["cv", "ca"]);
106+
});
107+
108+
it("keeps the inline composition apart on rows built from the runtime manifest", () => {
109+
const doc = inlineDoc();
110+
const rows = ["v", "a", "cv", "ca"].map((id, fallbackIndex) =>
111+
createTimelineElementFromManifestClip({
112+
clip: {
113+
...{ id, label: id, start: 0, duration: 4, track: 0, kind: "video", tagName: "video" },
114+
...{
115+
compositionId: null,
116+
parentCompositionId: null,
117+
compositionSrc: null,
118+
assetUrl: null,
119+
},
120+
},
121+
fallbackIndex,
122+
doc,
123+
hostEl: doc.getElementById(id),
124+
}),
125+
);
126+
const [rootVideo] = rows;
127+
expect(linkedMembersOf(rootVideo ?? video, rows).map((row) => row.domId)).toEqual(["v", "a"]);
128+
});
85129
});
86130

87131
describe("linkedMembersOf", () => {

‎packages/studio/src/player/components/audioClipLink.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { TimelineElement } from "../store/timelineElement";
1+
import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
22

3-
type LinkedElement = Pick<TimelineElement, "id" | "key" | "link" | "sourceFile">;
3+
type LinkScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
4+
type LinkedElement = Pick<TimelineElement, "id" | "key" | "link"> & LinkScoped;
45

56
const keyOf = (element: Pick<TimelineElement, "id" | "key">) => element.key ?? element.id;
67

@@ -24,10 +25,10 @@ function isLinked(element: Pick<TimelineElement, "link">): boolean {
2425
}
2526

2627
export function sharesLinkGroup(
27-
a: Pick<TimelineElement, "link" | "sourceFile">,
28-
b: Pick<TimelineElement, "link" | "sourceFile">,
28+
a: Pick<TimelineElement, "link"> & LinkScoped,
29+
b: Pick<TimelineElement, "link"> & LinkScoped,
2930
): boolean {
30-
return isLinked(a) && a.link === b.link && (a.sourceFile ?? "") === (b.sourceFile ?? "");
31+
return isLinked(a) && a.link === b.link && sameCompositionScope(a, b);
3132
}
3233

3334
export function audioPillFlags(

‎packages/studio/src/player/lib/timelineDOM.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
1212
import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
1313
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
1414
import { readClipTiming } from "@hyperframes/core/composition-contract";
15+
import { linkScopeOf } from "@hyperframes/core/media-link";
1516
import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
1617
import { groupInfoFor } from "./timelineGroupInfo";
1718
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
@@ -152,6 +153,8 @@ export function createTimelineElementFromManifestClip(params: {
152153
if (timelineRole) entry.timelineRole = timelineRole;
153154
const link = hostEl.getAttribute("data-link");
154155
if (link) entry.link = link;
156+
const compositionScope = linkScopeOf(hostEl)?.getAttribute("data-composition-id");
157+
if (compositionScope) entry.compositionScope = compositionScope;
155158
const audioGroup = hostEl.getAttribute("data-audio-group");
156159
if (audioGroup) {
157160
entry.audioGroup = audioGroup;
@@ -341,6 +344,8 @@ export function parseTimelineFromDOM(
341344
if (timelineRole) entry.timelineRole = timelineRole;
342345
const domLink = el.getAttribute("data-link");
343346
if (domLink) entry.link = domLink;
347+
const domCompositionScope = linkScopeOf(el)?.getAttribute("data-composition-id");
348+
if (domCompositionScope) entry.compositionScope = domCompositionScope;
344349

345350
const domAudioGroup = el.getAttribute("data-audio-group");
346351
if (domAudioGroup) {

0 commit comments

Comments
 (0)