Skip to content
Draft
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
5 changes: 4 additions & 1 deletion packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/pla
import {
parseTimelineFromDOM,
createTimelineElementFromManifestClip,
createTimelineDomIndex,
findTimelineDomNodeForClip,
createImplicitTimelineLayersFromDOM,
buildStandaloneRootTimelineElement,
Expand Down Expand Up @@ -222,16 +223,18 @@ export function useTimelineSyncCallbacks({

const usedHostEls = new Set<Element>();
markManifestDeriveStart();
const timelineDomIndex = iframeDoc ? createTimelineDomIndex(iframeDoc) : undefined;
const els: TimelineElement[] = filtered.map((clip, index) => {
const hostEl = iframeDoc
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls, timelineDomIndex)
: null;
if (hostEl) usedHostEls.add(hostEl);
return createTimelineElementFromManifestClip({
clip,
fallbackIndex: index,
doc: iframeDoc,
hostEl,
index: timelineDomIndex,
});
});
markManifestDeriveEnd();
Expand Down
171 changes: 170 additions & 1 deletion packages/studio/src/player/lib/timelineDOM.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
createTimelineElementFromManifestClip,
parseTimelineFromDOM,
createImplicitTimelineLayersFromDOM,
mergeTimelineElementsPreservingDowngrades,
createTimelineDomIndex,
findTimelineDomNodeForClip,
type TimelineDomIndex,
} from "./timelineDOM";
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import { generateStudioLoadFixture } from "../../../tests/e2e/generateStudioLoadFixture.mjs";

function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
Expand All @@ -18,6 +23,47 @@ function makeDoc(html: string): Document {
return d;
}

function manifestClip(element: Element, index: number): ClipManifestClip {
const compositionId = element.getAttribute("data-composition-id");
return {
id: element.id || element.getAttribute("data-hf-id"),
label: `Clip ${index}`,
start: Number(element.getAttribute("data-start")),
duration: Number(element.getAttribute("data-duration")),
track: Number(element.getAttribute("data-track-index")),
kind: compositionId ? "composition" : "element",
tagName: element.tagName.toLowerCase(),
compositionId,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
};
}

/**
* The whole per-clip derivation, exactly as processTimelineMessage runs it.
* Async only so the un-indexed arm — which is quadratic by construction, ~80s
* at 3,000 clips under jsdom — yields often enough to keep vitest's worker RPC
* heartbeat alive; a fully synchronous run makes the suite exit non-zero.
*/
async function deriveManifestElements(
doc: Document,
clips: readonly ClipManifestClip[],
index?: TimelineDomIndex,
): Promise<TimelineElement[]> {
const used = new Set<Element>();
const elements: TimelineElement[] = [];
for (const [fallbackIndex, clip] of clips.entries()) {
const hostEl = findTimelineDomNodeForClip(doc, clip, fallbackIndex, used, index);
if (hostEl) used.add(hostEl);
elements.push(
createTimelineElementFromManifestClip({ clip, fallbackIndex, doc, hostEl, index }),
);
if (fallbackIndex % 100 === 99) await new Promise((resolve) => setTimeout(resolve, 0));
}
return elements;
}

describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
it("harvests hfId from a data-start element that has data-hf-id", () => {
const doc = makeDoc(`
Expand Down Expand Up @@ -207,6 +253,129 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
});
});

describe("createTimelineElementFromManifestClip — indexed identity parity", () => {
it("keeps every id and key byte-identical across the generated 3,000-clip fixture", async () => {
const files = generateStudioLoadFixture({ clipCount: 3_000, trackCount: 150 });
const doc = makeDoc(files["index.html"]);
const subComposition = makeDoc(files["compositions/load-details.html"]);
const template = subComposition.querySelector("template");
const host = doc.querySelector('[data-composition-id="studio-load-details"]');
if (!(template instanceof HTMLTemplateElement) || !host) {
throw new Error("invalid generated fixture");
}
host.append(template.content.cloneNode(true));

const nodes = Array.from(doc.querySelectorAll(".clip"));
nodes.forEach((node, index) => {
if (!node.id && index % 29 !== 0) node.setAttribute("data-hf-id", `hf-${index}`);
});
const clips = nodes.map(manifestClip);
const nullIdCount = clips.filter((clip) => clip.id == null).length;
// Both arms run the FULL derivation, node resolution included — handing the
// legacy arm a pre-resolved hostEl would compare only the identity builder
// and could not see the indexed path resolving a clip to a different node.
const legacy = await deriveManifestElements(doc, clips);
const indexed = await deriveManifestElements(doc, clips, createTimelineDomIndex(doc));

expect(nodes).toHaveLength(3_000);
expect(nullIdCount).toBeGreaterThan(0);
expect(indexed.map(({ id, key }) => ({ id, key }))).toEqual(
legacy.map(({ id, key }) => ({ id, key })),
);
}, 300_000);

it("keeps a null-id clip's selector occurrence identity unchanged", async () => {
const doc = makeDoc(`
<main data-composition-id="root" data-composition-file="index.html">
<div class="clip card" data-start="0" data-duration="4" data-track-index="0"></div>
<div class="clip card" data-start="4" data-duration="4" data-track-index="1"></div>
</main>
`);
const clip: ClipManifestClip = {
id: null,
label: "Card",
start: 4,
duration: 4,
track: 1,
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
};
const legacy = await deriveManifestElements(doc, [clip]);
const indexed = await deriveManifestElements(doc, [clip], createTimelineDomIndex(doc));

expect(indexed[0]).toMatchObject({
id: legacy[0]?.id,
key: legacy[0]?.key,
selector: ".card",
selectorIndex: 1,
});
});

it("resolves an inlined composition without extra document queries", async () => {
const doc = makeDoc(`
<main data-composition-id="root" data-composition-file="index.html">
<section data-hf-id="host-hf" data-composition-id="nested"
data-composition-file="nested.html" data-start="0" data-duration="4"></section>
</main>
`);
const clip: ClipManifestClip = {
id: "host-hf",
label: "Nested",
start: 0,
duration: 4,
track: 0,
kind: "composition",
tagName: "section",
compositionId: "nested",
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
};
const legacy = (await deriveManifestElements(doc, [clip]))[0];
const index = createTimelineDomIndex(doc);
const querySpy = vi.spyOn(doc, "querySelector");
const indexed = (await deriveManifestElements(doc, [clip], index))[0];

expect(indexed).toMatchObject({
compositionSrc: legacy?.compositionSrc,
domId: legacy?.domId,
hfId: legacy?.hfId,
id: legacy?.id,
key: legacy?.key,
});
expect(querySpy).not.toHaveBeenCalled();
});

it("bounds document-wide queries for a 500-clip manifest including an inlined composition", async () => {
const clipsMarkup = Array.from(
{ length: 499 },
(_, index) =>
`<div class="clip card" data-hf-id="hf-${index}" data-start="${index}" data-duration="1" data-track-index="${index}"></div>`,
).join("");
const doc = makeDoc(`
<main data-composition-id="root" data-composition-file="index.html">
${clipsMarkup}
<section class="clip card" data-hf-id="composition-host" data-composition-id="nested"
data-composition-file="nested.html" data-start="499" data-duration="1" data-track-index="499"></section>
</main>
`);
const clips = Array.from(doc.querySelectorAll(".clip"), manifestClip);
const queryAllSpy = vi.spyOn(doc, "querySelectorAll");
const querySpy = vi.spyOn(doc, "querySelector");
const index = createTimelineDomIndex(doc);
const elements = await deriveManifestElements(doc, clips, index);

expect(elements).toHaveLength(500);
expect(elements.at(-1)?.compositionSrc).toBe("nested.html");
expect(queryAllSpy).toHaveBeenCalledTimes(1);
expect(querySpy).not.toHaveBeenCalled();
});
});

describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
it("uses the runtime root paint scope for implicit siblings of manifest clips", () => {
const doc = makeDoc(`
Expand Down
19 changes: 13 additions & 6 deletions packages/studio/src/player/lib/timelineDOM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getTimelineElementIdentity,
isTimelineIgnoredElement,
readTimelineElementZIndex,
type TimelineDomIndex,
} from "./timelineElementHelpers";

// Re-export helpers that were previously public from this module so that
Expand All @@ -46,7 +47,9 @@ export {
buildTimelineElementIdentity,
// fallow-ignore-next-line unused-exports
getTimelineElementIdentity,
createTimelineDomIndex,
findTimelineDomNodeForClip,
type TimelineDomIndex,
} from "./timelineElementHelpers";

// Re-export iframe helpers so the hook can keep a single import source.
Expand All @@ -73,8 +76,9 @@ export function createTimelineElementFromManifestClip(params: {
fallbackIndex: number;
doc?: Document | null;
hostEl?: Element | null;
index?: TimelineDomIndex;
}): TimelineElement {
const { clip, fallbackIndex, doc } = params;
const { clip, fallbackIndex, doc, index } = params;
let hostEl = params.hostEl ?? null;
const label = getTimelineElementDisplayLabel({
id: clip.id,
Expand All @@ -93,8 +97,8 @@ export function createTimelineElementFromManifestClip(params: {
hfId = hostEl.getAttribute("data-hf-id") || undefined;
selector = getTimelineElementSelector(hostEl);
selectorIndex =
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
sourceFile = getTimelineElementSourceFile(hostEl);
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector, index) : undefined;
sourceFile = getTimelineElementSourceFile(hostEl, index);
}

const identity = buildTimelineElementIdentity({
Expand Down Expand Up @@ -147,7 +151,10 @@ export function createTimelineElementFromManifestClip(params: {
let resolvedSrc = clip.compositionSrc;
if (!resolvedSrc) {
hostEl =
doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
(index
? index.byCompositionId.get(clip.compositionId)
: doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`)) ??
hostEl;
resolvedSrc =
hostEl?.getAttribute("data-composition-src") ??
hostEl?.getAttribute("data-composition-file") ??
Expand All @@ -168,9 +175,9 @@ export function createTimelineElementFromManifestClip(params: {
entry.selector = getTimelineElementSelector(hostEl);
entry.selectorIndex =
doc && entry.selector
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector, index)
: undefined;
entry.sourceFile = getTimelineElementSourceFile(hostEl);
entry.sourceFile = getTimelineElementSourceFile(hostEl, index);
const nextIdentity = buildTimelineElementIdentity({
preferredId: clip.id,
label,
Expand Down
Loading
Loading