From 11a24b804de7add1aee1b8099090bcd630c84004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Jul 2026 00:09:33 +0000 Subject: [PATCH 1/3] fix: offset nested template video timing --- packages/cli/src/commands/snapshot.test.ts | 21 +++++++++ packages/cli/src/commands/snapshot.ts | 50 ++++++++++++++++------ packages/core/src/runtime/init.test.ts | 49 +++++++++++++++++++++ packages/core/src/runtime/init.ts | 14 ++++-- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 76f466aac9..a536fc792d 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -5,6 +5,7 @@ import { formatSnapshotTimestamp, parseZoomScale, requireSnapshotFfmpeg, + resolveSnapshotVideoClipStart, resolveSnapshotVideoFrameTime, tailFrameTime, } from "./snapshot.js"; @@ -154,6 +155,26 @@ describe("resolveSnapshotVideoFrameTime", () => { }); }); +describe("resolveSnapshotVideoClipStart", () => { + it("offsets a scene-local video start by its later template host", () => { + expect( + resolveSnapshotVideoClipStart({ + authoredStart: 0, + templateHostStart: 3, + }), + ).toBe(3); + }); + + it("keeps top-level video starts unchanged", () => { + expect( + resolveSnapshotVideoClipStart({ + authoredStart: 3, + templateHostStart: null, + }), + ).toBe(3); + }); +}); + describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => { it("default frames: last point is the readable tail, never exact duration", () => { const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5 }); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index b48cf5ab18..7e7b1a337e 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -93,6 +93,16 @@ export function resolveSnapshotVideoFrameTime(input: { return Math.max(0, Math.min(relativeTime, sourceEnd - 1 / 30)); } +/** Convert a video's scene-local authored start into root timeline time when + * it lives inside a template-mounted composition host. Top-level videos have + * no template host and therefore retain their authored start unchanged. */ +export function resolveSnapshotVideoClipStart(input: { + authoredStart: number; + templateHostStart: number | null; +}): number { + return input.authoredStart + (input.templateHostStart ?? 0); +} + export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string { if (ffmpegPath) return ffmpegPath; throw new Error( @@ -399,10 +409,16 @@ async function captureSnapshots( if (cameraExpr) await page.evaluate(cameraExpr); if (injectVideoFramesBatch && syncVideoFrameVisibility) { - const candidates = await page.evaluate((t: number) => { + const candidates = await page.evaluate(() => { return Array.from(document.querySelectorAll("video[data-start]")).map((el) => { const v = el as HTMLVideoElement; - const start = parseFloat(v.dataset.start ?? "0") || 0; + const authoredStart = parseFloat(v.dataset.start ?? "0") || 0; + const templateHost = v.closest( + "[data-composition-src], [data-composition-file]", + ); + const templateHostStart = templateHost + ? parseFloat(templateHost.dataset.start ?? "0") || 0 + : null; const rawRate = v.defaultPlaybackRate; const playbackRate = Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1; @@ -416,30 +432,40 @@ async function captureSnapshots( : srcDur > 0 ? Math.max(0, (srcDur - mediaStart) / playbackRate) : Number.POSITIVE_INFINITY; - let relTime = (t - start) * playbackRate + mediaStart; - if (v.loop && srcDur > mediaStart && relTime >= srcDur) { - relTime = mediaStart + ((relTime - mediaStart) % (srcDur - mediaStart)); - } return { id: v.id, src: v.currentSrc || v.src, - start, + authoredStart, + templateHostStart, duration, srcDuration: srcDur, - relTime, + playbackRate, + mediaStart, + loop: v.loop, }; }); - }, time); + }); const active = candidates.flatMap((candidate) => { + const start = resolveSnapshotVideoClipStart(candidate); + let relTime = (time - start) * candidate.playbackRate + candidate.mediaStart; + if ( + candidate.loop && + candidate.srcDuration > candidate.mediaStart && + relTime >= candidate.srcDuration + ) { + relTime = + candidate.mediaStart + + ((relTime - candidate.mediaStart) % (candidate.srcDuration - candidate.mediaStart)); + } if (!candidate.id || !candidate.src) return []; const frameTime = resolveSnapshotVideoFrameTime({ globalTime: time, - clipStart: candidate.start, + clipStart: start, clipDuration: candidate.duration, - relativeTime: candidate.relTime, + relativeTime: relTime, sourceDuration: candidate.srcDuration, }); - return frameTime === null ? [] : [{ ...candidate, relTime: frameTime }]; + return frameTime === null ? [] : [{ ...candidate, start, relTime: frameTime }]; }); const updates: Array<{ videoId: string; dataUri: string }> = []; diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 666a0e4916..28c5275a7f 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -915,6 +915,55 @@ describe("initSandboxRuntimeModular", () => { expect(video.currentTime).toBe(5); }); + it("keeps a scene-local video visible inside a later template-mounted host", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "6"); + root.setAttribute("data-width", "360"); + root.setAttribute("data-height", "640"); + document.body.appendChild(root); + + const firstHost = document.createElement("div"); + firstHost.setAttribute("data-composition-id", "first"); + firstHost.setAttribute("data-composition-file", "compositions/first.html"); + firstHost.setAttribute("data-start", "0"); + firstHost.setAttribute("data-duration", "3"); + root.appendChild(firstHost); + + const firstVideo = document.createElement("video"); + firstVideo.setAttribute("data-start", "0"); + firstVideo.setAttribute("data-duration", "3"); + firstHost.appendChild(firstVideo); + + const secondHost = document.createElement("div"); + secondHost.setAttribute("data-composition-id", "second"); + secondHost.setAttribute("data-composition-file", "compositions/second.html"); + secondHost.setAttribute("data-start", "3"); + secondHost.setAttribute("data-duration", "3"); + root.appendChild(secondHost); + + const secondVideo = document.createElement("video"); + secondVideo.setAttribute("data-start", "0"); + secondVideo.setAttribute("data-duration", "3"); + secondHost.appendChild(secondVideo); + + window.__timelines = { + main: createMockTimeline(6), + first: createMockTimeline(3), + second: createMockTimeline(3), + }; + + initSandboxRuntimeModular(); + window.__player?.renderSeek(4); + + expect(firstHost.style.visibility).toBe("hidden"); + expect(firstVideo.style.visibility).toBe("hidden"); + expect(secondHost.style.visibility).toBe("visible"); + expect(secondVideo.style.visibility).toBe("visible"); + }); + it("updates visibility for timed elements inside nested compositions", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 83d3bab5c6..2e52cf4d75 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -628,10 +628,16 @@ export function initSandboxRuntimeModular(): void { return false; } - const start = - tag === "video" || tag === "audio" - ? resolveMediaStartSeconds(rawNode, 0) - : resolveStartForElement(rawNode, 0); + const isMedia = tag === "video" || tag === "audio"; + const mediaCompositionHost = isMedia + ? rawNode.closest("[data-composition-src], [data-composition-file]") + : null; + const mediaCompositionStart = mediaCompositionHost + ? resolveStartForElement(mediaCompositionHost, 0) + : 0; + const start = isMedia + ? resolveMediaStartSeconds(rawNode, mediaCompositionStart) + : resolveStartForElement(rawNode, 0); let duration = resolveDurationForElement(rawNode); const compId = rawNode.getAttribute("data-composition-id"); if (compId) { From b8a15cf73bdf8b635b43718ad45f6ed220db9932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Jul 2026 00:35:02 +0000 Subject: [PATCH 2/3] test(producer): cover nested sequential video render --- .../meta.json | 13 ++++ .../output/compiled.html | 3 + .../output/output.mp4 | 3 + .../src/compositions/first.html | 23 ++++++++ .../src/compositions/later.html | 23 ++++++++ .../src/index.html | 59 +++++++++++++++++++ .../src/media/source.mp4 | 3 + packages/producer/tests/shard-schedule.json | 1 + 8 files changed, 128 insertions(+) create mode 100644 packages/producer/tests/nested-sequential-video-local-start/meta.json create mode 100644 packages/producer/tests/nested-sequential-video-local-start/output/compiled.html create mode 100644 packages/producer/tests/nested-sequential-video-local-start/output/output.mp4 create mode 100644 packages/producer/tests/nested-sequential-video-local-start/src/compositions/first.html create mode 100644 packages/producer/tests/nested-sequential-video-local-start/src/compositions/later.html create mode 100644 packages/producer/tests/nested-sequential-video-local-start/src/index.html create mode 100644 packages/producer/tests/nested-sequential-video-local-start/src/media/source.mp4 diff --git a/packages/producer/tests/nested-sequential-video-local-start/meta.json b/packages/producer/tests/nested-sequential-video-local-start/meta.json new file mode 100644 index 0000000000..05a745160c --- /dev/null +++ b/packages/producer/tests/nested-sequential-video-local-start/meta.json @@ -0,0 +1,13 @@ +{ + "name": "Later nested template video stays visible", + "description": "Two template-mounted hosts play sequentially, and each template owns a scene-local video with data-start=0 that reads its half of one generated source. Before the fix, producer capture evaluated the later video's start against root time without its host offset, hid it for the entire second host window, and rendered black instead of the generated green half.", + "tags": ["video", "sub-composition", "regression"], + "minPsnr": 25, + "maxFrameFailures": 2, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 24, + "workers": 1 + } +} diff --git a/packages/producer/tests/nested-sequential-video-local-start/output/compiled.html b/packages/producer/tests/nested-sequential-video-local-start/output/compiled.html new file mode 100644 index 0000000000..2bbf3ec9c3 --- /dev/null +++ b/packages/producer/tests/nested-sequential-video-local-start/output/compiled.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04e1e32b29828b8cbe7ba114ab66081eb5443359eed7e911cf0e2462044b7cd0 +size 2291 diff --git a/packages/producer/tests/nested-sequential-video-local-start/output/output.mp4 b/packages/producer/tests/nested-sequential-video-local-start/output/output.mp4 new file mode 100644 index 0000000000..ccca6a533f --- /dev/null +++ b/packages/producer/tests/nested-sequential-video-local-start/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e56506833244506267cbe6c8bdd971eb2f92fbf96a83ce18c7565c74ecbfcae +size 2829 diff --git a/packages/producer/tests/nested-sequential-video-local-start/src/compositions/first.html b/packages/producer/tests/nested-sequential-video-local-start/src/compositions/first.html new file mode 100644 index 0000000000..89d4ee187a --- /dev/null +++ b/packages/producer/tests/nested-sequential-video-local-start/src/compositions/first.html @@ -0,0 +1,23 @@ + diff --git a/packages/producer/tests/nested-sequential-video-local-start/src/compositions/later.html b/packages/producer/tests/nested-sequential-video-local-start/src/compositions/later.html new file mode 100644 index 0000000000..6eff326d03 --- /dev/null +++ b/packages/producer/tests/nested-sequential-video-local-start/src/compositions/later.html @@ -0,0 +1,23 @@ + diff --git a/packages/producer/tests/nested-sequential-video-local-start/src/index.html b/packages/producer/tests/nested-sequential-video-local-start/src/index.html new file mode 100644 index 0000000000..56a83232a1 --- /dev/null +++ b/packages/producer/tests/nested-sequential-video-local-start/src/index.html @@ -0,0 +1,59 @@ + + + + + + + + +
+
+
+
+ + + diff --git a/packages/producer/tests/nested-sequential-video-local-start/src/media/source.mp4 b/packages/producer/tests/nested-sequential-video-local-start/src/media/source.mp4 new file mode 100644 index 0000000000..c75126f0eb --- /dev/null +++ b/packages/producer/tests/nested-sequential-video-local-start/src/media/source.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b3552325f6a25ea3859be60a6bef131878066aca48d8409e4182f539d144e5f +size 2750 diff --git a/packages/producer/tests/shard-schedule.json b/packages/producer/tests/shard-schedule.json index 828031ea49..2906b945e0 100644 --- a/packages/producer/tests/shard-schedule.json +++ b/packages/producer/tests/shard-schedule.json @@ -13,6 +13,7 @@ "distributedShardCount": 1, "timings": { "animejs-adapter": 17, + "nested-sequential-video-local-start": 8, "audio-mux-parity": 32, "chat": 54, "css-spinner-render-compat": 15, From 72174e8fed443b4d3b16b51ee1bb968481f72eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Jul 2026 01:13:01 +0000 Subject: [PATCH 3/3] fix: share canonical nested media timing --- packages/cli/src/commands/snapshot.test.ts | 15 +++- packages/cli/src/commands/snapshot.ts | 26 +++---- packages/core/src/runtime/init.test.ts | 78 +++++++++++++++++++ packages/core/src/runtime/init.ts | 90 +++++++++++++--------- packages/core/src/runtime/window.d.ts | 6 ++ 5 files changed, 161 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index a536fc792d..fc6155154c 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -160,16 +160,25 @@ describe("resolveSnapshotVideoClipStart", () => { expect( resolveSnapshotVideoClipStart({ authoredStart: 0, - templateHostStart: 3, + runtimeResolvedStart: 3, }), ).toBe(3); }); - it("keeps top-level video starts unchanged", () => { + it("uses the runtime's recursively resolved start for deeply nested media", () => { + expect( + resolveSnapshotVideoClipStart({ + authoredStart: 1, + runtimeResolvedStart: 8, + }), + ).toBe(8); + }); + + it("keeps authored starts as a compatibility fallback", () => { expect( resolveSnapshotVideoClipStart({ authoredStart: 3, - templateHostStart: null, + runtimeResolvedStart: null, }), ).toBe(3); }); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 7e7b1a337e..b05b86e066 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -93,14 +93,13 @@ export function resolveSnapshotVideoFrameTime(input: { return Math.max(0, Math.min(relativeTime, sourceEnd - 1 / 30)); } -/** Convert a video's scene-local authored start into root timeline time when - * it lives inside a template-mounted composition host. Top-level videos have - * no template host and therefore retain their authored start unchanged. */ +/** Prefer the runtime's canonical absolute media start. The authored value is + * only a compatibility fallback for pages built with an older runtime. */ export function resolveSnapshotVideoClipStart(input: { authoredStart: number; - templateHostStart: number | null; + runtimeResolvedStart: number | null; }): number { - return input.authoredStart + (input.templateHostStart ?? 0); + return input.runtimeResolvedStart ?? input.authoredStart; } export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string { @@ -410,15 +409,13 @@ async function captureSnapshots( if (injectVideoFramesBatch && syncVideoFrameVisibility) { const candidates = await page.evaluate(() => { - return Array.from(document.querySelectorAll("video[data-start]")).map((el) => { + const runtimeWindow = window as Window & { + __hfResolveMediaStartSeconds?: (element: Element) => number; + }; + return Array.from(document.querySelectorAll("video")).map((el) => { const v = el as HTMLVideoElement; const authoredStart = parseFloat(v.dataset.start ?? "0") || 0; - const templateHost = v.closest( - "[data-composition-src], [data-composition-file]", - ); - const templateHostStart = templateHost - ? parseFloat(templateHost.dataset.start ?? "0") || 0 - : null; + const runtimeResolvedStart = runtimeWindow.__hfResolveMediaStartSeconds?.(v); const rawRate = v.defaultPlaybackRate; const playbackRate = Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1; @@ -436,7 +433,10 @@ async function captureSnapshots( id: v.id, src: v.currentSrc || v.src, authoredStart, - templateHostStart, + runtimeResolvedStart: + runtimeResolvedStart !== undefined && Number.isFinite(runtimeResolvedStart) + ? runtimeResolvedStart + : null, duration, srcDuration: srcDur, playbackRate, diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 28c5275a7f..ff91e738ed 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -964,6 +964,81 @@ describe("initSandboxRuntimeModular", () => { expect(secondVideo.style.visibility).toBe("visible"); }); + it("resolves media starts through arbitrarily nested composition hosts", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const outerHost = document.createElement("div"); + outerHost.setAttribute("data-composition-id", "outer"); + outerHost.setAttribute("data-composition-file", "outer.html"); + outerHost.setAttribute("data-start", "2"); + outerHost.setAttribute("data-duration", "6"); + root.appendChild(outerHost); + + const innerHost = document.createElement("div"); + innerHost.setAttribute("data-composition-id", "inner"); + innerHost.setAttribute("data-composition-file", "inner.html"); + innerHost.setAttribute("data-start", "3"); + innerHost.setAttribute("data-duration", "3"); + outerHost.appendChild(innerHost); + + const video = document.createElement("video"); + video.setAttribute("data-start", "1"); + video.setAttribute("data-duration", "1"); + innerHost.appendChild(video); + + window.__timelines = { + main: createMockTimeline(10), + outer: createMockTimeline(6), + inner: createMockTimeline(3), + }; + + initSandboxRuntimeModular(); + + expect(window.__hfResolveMediaStartSeconds?.(video)).toBe(6); + window.__player?.renderSeek(5.5); + expect(video.style.visibility).toBe("hidden"); + window.__player?.renderSeek(6.5); + expect(video.style.visibility).toBe("visible"); + }); + + it("uses the canonical resolver for reference starts, auto-start media, and inline hosts", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const intro = document.createElement("section"); + intro.id = "intro"; + intro.setAttribute("data-start", "0"); + intro.setAttribute("data-duration", "2"); + root.appendChild(intro); + + const inlineHost = document.createElement("div"); + inlineHost.setAttribute("data-composition-id", "inline"); + inlineHost.setAttribute("data-start", "intro + 1"); + inlineHost.setAttribute("data-duration", "2"); + root.appendChild(inlineHost); + + const video = document.createElement("video"); + video.setAttribute("data-hf-auto-start", "true"); + video.setAttribute("data-duration", "2"); + inlineHost.appendChild(video); + + window.__timelines = { + main: createMockTimeline(10), + inline: createMockTimeline(2), + }; + + initSandboxRuntimeModular(); + + expect(window.__hfResolveMediaStartSeconds?.(video)).toBe(3); + }); + it("updates visibility for timed elements inside nested compositions", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); @@ -1480,6 +1555,7 @@ describe("initSandboxRuntimeModular", () => { const host = document.createElement("div"); host.setAttribute("data-composition-id", "scene-pip"); + host.setAttribute("data-composition-file", "compositions/pip.html"); host.setAttribute("data-start", "45.40"); host.setAttribute("data-duration", "7.06"); root.appendChild(host); @@ -1509,6 +1585,8 @@ describe("initSandboxRuntimeModular", () => { initSandboxRuntimeModular(); + expect(window.__hfResolveMediaStartSeconds?.(pipVideo)).toBeCloseTo(45.4); + const player = ( window as Window & { __player?: { seek: (timeSeconds: number) => void }; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2e52cf4d75..cf65ecd262 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -53,6 +53,7 @@ import type { PlayerAPI } from "../core.types"; import { swallow } from "./diagnostics"; import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy"; import { installStudioCustomEase } from "./customEase"; +import { parseNumeric } from "./startExpression"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_END_ATTR = "data-hf-authored-end"; @@ -606,22 +607,55 @@ export function initSandboxRuntimeModular(): void { return resolver.resolveDurationForElement(element); }; - const resolveMediaStartSeconds = (element: Element, fallback = 0): number => { - if (!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")) { - // `data-start` is authored relative to the media element's OWN sub- - // composition, not the root timeline — `fallback` carries the host - // composition's resolved absolute start (see syncMediaForCurrentState's - // inheritedStart), so it must be added, not discarded. Skipping it made - // a nested video play from root t=0 instead of holding until its - // parent scene began (issue #1838) — resolveStartForElement's own - // absolute-expression branch already adds this same host offset, this - // fast literal-value path just didn't. - const own = Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0); - return own + fallback; - } - return resolveStartForElement(element, fallback); + const resolveMediaCompositionContext = (element: Element) => { + const compositionRoot = element.closest("[data-composition-id]"); + const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null; + const inheritedDuration = compositionRoot + ? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true }) + : null; + return { compositionRoot, inheritedStart, inheritedDuration }; }; + const resolveAbsoluteMediaStartSeconds = (element: Element): number => { + const context = resolveMediaCompositionContext(element); + const inheritedStart = context.inheritedStart ?? 0; + const authoredStart = parseNumeric(element.getAttribute("data-start")); + if ( + element.hasAttribute("data-hf-auto-start") || + authoredStart == null || + inheritedStart <= 0 + ) { + return resolveStartForElement(element, inheritedStart); + } + + // Both timing conventions exist in shipped projects: + // - composition-local media, e.g. host@20 + video@0 => root@20 + // - legacy root-global PIP media, e.g. host@45.4 + video@45.4 => root@45.4 + // Preserve the global value when its authored window already intersects + // the host's absolute window. Otherwise it is unambiguously local and + // must inherit the recursively-resolved host start. + const authoredDuration = parseNumeric(element.getAttribute("data-duration")); + const hostDuration = context.inheritedDuration; + const hostEnd = hostDuration != null && hostDuration > 0 ? inheritedStart + hostDuration : null; + const authoredEnd = + authoredDuration != null && authoredDuration > 0 + ? authoredStart + authoredDuration + : authoredStart; + const overlapsHostWindow = + hostEnd == null + ? authoredStart >= inheritedStart + : authoredStart < hostEnd && + (authoredEnd > inheritedStart || authoredStart === inheritedStart); + return overlapsHostWindow ? authoredStart : inheritedStart + authoredStart; + }; + + window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds; + runtimeCleanupCallbacks.push(() => { + if (window.__hfResolveMediaStartSeconds === resolveAbsoluteMediaStartSeconds) { + delete window.__hfResolveMediaStartSeconds; + } + }); + const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { @@ -629,14 +663,8 @@ export function initSandboxRuntimeModular(): void { } const isMedia = tag === "video" || tag === "audio"; - const mediaCompositionHost = isMedia - ? rawNode.closest("[data-composition-src], [data-composition-file]") - : null; - const mediaCompositionStart = mediaCompositionHost - ? resolveStartForElement(mediaCompositionHost, 0) - : 0; const start = isMedia - ? resolveMediaStartSeconds(rawNode, mediaCompositionStart) + ? resolveAbsoluteMediaStartSeconds(rawNode) : resolveStartForElement(rawNode, 0); let duration = resolveDurationForElement(rawNode); const compId = rawNode.getAttribute("data-composition-id"); @@ -736,7 +764,7 @@ export function initSandboxRuntimeModular(): void { if (mediaNodes.length === 0) return null; let maxWindowEndSeconds = 0; for (const node of mediaNodes) { - const start = resolveMediaStartSeconds(node, 0); + const start = resolveAbsoluteMediaStartSeconds(node); if (!Number.isFinite(start)) continue; const duration = resolveMediaElementDurationSeconds(node); if (duration == null || duration <= MIN_VALID_TIMELINE_DURATION_SECONDS) continue; @@ -1951,30 +1979,16 @@ export function initSandboxRuntimeModular(): void { }; const syncMediaForCurrentState = () => { - const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { - const compositionRoot = element.closest("[data-composition-id]"); - const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null; - // Media sync follows the authored host window, matching visibility for - // authored composition hosts. Live child timeline duration only fills in - // when no authored timing exists, so seeks clamp against host clip timing. - const inheritedDuration = compositionRoot - ? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true }) - : null; - return { compositionRoot, inheritedStart, inheritedDuration }; - }; const cache = refreshRuntimeMediaCache({ shouldIncludeElement: (element) => element.hasAttribute("data-start") || Boolean(resolveMediaCompositionContext(element).compositionRoot), resolveStartSeconds: (element) => { - const context = resolveMediaCompositionContext( - element as HTMLVideoElement | HTMLAudioElement, - ); - return resolveMediaStartSeconds(element, context.inheritedStart ?? 0); + return resolveAbsoluteMediaStartSeconds(element); }, resolveDurationSeconds: (element) => { const context = resolveMediaCompositionContext(element); - const start = resolveMediaStartSeconds(element, context.inheritedStart ?? 0); + const start = resolveAbsoluteMediaStartSeconds(element); const mediaStart = Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") || 0; diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index d616c7ee79..aaa79ccb47 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -72,6 +72,12 @@ declare global { * freshly-injected `__render_frame__` images. See `forceDispatchSeekEvent`. */ __hfReseekGpu?: (time: number) => void; + /** + * Canonical root-timeline start for a media element. Snapshot capture uses + * this runtime-owned resolver so reference expressions, authored timing + * restoration, and arbitrary composition nesting cannot drift. + */ + __hfResolveMediaStartSeconds?: (element: Element) => number; __HF_PICKER_API?: HyperframePickerApi; gsap?: { timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;