fix(engine): resolve relative data-start references in video-frame extraction

* fix(engine): resolve relative data-start references in video-frame extraction

<video data-start="intro"> (a relative reference to another clip's end) is
resolved by the browser runtime but parseVideoElements/parseImageElements did a
raw parseFloat, yielding NaN start/end. The FrameLookupTable active-window
checks (start <= t <= end) are then always false, so the clip is never injected
and composites BLANK in the final render — while lint/validate/inspect/snapshot
and the live preview all look fine. The docs' Relative Timing section teaches
exactly this pattern on <video>.

Share the pure reference-syntax parser (parseStartExpression) out of the runtime
resolver into @hyperframes/core, and resolve references in the extractor against
the linkedom document it already holds: a reference resolves to the target
clip's resolved start + its duration (data-duration or data-end) + offset,
mirroring the runtime. Cycle-guarded; an unknown target or unknown duration
falls back to the target's start / 0 (never NaN), matching runtime semantics.
Natural-media-duration-only targets aren't known at parse time (same limit as
the runtime's fallback). parseImageElements gets the same fix.

Runtime resolver behavior is unchanged (its 25-case suite still passes).

* chore: re-trigger CI to refresh a stuck CodeQL aggregate check
This commit is contained in:
Miguel Ángel
2026-07-07 18:11:21 -04:00
committed by GitHub
parent 42a209545b
commit 4a36655b2b
5 changed files with 246 additions and 39 deletions
@@ -259,6 +259,74 @@ describe("parseVideoElements", () => {
loop: true,
});
});
it("resolves a relative data-start reference to another clip's end", () => {
const videos = parseVideoElements(
'<video id="intro" src="a.mp4" data-start="0" data-duration="10"></video>' +
'<video id="main" src="b.mp4" data-start="intro" data-duration="20"></video>',
);
const main = videos.find((v) => v.id === "main");
// intro ends at 10, so main starts at 10 and ends at 30 — not NaN.
expect(main?.start).toBe(10);
expect(main?.end).toBe(30);
});
it("applies + and - offsets on a relative reference", () => {
const videos = parseVideoElements(
'<video id="intro" src="a.mp4" data-start="0" data-duration="10"></video>' +
'<video id="gap" src="b.mp4" data-start="intro + 2" data-duration="5"></video>' +
'<video id="overlap" src="c.mp4" data-start="intro - 0.5" data-duration="5"></video>',
);
expect(videos.find((v) => v.id === "gap")?.start).toBe(12);
expect(videos.find((v) => v.id === "overlap")?.start).toBe(9.5);
});
it("resolves chained references (A -> B -> C)", () => {
const videos = parseVideoElements(
'<video id="a" src="a.mp4" data-start="0" data-duration="4"></video>' +
'<video id="b" src="b.mp4" data-start="a" data-duration="3"></video>' +
'<video id="c" src="c.mp4" data-start="b" data-duration="2"></video>',
);
expect(videos.find((v) => v.id === "b")?.start).toBe(4);
expect(videos.find((v) => v.id === "c")?.start).toBe(7); // 4 + 3
});
it("resolves a reference to a non-video timed element (div clip)", () => {
const videos = parseVideoElements(
'<div id="title" data-start="0" data-duration="6"></div>' +
'<video id="clip" src="b.mp4" data-start="title" data-duration="5"></video>',
);
expect(videos.find((v) => v.id === "clip")?.start).toBe(6);
});
it("derives a referenced clip's duration from data-end when data-duration is absent", () => {
const videos = parseVideoElements(
'<video id="intro" src="a.mp4" data-start="2" data-end="9"></video>' +
'<video id="main" src="b.mp4" data-start="intro" data-duration="5"></video>',
);
// intro: start 2, end 9 -> duration 7 -> main starts at 9.
expect(videos.find((v) => v.id === "main")?.start).toBe(9);
});
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>',
);
const orphan = videos.find((v) => v.id === "orphan");
expect(orphan?.start).toBe(0);
expect(Number.isNaN(orphan?.start)).toBe(false);
expect(orphan?.end).toBe(5);
});
it("does not hang or NaN on a circular reference", () => {
const videos = parseVideoElements(
'<video id="a" src="a.mp4" data-start="b" data-duration="4"></video>' +
'<video id="b" src="b.mp4" data-start="a" data-duration="3"></video>',
);
for (const v of videos) {
expect(Number.isNaN(v.start)).toBe(false);
}
});
});
describe("FrameLookupTable", () => {
@@ -334,6 +402,21 @@ describe("FrameLookupTable", () => {
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
});
it("places a relative-reference video in its resolved window end-to-end (was blank)", () => {
// The reported bug: <video data-start="intro"> gave NaN start/end, so the
// active-window checks (start <= t <= end) were always false and the clip
// composited blank. With resolution, `main` is active across [10, 30].
const videos = parseVideoElements(
'<video id="intro" src="a.mp4" data-start="0" data-duration="10"></video>' +
'<video id="main" src="b.mp4" data-start="intro" data-duration="20"></video>',
);
const table = createFrameLookupTable(videos, [{ ...fakeExtracted(600, 30), videoId: "main" }]);
expect(table.getActiveFramePayloads(5).has("main")).toBe(false); // before resolved start (10)
expect(table.getActiveFramePayloads(15).has("main")).toBe(true); // within [10, 30]
expect(table.getActiveFramePayloads(29).has("main")).toBe(true);
expect(table.getActiveFramePayloads(31).has("main")).toBe(false); // after resolved end (30)
});
it("holds the last frame at the inclusive clip end (t === end)", () => {
// clip [1,3] with exactly 2s of source frames (60 @ 30fps). The frame
// landing on t === end used to deactivate one frame early and render blank,