Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,12 @@
"runtime": "./schemas/registry-item.json",
"types": null,
"environments": ["browser", "bun", "node"]
},
"./timeline-asset-id": {
"source": "./src/timelineAssetId.ts",
"runtime": "./dist/timelineAssetId.js",
"types": "./dist/timelineAssetId.d.ts",
"environments": ["browser", "bun", "node"]
}
}
}
14 changes: 12 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,13 @@
"types": "./src/figma/index.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
"./schemas/registry-item.json": "./schemas/registry-item.json",
"./timeline-asset-id": {
"bun": "./src/timelineAssetId.ts",
"node": "./dist/timelineAssetId.js",
"import": "./src/timelineAssetId.ts",
"types": "./src/timelineAssetId.ts"
}
},
"publishConfig": {
"access": "public",
Expand Down Expand Up @@ -852,7 +858,11 @@
"types": "./dist/figma/index.d.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
"./schemas/registry-item.json": "./schemas/registry-item.json",
"./timeline-asset-id": {
"import": "./dist/timelineAssetId.js",
"types": "./dist/timelineAssetId.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,22 @@ function getFirstClassToken(node: Element): string | null {

function filenameFromAssetUrl(url: string | null): string | null {
if (!url) return null;
let filename: string | undefined;
try {
const parsed = new URL(url, document.baseURI);
return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null;
filename = parsed.pathname.split("/").filter(Boolean).at(-1);
} catch {
return url.split(/[\\/]/).filter(Boolean).at(-1) ?? null;
filename = url
.replace(/[?#].*$/, "")
.split(/[\\/]/)
.filter(Boolean)
.at(-1);
}
if (!filename) return null;
try {
return decodeURIComponent(filename);
} catch {
return filename;
}
}

Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/runtime/timelineMediaLabels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, afterEach } from "vitest";
import { collectRuntimeTimelinePayload } from "./timeline";

describe("media timeline labels", () => {
const defaultParams = { canonicalFps: 30 };
afterEach(() => {
document.body.innerHTML = "";
});
it.each(["video", "audio", "img"])(
"preserves an authored %s id after replacing its source",
(tag) => {
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
<${tag} id="harbor" class="clip harbor" src="assets/harbor.mp4"
data-start="0" data-duration="5"></${tag}>
</div>`;
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Harbor");
document.getElementById("harbor")!.setAttribute("src", "assets/library.mp4");
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Harbor");
},
);

it.each([
["Harbor%20Sunset.mp4", "Harbor Sunset"],
["%E6%B5%B7%E6%B8%AF.mp4", "海港"],
["Harbor%.mp4?v=1.2", "Harbor%"],
])("decodes the generated label from %s", (filename, label) => {
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
<video src="assets/${filename}" data-start="0" data-duration="5"></video>
</div>`;
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe(label);
});

it("decodes a generated filename without a usable document base URL", () => {
const base = document.createElement("base");
base.href = "about:blank";
document.head.appendChild(base);
try {
expect(document.baseURI).toBe("about:blank");
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
<video src="assets/Harbor%20Sunset.mp4?v=1.2"
data-start="0" data-duration="5"></video>
</div>`;
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Harbor Sunset");
} finally {
base.remove();
}
});

it.each(["data-timeline-label", "data-label", "aria-label"])(
"preserves an authored %s after replacing a media source",
(attribute) => {
document.body.innerHTML = `<div data-composition-id="main" data-duration="10">
<video id="harbor" src="assets/harbor.mp4" data-start="0" data-duration="5"></video>
</div>`;
const clip = document.getElementById("harbor")!;
clip.setAttribute(attribute, "Opening Shot");
clip.setAttribute("src", "assets/library.mp4");
expect(collectRuntimeTimelinePayload(defaultParams).clips[0].label).toBe("Opening Shot");
},
);
});
13 changes: 13 additions & 0 deletions packages/core/src/timelineAssetId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { buildTimelineAssetId } from "./timelineAssetId";

describe("timeline asset ids", () => {
it("trims a long underscore prefix and suffix", () => {
const padding = "_".repeat(20000);
expect(buildTimelineAssetId(`assets/${padding}harbor${padding}.mp4`, [])).toBe("harbor");
});

it("mints the fallback when normalization leaves no name", () => {
expect(buildTimelineAssetId("assets/____.mp4", ["asset", "asset_2"])).toBe("asset_3");
});
});
114 changes: 114 additions & 0 deletions packages/core/src/timelineAssetId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
function trimIdUnderscores(value: string): string {
let start = 0;
let end = value.length;
while (value[start] === "_") start += 1;
while (end > start && value[end - 1] === "_") end -= 1;
return value.slice(start, end);
}

export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
const baseName = assetPath.slice(assetPath.lastIndexOf("/") + 1);
const normalized = baseName
.replace(/\.[^.]+$/, "")
.replace(/[^a-zA-Z0-9_-]+/g, "_")
.toLowerCase();
const baseId = trimIdUnderscores(normalized) || "asset";
const ids = new Set(existingIds);
if (!ids.has(baseId)) return baseId;
let suffix = 2;
while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
return `${baseId}_${suffix}`;
}

function referenceChildren(element: Element): Iterable<Element> {
if (element.children.length > 0) return element.children;
if (element.tagName.toLowerCase() !== "template") return [];
const content = (element as HTMLTemplateElement).content;
return content ? content.children : [];
}

function documentElements(document: Document): Element[] {
const elements: Element[] = [];
const visit = (element: Element): void => {
elements.push(element);
for (const child of referenceChildren(element)) visit(child);
};
if (document.documentElement) visit(document.documentElement);
return elements;
}

function decodeIdEscape(original: string, hex: string): string {
const codePoint = Number.parseInt(hex, 16);
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : original;
}

function referencesId(value: string, id: string): boolean {
const decoded = value
.replace(/\\x([0-9a-f]{2})/gi, (original, hex) => decodeIdEscape(original, hex))
.replace(/\\u\{([0-9a-f]+)\}|\\u([0-9a-f]{4})/gi, (original, braced, fixed) =>
decodeIdEscape(original, braced ?? fixed),
)
.replace(/\\([0-9a-f]{1,6})\s?/gi, (original, hex) => decodeIdEscape(original, hex))
.replace(/\\([^\r\n])/g, "$1");
return decoded.includes(id);
}

function decodeFragment(value: string): string {
const fragment = value.split("#").slice(1).join("#");
try {
return decodeURIComponent(fragment);
} catch {
return fragment;
}
}

function attributeReferencesId(attribute: Attr, id: string): boolean {
if (["id", "data-hf-id"].includes(attribute.name)) return false;
if (attribute.name === "src") return referencesId(decodeFragment(attribute.value), id);
return referencesId(attribute.value, id);
}

function elementReferencesId(element: Element, id: string): boolean {
const code = ["script", "style"].includes(element.tagName.toLowerCase());
if (code && referencesId(element.textContent ?? "", id)) return true;
return Array.from(element.attributes).some((attribute) => attributeReferencesId(attribute, id));
}

type MediaIdentity = { id: string; src: string };

function readMediaIdentity(element: Element): MediaIdentity | null {
const media = ["video", "audio", "img"].includes(element.tagName.toLowerCase());
const src = element.getAttribute("src");
const id = element.getAttribute("id");
if (!media || !src || !id) return null;
return { id, src };
}

function matchesGeneratedId({ id, src }: MediaIdentity): boolean {
const base = buildTimelineAssetId(src, []);
const suffix = id.slice(base.length);
return id === base || (id.startsWith(base) && /^_(?:[2-9]\d*|1\d+)$/.test(suffix));
}

function canRenameMedia(element: Element, identity: MediaIdentity, newSrc: string): boolean {
const authored = ["data-timeline-label", "data-label", "aria-label"].some((name) =>
element.getAttribute(name)?.trim(),
);
return !authored && identity.src !== newSrc && matchesGeneratedId(identity);
}

export function replacementTimelineAssetId(
document: Document,
element: Element,
newSrc: string,
): string | null {
const identity = readMediaIdentity(element);
if (!identity || !canRenameMedia(element, identity, newSrc)) return null;
const elements = documentElements(document);
if (elements.some((other) => elementReferencesId(other, identity.id))) return null;
const ids = elements
.filter((other) => other !== element)
.map((other) => other.getAttribute("id"))
.filter((value): value is string => value !== null);
return buildTimelineAssetId(newSrc, ids);
}
103 changes: 103 additions & 0 deletions packages/sdk/src/engine/mutate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,109 @@ describe("setText", () => {
// ─── setAttribute ─────────────────────────────────────────────────────────────

describe("setAttribute", () => {
it("keeps an authored media id", () => {
const parsed = parseMutable(
'<video data-hf-id="hf-media" id="hero-shot" src="harbor.mp4"></video>',
);
applyOp(parsed, {
type: "setAttribute",
target: "hf-media",
name: "src",
value: "library.mp4",
});
expect(parsed.document.querySelector("video")?.id).toBe("hero-shot");
});

it("re-mints a generated media id across two replacements and restores it on undo", () => {
const parsed = parseMutable(
'<video data-hf-id="hf-media" id="harbor_2" src="harbor.mp4"></video><img id="library" src="other.png">',
);
const before = serializeDocument(parsed);
const first = applyOp(parsed, {
type: "setAttribute",
target: "hf-media",
name: "src",
value: "library.mp4",
});
expect(parsed.document.querySelector("video")?.id).toBe("library_2");
const second = applyOp(parsed, {
type: "setAttribute",
target: "hf-media",
name: "src",
value: "sunset.mp4",
});
expect(parsed.document.querySelector("video")?.id).toBe("sunset");
applyPatchesToDocument(parsed, second.inverse);
applyPatchesToDocument(parsed, first.inverse);
expect(serializeDocument(parsed)).toBe(before);
});

it.each(["data-timeline-label", "data-label", "aria-label"])(
"keeps an explicitly labeled generated id: %s",
(name) => {
const parsed = parseMutable(
`<video data-hf-id="hf-media" id="harbor" src="harbor.mp4" ${name}="Opening Shot"></video>`,
);
applyOp(parsed, {
type: "setAttribute",
target: "hf-media",
name: "src",
value: "library.mp4",
});
expect(parsed.document.querySelector("video")?.id).toBe("harbor");
expect(parsed.document.querySelector("video")?.getAttribute(name)).toBe("Opening Shot");
},
);

it("does not treat another clip source as an id reference", () => {
const parsed = parseMutable(
'<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>',
);
applyOp(parsed, {
type: "setAttribute",
target: "hf-media",
name: "src",
value: "library.mp4",
});
expect(parsed.document.querySelector("video")?.id).toBe("library");
});

it("allows replacement with unrelated invalid escapes in authored code", () => {
const parsed = parseMutable(
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>`,
);
applyOp(parsed, {
type: "setAttribute",
target: "hf-media",
name: "src",
value: "library.mp4",
});
expect(parsed.document.querySelector("video")?.id).toBe("library");
});

it.each([
'<script>gsap.to("#harbor", {x: 10})</script>',
"<style>#harbor {opacity: .5}</style>",
'<img src="asset.svg#harbor">',
'<img src="asset.svg#%68arbor">',
'<img src="#harbor">',
String.raw`<style>#\68 arbor {opacity: .5}</style>`,
String.raw`<script>document.getElementById("\x68arbor")</script>`,
'<div aria-labelledby="harbor"></div>',
'<template><div data-start="harbor + 2"></div></template>',
])("keeps a referenced generated id: %s", (reference) => {
const parsed = parseMutable(
'<video data-hf-id="hf-media" id="harbor" src="harbor.mp4"></video>' + reference,
);
applyOp(parsed, {
type: "setAttribute",
target: "hf-media",
name: "src",
value: "library.mp4",
});
expect(parsed.document.querySelector("video")?.id).toBe("harbor");
});

it("sets a new attribute and emits add patch", () => {
const parsed = fresh();
const result = applyOp(parsed, {
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { replacementTimelineAssetId } from "@hyperframes/core/timeline-asset-id";
/**
* Op handlers for Phase 3a (non-parser ops).
*
Expand Down Expand Up @@ -545,6 +546,14 @@ function handleSetAttribute(
const oldValue = el.getAttribute(name);
const path = attrPath(id, name);
if (value !== null) {
const replacementId =
name === "src" ? replacementTimelineAssetId(parsed.document, el, value) : null;
if (replacementId !== null) {
const idPatch = scalarChange(attrPath(id, "id"), el.getAttribute("id"), replacementId);
el.setAttribute("id", replacementId);
result.forward.push(idPatch.forward);
result.inverse.push(idPatch.inverse);
}
el.setAttribute(name, value);
const p = scalarChange(path, oldValue, value);
result.forward.push(p.forward);
Expand Down
Loading
Loading