Skip to content

Commit de4b31a

Browse files
fix(studio): update unreferenced generated media names on replace (#5169)
* fix(core): derive generated media labels from current source * fix(core): decode generated media filenames * fix(core): share filename decoding across URL forms * fix(core): strip filename suffixes without unsafe indexing * fix(studio): rename unreferenced generated media ids on replace * fix(core): register media ID exports and trim names in linear time
1 parent cf1b04b commit de4b31a

11 files changed

Lines changed: 379 additions & 19 deletions

File tree

‎packages/core/package-subpaths.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,12 @@
517517
"runtime": "./schemas/registry-item.json",
518518
"types": null,
519519
"environments": ["browser", "bun", "node"]
520+
},
521+
"./timeline-asset-id": {
522+
"source": "./src/timelineAssetId.ts",
523+
"runtime": "./dist/timelineAssetId.js",
524+
"types": "./dist/timelineAssetId.d.ts",
525+
"environments": ["browser", "bun", "node"]
520526
}
521527
}
522528
}

‎packages/core/package.json‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,13 @@
516516
"types": "./src/figma/index.ts"
517517
},
518518
"./schemas/registry.json": "./schemas/registry.json",
519-
"./schemas/registry-item.json": "./schemas/registry-item.json"
519+
"./schemas/registry-item.json": "./schemas/registry-item.json",
520+
"./timeline-asset-id": {
521+
"bun": "./src/timelineAssetId.ts",
522+
"node": "./dist/timelineAssetId.js",
523+
"import": "./src/timelineAssetId.ts",
524+
"types": "./src/timelineAssetId.ts"
525+
}
520526
},
521527
"publishConfig": {
522528
"access": "public",
@@ -852,7 +858,11 @@
852858
"types": "./dist/figma/index.d.ts"
853859
},
854860
"./schemas/registry.json": "./schemas/registry.json",
855-
"./schemas/registry-item.json": "./schemas/registry-item.json"
861+
"./schemas/registry-item.json": "./schemas/registry-item.json",
862+
"./timeline-asset-id": {
863+
"import": "./dist/timelineAssetId.js",
864+
"types": "./dist/timelineAssetId.d.ts"
865+
}
856866
},
857867
"main": "./dist/index.js",
858868
"types": "./dist/index.d.ts"

