From 39879d5e19a2c8e5e1aca68046e57f6497deb444 Mon Sep 17 00:00:00 2001 From: Val Date: Tue, 15 Sep 2026 11:42:42 -0700 Subject: [PATCH] fix(producer): bound nested media by a host's data-duration when it has no data-end The runtime hides a slot's descendants at data-start + data-duration, and the compiler only stamps data-end on media tags, so a composition host authored with data-duration was unbounded for the media planner: audio and video nested in a shortened scene kept playing over the next scene. The template-wrapped compile test asserted that unbounded end (a 4s clip in a 2s slot ending at 6); it now expects the slot end. --- .../src/services/htmlCompiler.test.ts | 6 +++-- .../src/services/renderMediaCollector.test.ts | 24 +++++++++++++++++-- .../src/services/renderMediaCollector.ts | 15 +++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index edfb63a8b0..95b49a8d56 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -1224,17 +1224,19 @@ describe("template-wrapped sub-composition media offsets", () => { const compiled = await compileForRender(projectDir, indexPath, projectDir); + // The 4s clip closes with its 2s host (data-start 2 + data-duration 2), + // not at its own authored end. expect(compiled.videos).toHaveLength(1); expect(compiled.videos[0]).toMatchObject({ id: "scene-video", start: 2, - end: 6, + end: 4, }); expect(compiled.audios).toHaveLength(1); expect(compiled.audios[0]).toMatchObject({ id: "scene-video-audio", start: 2, - end: 6, + end: 4, }); }); diff --git a/packages/producer/src/services/renderMediaCollector.test.ts b/packages/producer/src/services/renderMediaCollector.test.ts index 67e52049fe..f06a269c59 100644 --- a/packages/producer/src/services/renderMediaCollector.test.ts +++ b/packages/producer/src/services/renderMediaCollector.test.ts @@ -17,6 +17,25 @@ describe("collectRenderMedia host windows", () => { expect(videos.find((v) => v.id === "blue")).toMatchObject({ start: 2, end: 4 }); }); + it("closes a host authored with data-duration but no data-end", () => { + // A slot shortened to 2s over a 4s scene file: the runtime hides the + // scene's descendants past 2s, so the planner must stop its media there. + const html = + `
` + + `` + + `` + + `
` + + `
` + + `` + + `
`; + + const { videos, audios } = collectRenderMedia(html); + expect(audios.find((a) => a.id === "hook-sound")).toMatchObject({ start: 0, end: 2 }); + expect(videos.find((v) => v.id === "late")).toBeUndefined(); + // A host whose start is an id-ref is bounded at resolved start + duration. + expect(audios.find((a) => a.id === "body-sound")).toMatchObject({ start: 2, end: 4 }); + }); + it("preserves an explicitly marked legacy-global media window", () => { const html = `
` + @@ -27,7 +46,8 @@ describe("collectRenderMedia host windows", () => { const { videos, audios } = collectRenderMedia(html); expect(videos.find((video) => video.id === "local")).toMatchObject({ start: 4, end: 6 }); expect(videos.find((video) => video.id === "global")).toMatchObject({ start: 2, end: 4 }); - expect(audios.find((audio) => audio.id === "local-audio")).toMatchObject({ start: 4, end: 0 }); - expect(audios.find((audio) => audio.id === "global-audio")).toMatchObject({ start: 2, end: 0 }); + // Open-ended audio tracks close with the host (data-start 2 + data-duration 6). + expect(audios.find((audio) => audio.id === "local-audio")).toMatchObject({ start: 4, end: 8 }); + expect(audios.find((audio) => audio.id === "global-audio")).toMatchObject({ start: 2, end: 8 }); }); }); diff --git a/packages/producer/src/services/renderMediaCollector.ts b/packages/producer/src/services/renderMediaCollector.ts index db890615ce..ec7c2c2c20 100644 --- a/packages/producer/src/services/renderMediaCollector.ts +++ b/packages/producer/src/services/renderMediaCollector.ts @@ -41,6 +41,19 @@ import { */ const COMPOSITION_HOST_ATTR = "data-composition-file"; +/** + * Where a composition host closes, in its parent's time. `data-end` wins; a + * host authored with only `data-duration` closes at start + duration — the + * same window the runtime uses to hide the host's descendants, so nested + * media stops with the scene instead of running to the scene file's end. + */ +function resolveHostEnd(host: Element, hostStart: number): number | null { + const end = parseNumeric(host.getAttribute("data-end")); + if (end != null) return end; + const duration = parseNumeric(host.getAttribute("data-duration")); + return duration == null ? null : hostStart + duration; +} + interface HostWindow { /** Seconds to add to a descendant's authored, scene-relative start. */ offset: number; @@ -84,7 +97,7 @@ function resolveHostWindow( // parentElement walks leaf → root; the offsets accumulate root → leaf. for (const host of hosts.reverse()) { const hostStart = resolveReferencedStart(document, host, startCache, visiting); - const hostEnd = parseNumeric(host.getAttribute("data-end")); + const hostEnd = resolveHostEnd(host, hostStart); if (hostEnd != null) limit = Math.min(limit, offset + hostEnd); offset += hostStart; }