Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions packages/core/src/generators/hyperframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ function generateZoomGsapAnimations(
function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): string {
const baseAttrs = [
`id="${element.id}"`,
`data-hf-id="${element.id}"`,
`data-start="${element.startTime}"`,
`data-end="${element.startTime + element.duration}"`,
`data-layer="${element.zIndex}"`,
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/parsers/hfIds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,24 @@ describe("ensureHfIds", () => {
expect(a).toMatch(/^hf-[a-z0-9]{4}$/);
expect(b).toMatch(/^hf-[a-z0-9]{4}$/);
});

// Post-persist stability: once data-hf-id is written back to source, edits
// don't drift the id because the attribute is already present and pinned.
it("pinned id survives text edit after first persist", () => {
const raw = `<!doctype html><html><body><div>original text</div></body></html>`;
const persisted = ensureHfIds(raw); // simulates write-back on first serve
const [originalId] = ids(persisted);
const edited = persisted.replace("original text", "edited text");
expect(ids(ensureHfIds(edited))).toContain(originalId);
});

it("pinned id survives attribute edit after first persist", () => {
const raw = `<!doctype html><html><body><div class="old">text</div></body></html>`;
const persisted = ensureHfIds(raw); // simulates write-back on first serve
const [originalId] = ids(persisted);
const edited = persisted.replace('class="old"', 'class="new"');
expect(ids(ensureHfIds(edited))).toContain(originalId);
});
});

// Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/parsers/hfIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ function contentKey(el: Element): string {
return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`;
}

/**
* Collision tiebreak for byte-identical siblings: document-order dup counter
* (`hash(key#N)`). This IS order-dependent — two identical `<span></span>`
* get different ids based on which comes first in the DOM. This is unavoidable:
* unique ids for byte-identical elements require a positional signal.
*
* Why this is safe in practice: once `ensureHfIds` write-back persists
* `data-hf-id` to source the attribute is physically bound to its element.
* Reordering identical siblings carries the attribute along → zero
* order-dependence post-persist. `ensureHfIds` skips pinned elements
* (`if (el.getAttribute("data-hf-id")) continue`), so normal operation
* never re-exposes the ordering after first persist.
*/
export function mintHfId(el: Element, assigned: Set<string>): string {
const key = contentKey(el);
let id = toHfId(fnv1a(key));
Expand Down
29 changes: 16 additions & 13 deletions packages/core/src/parsers/htmlParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ describe("parseHtml", () => {
const result = parseHtml(html);

expect(result.elements).toHaveLength(2);
expect(result.elements[0].id).toBe("text1");
expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/);
expect(result.elements[0].startTime).toBe(0);
expect(result.elements[0].duration).toBe(5);
expect(result.elements[0].name).toBe("Title");
expect(result.elements[0].type).toBe("text");

expect(result.elements[1].id).toBe("text2");
expect(result.elements[1].id).toMatch(/^hf-[a-z0-9]{4}$/);
expect(result.elements[1].startTime).toBe(2);
expect(result.elements[1].duration).toBe(5);
});
Expand All @@ -53,7 +53,7 @@ describe("parseHtml", () => {

expect(result.elements).toHaveLength(1);
expect(result.elements[0].type).toBe("composition");
expect(result.elements[0].id).toBe("comp1");
expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/);
if (result.elements[0].type === "composition") {
expect(result.elements[0].compositionId).toBe("abc123");
expect(result.elements[0].src).toBe("/compositions/abc123");
Expand All @@ -76,20 +76,20 @@ describe("parseHtml", () => {

expect(result.elements).toHaveLength(3);

const video = result.elements.find((e) => e.id === "vid1");
const video = result.elements.find((e) => e.type === "video");
expect(video).toBeDefined();
expect(video?.type).toBe("video");
expect(video?.id).toMatch(/^hf-[a-z0-9]{4}$/);
if (video?.type === "video") {
expect(video.src).toBe("video.mp4");
}

const audio = result.elements.find((e) => e.id === "aud1");
const audio = result.elements.find((e) => e.type === "audio");
expect(audio).toBeDefined();
expect(audio?.type).toBe("audio");
expect(audio?.id).toMatch(/^hf-[a-z0-9]{4}$/);

const img = result.elements.find((e) => e.id === "img1");
const img = result.elements.find((e) => e.type === "image");
expect(img).toBeDefined();
expect(img?.type).toBe("image");
expect(img?.id).toMatch(/^hf-[a-z0-9]{4}$/);
});

it("handles missing attributes gracefully", () => {
Expand Down Expand Up @@ -123,7 +123,7 @@ describe("parseHtml", () => {
const result = parseHtml(html);

expect(result.elements).toHaveLength(1);
expect(result.elements[0].id).toMatch(/^element-\d+$/);
expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/);
});

it("extracts GSAP script from script tags", () => {
Expand Down Expand Up @@ -398,9 +398,12 @@ describe("parseHtml", () => {
`;
const result = parseHtml(html);

expect(result.keyframes["text1"]).toBeDefined();
expect(result.keyframes["text1"]).toHaveLength(2);
expect(result.keyframes["text1"][0].id).toBe("kf1");
const elId = result.elements[0]?.id ?? "";
expect(elId).toMatch(/^hf-[a-z0-9]{4}$/);
const kfs = result.keyframes[elId];
expect(kfs).toBeDefined();
expect(kfs).toHaveLength(2);
expect(kfs[0].id).toBe("kf1");
});

it("parses stage zoom keyframes", () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/parsers/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
CompositionVariable,
} from "../core.types";
import { validateCompositionGsap } from "./gsapSerialize";
import { ensureHfIds } from "./hfIds.js";
import type { ValidationResult } from "../core.types";

const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
Expand Down Expand Up @@ -156,8 +157,9 @@ function resolveResolutionFromDimensions(width: number, height: number): CanvasR
}

export function parseHtml(html: string): ParsedHtml {
const withIds = ensureHfIds(html);
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const doc = parser.parseFromString(withIds, "text/html");

const elements: TimelineElement[] = [];
const keyframes: Record<string, Keyframe[]> = {};
Expand Down Expand Up @@ -190,7 +192,16 @@ export function parseHtml(html: string): ParsedHtml {
duration = 5;
}

const id = el.id || `element-${++idCounter}`;
// R1: stable hf- id minted by ensureHfIds above; clips just read it.
// Legacy/migration note: ensureHfIds pins a pre-existing `data-hf-id`, and
// the generator emits `data-hf-id="${element.id}"`. So a clip authored
// before R1 with `id="my-title"` round-trips as `data-hf-id="my-title"` —
// a non-`hf-`-shaped but still stable, exact-match handle. This is safe
// indefinitely: targeting uses exact `[data-hf-id="…"]` match (it does not
// require the hf- prefix). ensureHfIds skips elements that already carry
// data-hf-id, so legacy values are NOT re-minted automatically — they
// persist until the user re-saves the composition through Studio. Not a bug.
const id = el.getAttribute("data-hf-id") || el.id || `element-${++idCounter}`;
const name = getElementName(el);
const zIndex = getZIndex(el);

Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/parsers/stableIds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { serialize } from "./test-utils.js";
describe("T2 — stable element ids (spec for R1)", () => {
// --- Spec (red until R1) ---

it.fails("[spec] elements without an id get a hf- prefixed id at parse", () => {
it("[spec] elements without an id get a hf- prefixed id at parse", () => {
const html = `<html><body><div id="stage">
<img src="logo.svg" data-start="0" data-end="5" data-name="Logo" />
<div data-start="0" data-end="5" data-name="Card"><div>Text</div></div>
Expand All @@ -29,7 +29,7 @@ describe("T2 — stable element ids (spec for R1)", () => {
}
});

it.fails("[spec] generated hf- ids match /^hf-[a-z0-9]{4}$/", () => {
it("[spec] generated hf- ids match /^hf-[a-z0-9]{4}$/", () => {
const html = `<html><body><div id="stage">
<div data-start="0" data-end="5" data-name="Unnamed"><div>X</div></div>
<video data-start="1" data-end="6" src="v.mp4" data-name="Clip"></video>
Expand All @@ -41,7 +41,7 @@ describe("T2 — stable element ids (spec for R1)", () => {
}
});

it.fails("[spec] adding an element before existing ones does not change existing ids", () => {
it("[spec] adding an element before existing ones does not change existing ids", () => {
const base = `<html><body><div id="stage">
<div data-start="0" data-end="5" data-name="AlphaEl"><div>A</div></div>
<div data-start="1" data-end="6" data-name="BetaEl"><div>B</div></div>
Expand All @@ -62,12 +62,12 @@ describe("T2 — stable element ids (spec for R1)", () => {

// --- Baseline (already pass, must not regress) ---

it("elements with an existing id keep it unchanged", () => {
it("existing data-hf-id is pinned and becomes the clip id (never re-minted)", () => {
const html = `<html><body><div id="stage">
<div id="my-title" data-start="0" data-end="5" data-name="Title"><div>Hi</div></div>
<div data-hf-id="hf-anch" data-start="0" data-end="5" data-name="Title"><div>Hi</div></div>
</div></body></html>`;
const { elements } = parseHtml(html);
expect(elements.some((e) => e.id === "my-title")).toBe(true);
expect(elements.some((e) => e.id === "hf-anch")).toBe(true);
});

it("ids are deterministic: same input produces same ids on re-parse", () => {
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/studio-api/helpers/hfIdPersist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js";

describe("normalizeHfIds", () => {
it("marks changed=true and adds data-hf-id to all body elements when untagged", () => {
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
const { html, changed } = normalizeHfIds(raw);
expect(changed).toBe(true);
expect(html).toContain('data-hf-id="hf-');
const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
expect(matches?.length).toBeGreaterThanOrEqual(2);
});

it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => {
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
const first = normalizeHfIds(raw).html;
const { html, changed } = normalizeHfIds(first);
expect(changed).toBe(false);
expect(html).toBe(first);
});
});

describe("persistHfIdsIfNeeded", () => {
const tmpDirs: string[] = [];

afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});

function tmpFile(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
tmpDirs.push(dir);
const file = join(dir, "index.html");
writeFileSync(file, content, "utf-8");
return file;
}

it("writes data-hf-id to disk when source is untagged", () => {
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
const file = tmpFile(raw);
const returned = persistHfIdsIfNeeded(file, raw);
expect(returned).toContain('data-hf-id="hf-');
const onDisk = readFileSync(file, "utf-8");
expect(onDisk).toContain('data-hf-id="hf-');
expect(onDisk).toBe(returned);
});

it("does not rewrite disk when source is already tagged", () => {
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
const file = tmpFile(raw);
const tagged = persistHfIdsIfNeeded(file, raw);
const diskAfterFirst = readFileSync(file, "utf-8");
const returned2 = persistHfIdsIfNeeded(file, tagged);
expect(returned2).toBe(tagged);
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
});

it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
const file = tmpFile(raw);
const result = persistHfIdsIfNeeded(file, raw);
const onDisk = readFileSync(file, "utf-8");
expect(result).toBe(onDisk);
});
});
21 changes: 21 additions & 0 deletions packages/core/src/studio-api/helpers/hfIdPersist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ensureHfIds } from "../../parsers/hfIds.js";
import { writeFileSync } from "node:fs";

export { ensureHfIds };

export function normalizeHfIds(html: string): { html: string; changed: boolean } {
const normalized = ensureHfIds(html);
return { html: normalized, changed: normalized !== html };
}

export function persistHfIdsIfNeeded(filePath: string, html: string): string {
const { html: normalized, changed } = normalizeHfIds(html);
if (changed) {
try {
writeFileSync(filePath, normalized, "utf-8");
} catch {
// non-fatal — serve with ids even if persist fails
}
}
return normalized;
}
Loading
Loading