‎packages/core/src/runtime/timeline.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,22 @@ function getFirstClassToken(node: Element): string | null {
185185

186186
function filenameFromAssetUrl(url: string | null): string | null {
187187
if (!url) return null;
188+
let filename: string | undefined;
188189
try {
189190
const parsed = new URL(url, document.baseURI);
190-
return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null;
191+
filename = parsed.pathname.split("/").filter(Boolean).at(-1);
191192
} catch {
192-
return url.split(/[\\/]/).filter(Boolean).at(-1) ?? null;
193+
filename = url
194+
.replace(/[?#].*$/, "")
195+
.split(/[\\/]/)
196+
.filter(Boolean)
197+
.at(-1);
198+
}
199+
if (!filename) return null;
200+
try {
201+
return decodeURIComponent(filename);
202+
} catch {
203+
return filename;
193204
}
194205
}
195206

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import { collectRuntimeTimelinePayload } from "./timeline";
3+
4+
describe("media timeline labels", () => {
5+
const defaultParams = { canonicalFps: 30 };
6+
afterEach(() => {
7+
document.body.innerHTML = "";
8+
});
9+
it.each(["video", "audio", "img"])(
10+
"preserves an authored %s id after replacing its source",
11+
(tag) => {
12+
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
13+
<${tag} id="harbor" class="clip harbor" src="assets/harbor.mp4"
14+
data-start="0" data-duration="5"></${tag}>
15+
</div>`;
16+
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Harbor");
17+
document.getElementById("harbor")!.setAttribute("src", "assets/library.mp4");
18+
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Harbor");
19+
},
20+
);
21+
22+
it.each([
23+
["Harbor%20Sunset.mp4", "Harbor Sunset"],
24+
["%E6%B5%B7%E6%B8%AF.mp4", "海港"],
25+
["Harbor%.mp4?v=1.2", "Harbor%"],
26+
])("decodes the generated label from %s", (filename, label) => {
27+
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
28+
<video src="assets/${filename}" data-start="0" data-duration="5"></video>
29+
</div>`;
30+
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe(label);
31+
});
32+
33+
it("decodes a generated filename without a usable document base URL", () => {
34+
const base = document.createElement("base");
35+
base.href = "about:blank";
36+
document.head.appendChild(base);
37+
try {
38+
expect(document.baseURI).toBe("about:blank");
39+
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
40+
<video src="assets/Harbor%20Sunset.mp4?v=1.2"
41+
data-start="0" data-duration="5"></video>
42+
</div>`;
43+
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Harbor Sunset");
44+
} finally {
45+
base.remove();
46+
}
47+
});
48+
49+
it.each(["data-timeline-label", "data-label", "aria-label"])(
50+
"preserves an authored %s after replacing a media source",
51+
(attribute) => {
52+
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
53+
<video id="harbor" src="assets/harbor.mp4" data-start="0" data-duration="5"></video>
54+
</div>`;
55+
const clip = document.getElementById("harbor")!;
56+
clip.setAttribute(attribute, "Opening Shot");
57+
clip.setAttribute("src", "assets/library.mp4");
58+
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Opening Shot");
59+
},
60+
);
61+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildTimelineAssetId } from "./timelineAssetId";
3+
4+
describe("timeline asset ids", () => {
5+
it("trims a long underscore prefix and suffix", () => {
6+
const padding = "_".repeat(20000);
7+
expect(buildTimelineAssetId(`assets/${padding}harbor${padding}.mp4`, [])).toBe("harbor");
8+
});
9+
10+
it("mints the fallback when normalization leaves no name", () => {
11+
expect(buildTimelineAssetId("assets/____.mp4", ["asset", "asset_2"])).toBe("asset_3");
12+
});
13+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
function trimIdUnderscores(value: string): string {
2+
let start = 0;
3+
let end = value.length;
4+
while (value[start] === "_") start += 1;
5+
while (end > start && value[end - 1] === "_") end -= 1;
6+
return value.slice(start, end);
7+
}
8+
9+
export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
10+
const baseName = assetPath.slice(assetPath.lastIndexOf("/") + 1);
11+
const normalized = baseName
12+
.replace(/\.[^.]+$/, "")
13+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
14+
.toLowerCase();
15+
const baseId = trimIdUnderscores(normalized) || "asset";
16+
const ids = new Set(existingIds);
17+
if (!ids.has(baseId)) return baseId;
18+
let suffix = 2;
19+
while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
20+
return `${baseId}_${suffix}`;
21+
}
22+
23+
function referenceChildren(element: Element): Iterable<Element> {
24+
if (element.children.length > 0) return element.children;
25+
if (element.tagName.toLowerCase() !== "template") return [];
26+
const content = (element as HTMLTemplateElement).content;
27+
return content ? content.children : [];
28+
}
29+
30+
function documentElements(document: Document): Element[] {
31+
const elements: Element[] = [];
32+
const visit = (element: Element): void => {
33+
elements.push(element);
34+
for (const child of referenceChildren(element)) visit(child);
35+
};
36+
if (document.documentElement) visit(document.documentElement);
37+
return elements;
38+
}
39+
40+
function decodeIdEscape(original: string, hex: string): string {
41+
const codePoint = Number.parseInt(hex, 16);
42+
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : original;
43+
}
44+
45+
function referencesId(value: string, id: string): boolean {
46+
const decoded = value
47+
.replace(/\\x([0-9a-f]{2})/gi, (original, hex) => decodeIdEscape(original, hex))
48+
.replace(/\\u\{([0-9a-f]+)\}|\\u([0-9a-f]{4})/gi, (original, braced, fixed) =>
49+
decodeIdEscape(original, braced ?? fixed),
50+
)
51+
.replace(/\\([0-9a-f]{1,6})\s?/gi, (original, hex) => decodeIdEscape(original, hex))
52+
.replace(/\\([^\r\n])/g, "$1");
53+
return decoded.includes(id);
54+
}
55+
56+
function decodeFragment(value: string): string {
57+
const fragment = value.split("#").slice(1).join("#");
58+
try {
59+
return decodeURIComponent(fragment);
60+
} catch {
61+
return fragment;
62+
}
63+
}
64+
65+
function attributeReferencesId(attribute: Attr, id: string): boolean {
66+
if (["id", "data-hf-id"].includes(attribute.name)) return false;
67+
if (attribute.name === "src") return referencesId(decodeFragment(attribute.value), id);
68+
return referencesId(attribute.value, id);
69+
}
70+
71+
function elementReferencesId(element: Element, id: string): boolean {
72+
const code = ["script", "style"].includes(element.tagName.toLowerCase());
73+
if (code && referencesId(element.textContent ?? "", id)) return true;
74+
return Array.from(element.attributes).some((attribute) => attributeReferencesId(attribute, id));
75+
}
76+
77+
type MediaIdentity = { id: string; src: string };
78+
79+
function readMediaIdentity(element: Element): MediaIdentity | null {
80+
const media = ["video", "audio", "img"].includes(element.tagName.toLowerCase());
81+
const src = element.getAttribute("src");
82+
const id = element.getAttribute("id");
83+
if (!media || !src || !id) return null;
84+
return { id, src };
85+
}
86+
87+
function matchesGeneratedId({ id, src }: MediaIdentity): boolean {
88+
const base = buildTimelineAssetId(src, []);
89+
const suffix = id.slice(base.length);
90+
return id === base || (id.startsWith(base) && /^_(?:[2-9]\d*|1\d+)$/.test(suffix));
91+
}
92+
93+
function canRenameMedia(element: Element, identity: MediaIdentity, newSrc: string): boolean {
94+
const authored = ["data-timeline-label", "data-label", "aria-label"].some((name) =>
95+
element.getAttribute(name)?.trim(),
96+
);
97+
return !authored && identity.src !== newSrc && matchesGeneratedId(identity);
98+
}
99+
100+
export function replacementTimelineAssetId(
101+
document: Document,
102+
element: Element,
103+
newSrc: string,
104+
): string | null {
105+
const identity = readMediaIdentity(element);
106+
if (!identity || !canRenameMedia(element, identity, newSrc)) return null;
107+
const elements = documentElements(document);
108+
if (elements.some((other) => elementReferencesId(other, identity.id))) return null;
109+
const ids = elements
110+
.filter((other) => other !== element)
111+
.map((other) => other.getAttribute("id"))
112+
.filter((value): value is string => value !== null);
113+
return buildTimelineAssetId(newSrc, ids);
114+
}

‎packages/sdk/src/engine/mutate.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,109 @@ describe("setText", () => {
323323
// ─── setAttribute ─────────────────────────────────────────────────────────────
324324

325325
describe("setAttribute", () => {
326+
it("keeps an authored media id", () => {
327+
const parsed = parseMutable(
328+
'<video data-hf-id="hf-media" id="hero-shot" src="harbor.mp4"></video>',
329+
);
330+
applyOp(parsed, {
331+
type: "setAttribute",
332+
target: "hf-media",
333+
name: "src",
334+
value: "library.mp4",
335+
});
336+
expect(parsed.document.querySelector("video")?.id).toBe("hero-shot");
337+
});
338+
339+
it("re-mints a generated media id across two replacements and restores it on undo", () => {
340+
const parsed = parseMutable(
341+
'<video data-hf-id="hf-media" id="harbor_2" src="harbor.mp4"></video><img id="library" src="other.png">',
342+
);
343+
const before = serializeDocument(parsed);
344+
const first = applyOp(parsed, {
345+
type: "setAttribute",
346+
target: "hf-media",
347+
name: "src",
348+
value: "library.mp4",
349+
});
350+
expect(parsed.document.querySelector("video")?.id).toBe("library_2");
351+
const second = applyOp(parsed, {
352+
type: "setAttribute",
353+
target: "hf-media",
354+
name: "src",
355+
value: "sunset.mp4",
356+
});
357+
expect(parsed.document.querySelector("video")?.id).toBe("sunset");
358+
applyPatchesToDocument(parsed, second.inverse);
359+
applyPatchesToDocument(parsed, first.inverse);
360+
expect(serializeDocument(parsed)).toBe(before);
361+
});
362+
363+
it.each(["data-timeline-label", "data-label", "aria-label"])(
364+
"keeps an explicitly labeled generated id: %s",
365+
(name) => {
366+
const parsed = parseMutable(
367+
`<video data-hf-id="hf-media" id="harbor" src="harbor.mp4" ${name}="Opening Shot"></video>`,
368+
);
369+
applyOp(parsed, {
370+
type: "setAttribute",
371+
target: "hf-media",
372+
name: "src",
373+
value: "library.mp4",
374+
});
375+
expect(parsed.document.querySelector("video")?.id).toBe("harbor");
376+
expect(parsed.document.querySelector("video")?.getAttribute(name)).toBe("Opening Shot");
377+
},
378+
);
379+
380+
it("does not treat another clip source as an id reference", () => {
381+
const parsed = parseMutable(
382+
'<video data-hf-id="hf-media" id="harbor" src="harbor.mp4"></video><video data-hf-id="hf-other" id="harbor_2" src="harbor.mp4"></video>',
383+
);
384+
applyOp(parsed, {
385+
type: "setAttribute",
386+
target: "hf-media",
387+
name: "src",
388+
value: "library.mp4",
389+
});
390+
expect(parsed.document.querySelector("video")?.id).toBe("library");
391+
});
392+
393+
it("allows replacement with unrelated invalid escapes in authored code", () => {
394+
const parsed = parseMutable(
395+
String.raw`<video data-hf-id="hf-media" id="harbor" src="harbor.mp4"></video><style>.other { content: "\ffffff" }</style><script>const unrelated = "\u{110000}";</script>`,
396+
);
397+
applyOp(parsed, {
398+
type: "setAttribute",
399+
target: "hf-media",
400+
name: "src",
401+
value: "library.mp4",
402+
});
403+
expect(parsed.document.querySelector("video")?.id).toBe("library");
404+
});
405+
406+
it.each([
407+
'<script>gsap.to("#harbor", {x: 10})</script>',
408+
"<style>#harbor {opacity: .5}</style>",
409+
'<img src="asset.svg#harbor">',
410+
'<img src="asset.svg#%68arbor">',
411+
'<img src="#harbor">',
412+
String.raw`<style>#\68 arbor {opacity: .5}</style>`,
413+
String.raw`<script>document.getElementById("\x68arbor")</script>`,
414+
'<div aria-labelledby="harbor"></div>',
415+
'<template><div data-start="harbor + 2"></div></template>',
416+
])("keeps a referenced generated id: %s", (reference) => {
417+
const parsed = parseMutable(
418+
'<video data-hf-id="hf-media" id="harbor" src="harbor.mp4"></video>' + reference,
419+
);
420+
applyOp(parsed, {
421+
type: "setAttribute",
422+
target: "hf-media",
423+
name: "src",
424+
value: "library.mp4",
425+
});
426+
expect(parsed.document.querySelector("video")?.id).toBe("harbor");
427+
});
428+
326429
it("sets a new attribute and emits add patch", () => {
327430
const parsed = fresh();
328431
const result = applyOp(parsed, {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { replacementTimelineAssetId } from "@hyperframes/core/timeline-asset-id";
12
/**
23
* Op handlers for Phase 3a (non-parser ops).
34
*
@@ -545,6 +546,14 @@ function handleSetAttribute(
545546
const oldValue = el.getAttribute(name);
546547
const path = attrPath(id, name);
547548
if (value !== null) {
549+
const replacementId =
550+
name === "src" ? replacementTimelineAssetId(parsed.document, el, value) : null;
551+
if (replacementId !== null) {
552+
const idPatch = scalarChange(attrPath(id, "id"), el.getAttribute("id"), replacementId);
553+
el.setAttribute("id", replacementId);
554+
result.forward.push(idPatch.forward);
555+
result.inverse.push(idPatch.inverse);
556+
}
548557
el.setAttribute(name, value);
549558
const p = scalarChange(path, oldValue, value);
550559
result.forward.push(p.forward);

0 commit comments

Comments
 (0)