Skip to content
Open
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
29 changes: 29 additions & 0 deletions packages/core/src/runtime/startResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,35 @@ describe("createRuntimeStartTimeResolver", () => {
expect(resolver.resolveStartForElement(video)).toBe(26);
});

it("resolves a reference inside the referencing clip's own composition", () => {
const mount = (hostId: string, start: string) => {
const host = document.createElement("div");
host.id = hostId;
host.setAttribute("data-composition-id", "card");
host.setAttribute("data-start", start);
host.setAttribute("data-duration", "2");
document.body.appendChild(host);

const first = document.createElement("div");
first.id = "first";
first.setAttribute("data-start", "0");
first.setAttribute("data-duration", "1");
host.appendChild(first);

const second = document.createElement("div");
second.setAttribute("data-start", "first");
second.setAttribute("data-duration", "1");
host.appendChild(second);
return second;
};
const secondInA = mount("card-a", "0");
const secondInB = mount("card-b", "2");

const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveStartForElement(secondInA)).toBe(1);
expect(resolver.resolveStartForElement(secondInB)).toBe(3);
});

it("returns fallback when reference target not found", () => {
const el = document.createElement("div");
el.setAttribute("data-start", "nonexistent");
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/runtime/startResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,18 @@ export function createRuntimeStartTimeResolver(params: {
const durationCache = new WeakMap<Element, number | null>();
const visiting = new Set<Element>();

const findReferenceTarget = (refId: string): Element | null => {
const compositionScopeOf = (element: Element): Element | null =>
(element.hasAttribute("data-composition-id") ? element.parentElement : element)?.closest(
"[data-composition-id]",
) ?? null;

const findReferenceTarget = (refId: string, from: Element): Element | null => {
const scope = compositionScopeOf(from);
if (scope) {
for (const candidate of scope.querySelectorAll("[id]")) {
if (candidate.id === refId && compositionScopeOf(candidate) === scope) return candidate;
}
}
const byId = doc.getElementById(refId);
if (byId) return byId;
return (
Expand Down Expand Up @@ -135,7 +146,7 @@ export function createRuntimeStartTimeResolver(params: {
// Negative when a host's in-point is past its start; the host's slot cuts that part.
return resolveHostOffsetForElement(element, fallback) + Math.max(0, expression.value);
}
const target = findReferenceTarget(expression.refId);
const target = findReferenceTarget(expression.refId, element);
if (!target) return fallback;
const targetStart = resolveStartForElementInternal(target, 0);
const targetDuration = resolveDurationForElement(target) ?? 0;
Expand Down
15 changes: 15 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,21 @@ describe("parseVideoElements", () => {
expect(videos.find((v) => v.id === "main")?.start).toBe(9);
});

it("resolves a reference inside its own composition when another composition reuses the id", () => {
const videos = parseVideoElements(
'<div data-composition-id="main">' +
'<div data-composition-id="scene-a" data-start="0" data-duration="4">' +
'<div id="intro" data-start="0" data-duration="2"></div>' +
"</div>" +
'<div data-composition-id="scene-b" data-start="4" data-duration="4">' +
'<div id="intro" data-start="0" data-duration="3"></div>' +
'<video id="clip" src="b.mp4" data-start="intro" data-duration="1"></video>' +
"</div>" +
"</div>",
);
expect(videos.find((v) => v.id === "clip")?.start).toBe(3);
});

it("falls back to 0 (never NaN) for an unknown reference target", () => {
const videos = parseVideoElements(
'<video id="orphan" src="a.mp4" data-start="does-not-exist" data-duration="5"></video>',
Expand Down
17 changes: 17 additions & 0 deletions packages/parsers/src/referenceResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js";

describe("resolveReferencedStart", () => {
it("still resolves through an adapter that only implements getAttribute", () => {
const element = (attrs: Record<string, string>): RefResolverEl => ({
getAttribute: (name) => attrs[name] ?? null,
});
const intro = element({ id: "intro", "data-start": "1", "data-duration": "3" });
const clip = element({ id: "clip", "data-start": "intro + 1" });
const doc = {
getElementById: (id: string) => (id === "intro" ? intro : null),
querySelector: () => null,
};
expect(resolveReferencedStart(doc, clip, new Map(), new Set())).toBe(5);
});
});
30 changes: 27 additions & 3 deletions packages/parsers/src/referenceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,38 @@ import { parseNumeric, parseStartExpression } from "./compositionContract.js";
/** Minimal structural DOM shape the reference resolver needs. */
export interface RefResolverEl {
getAttribute(name: string): string | null;
readonly parentElement?: RefResolverEl | null;
closest?(selector: string): RefResolverEl | null;
querySelectorAll?(selector: string): Iterable<RefResolverEl>;
}
export interface RefResolverDoc {
getElementById(id: string): RefResolverEl | null;
querySelector(selector: string): RefResolverEl | null;
}

// By `id`, then `data-composition-id`. The reference grammar allows only `[A-Za-z0-9_.:-]`, so no CSS.escape.
function findReferenceTargetEl(doc: RefResolverDoc, refId: string): RefResolverEl | null {
function compositionScopeOf(el: RefResolverEl): RefResolverEl | null {
return (
(el.getAttribute("data-composition-id") !== null ? el.parentElement : el)?.closest?.(
"[data-composition-id]",
) ?? null
);
}

// By `id` in the clip's own composition, then document-wide by `id`, then `data-composition-id`.
// The reference grammar allows only `[A-Za-z0-9_.:-]`, so no CSS.escape.
function findReferenceTargetEl(
doc: RefResolverDoc,
refId: string,
from: RefResolverEl,
): RefResolverEl | null {
const scope = compositionScopeOf(from);
if (scope) {
for (const candidate of scope.querySelectorAll?.("[id]") ?? []) {
if (candidate.getAttribute("id") === refId && compositionScopeOf(candidate) === scope) {
return candidate;
}
}
}
return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`);
}

Expand All @@ -40,7 +64,7 @@ export function resolveReferencedStart(
startCache.set(el, value);
return value;
}
const target = findReferenceTargetEl(doc, expression.refId);
const target = findReferenceTargetEl(doc, expression.refId, el);
if (!target) {
startCache.set(el, 0);
return 0;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/session.timings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ describe("getElementTimings — relative data-start references", () => {

it("resolves a colliding bare id to the TOP-LEVEL match, not a same-scope sibling", async () => {
// Bare ids have no scope syntax — resolveScoped's bare-id rule prefers the
// canonical top-level match when one exists, same as the runtime's own (also
// global, not scope-aware) resolver. Both the outer document AND the sub-comp
// canonical top-level match when one exists. The runtime's `id` lookup prefers
// the clip's own composition instead. Both the outer document AND the sub-comp
// author an element with the SAME bare id "hf-intro" — a genuine collision.
const html = `
<!DOCTYPE html><html><body>
Expand Down
6 changes: 3 additions & 3 deletions packages/sdk/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,9 @@ class CompositionImpl implements Composition {
this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null;
}

// refId remains a bare global id, matching runtime resolution. The shared
// contract owns parsing, canonical duration preference, clamping, and
// legacy data-end compatibility; this adapter only supplies document lookup.
// refId resolves as a bare hf-id (top level first), unlike the runtime's
// composition-scoped `id` lookup. The shared contract owns parsing, canonical
// duration preference, clamping, and legacy data-end; this adapter only supplies lookup.
const timingCache = new Map<Element, ClipTiming>();
const visiting = new Set<Element>();
const resolveTiming = (el: Element): ClipTiming => {
Expand Down