From a1f32d4414d2e340b5683359d87aa3f99db12b26 Mon Sep 17 00:00:00 2001 From: Val Date: Tue, 15 Sep 2026 11:42:42 -0700 Subject: [PATCH 1/2] fix(producer): bound nested media by a host's data-duration when it has no data-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. The host end now comes from the runtime's own resolveAuthoredTimingWindow rather than a local re-implementation, so precedence matches the runtime exactly: a positive data-duration wins over a conflicting data-end, and a zero or negative data-duration is treated as absent (falling back to data-end, or unbounded) instead of collapsing the host window to its start and dropping every nested clip. Tests cover the conflicting-attribute case and the non-positive-duration fallback. Co-Authored-By: Miguel Ángel --- .../src/services/htmlCompiler.test.ts | 6 +- .../src/services/renderMediaCollector.test.ts | 69 ++++++++++++++++++- .../src/services/renderMediaCollector.ts | 35 +++++++--- 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index edfb63a8b08..95b49a8d566 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 67e52049fe2..1e2af711956 100644 --- a/packages/producer/src/services/renderMediaCollector.test.ts +++ b/packages/producer/src/services/renderMediaCollector.test.ts @@ -17,6 +17,70 @@ 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("prefers data-duration over a conflicting data-end, like the runtime", () => { + // data-start 2 + data-duration 2 closes the host at 4 even though data-end + // says 6; the runtime hides descendants at start + duration. + const html = + `
` + + `` + + `` + + `
`; + + const { videos, audios } = collectRenderMedia(html); + expect(audios.find((a) => a.id === "scene-sound")).toMatchObject({ start: 2, end: 4 }); + expect(videos.find((v) => v.id === "scene-clip")).toMatchObject({ start: 2, end: 4 }); + }); + + it.each(["0", "-1"])( + "treats a non-positive data-duration (%s) as absent instead of collapsing the host", + (duration) => { + const html = + `
` + + `` + + `
` + + `
` + + `` + + `
`; + + const { videos } = collectRenderMedia(html); + // No usable duration and no data-end: the host is unbounded, the clip keeps its own end. + expect(videos.find((v) => v.id === "open-clip")).toMatchObject({ start: 1, end: 4 }); + // No usable duration: fall back to data-end. + expect(videos.find((v) => v.id === "capped-clip")).toMatchObject({ start: 1, end: 3 }); + }, + ); + + it("ignores a data-end at or before the host start instead of dropping nested media", () => { + // The runtime treats an end that does not lie past the start as absent; the + // planner must not collapse the window to hostStart and drop every clip. + const html = + `
` + + `` + + `
`; + + const { videos } = collectRenderMedia(html); + expect(videos.find((v) => v.id === "scene-clip")).toMatchObject({ start: 2, end: 5 }); + }); + it("preserves an explicitly marked legacy-global media window", () => { const html = `
` + @@ -27,7 +91,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 db890615ce6..f3288bd9122 100644 --- a/packages/producer/src/services/renderMediaCollector.ts +++ b/packages/producer/src/services/renderMediaCollector.ts @@ -15,7 +15,7 @@ */ import { parseHTML } from "linkedom"; -import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core"; +import { MEDIA_RENDER_ID_ATTR, resolveAuthoredTimingWindow } from "@hyperframes/core"; import { MEDIA_START_BASIS_ATTR, readMediaStartBasis, @@ -41,6 +41,26 @@ import { */ const COMPOSITION_HOST_ATTR = "data-composition-file"; +/** + * Where a composition host closes, in its parent's time, or null when it is + * unbounded. Delegates to the runtime's own `resolveAuthoredTimingWindow`: a + * positive `data-duration` closes the host at start + duration; `data-end` + * applies only when `data-duration` is absent or non-positive, and only when + * it lies past the start. This is 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 — and a zero duration no longer collapses + * the window to `hostStart` and drops every nested clip. + */ +function resolveHostEnd(host: Element, hostStart: number): number | null { + return ( + resolveAuthoredTimingWindow({ + start: hostStart, + duration: host.getAttribute("data-duration"), + end: host.getAttribute("data-end"), + })?.end ?? null + ); +} + interface HostWindow { /** Seconds to add to a descendant's authored, scene-relative start. */ offset: number; @@ -52,20 +72,15 @@ interface HostWindow { const ROOT_WINDOW: HostWindow = { offset: 0, limit: Infinity, basis: "local" }; -function parseNumeric(value: string | null): number | null { - if (value == null || value === "") return null; - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : null; -} - /** * Fold a media element's chain of composition hosts into one window. * * Host `data-start` is resolved the same way media is (`resolveReferencedStart`): * numeric literals, or an id / `data-composition-id` ref to a sibling slot's * end (`data-start="hook"`). `parseFloat("hook")` is 0, which stacked every - * chained scene at 0–2s. Only `data-end` bounds a host: a host carrying just - * `data-duration` was unbounded in the file-tree walk too. + * chained scene at 0–2s. Each host's end comes from `resolveHostEnd`, which + * shares the runtime's timing resolver, so the planner's media windows match + * the window in which the runtime shows the host's descendants. */ function resolveHostWindow( element: Element, @@ -84,7 +99,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; } From a31347bbaf99efdec2f444bf24b81df312b06c2a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 23 Sep 2026 00:31:24 -0400 Subject: [PATCH 2/2] style(producer): trim long comments to one-to-three lines --- packages/producer/src/services/renderMediaCollector.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/producer/src/services/renderMediaCollector.ts b/packages/producer/src/services/renderMediaCollector.ts index f3288bd9122..c497280ce20 100644 --- a/packages/producer/src/services/renderMediaCollector.ts +++ b/packages/producer/src/services/renderMediaCollector.ts @@ -42,14 +42,8 @@ import { const COMPOSITION_HOST_ATTR = "data-composition-file"; /** - * Where a composition host closes, in its parent's time, or null when it is - * unbounded. Delegates to the runtime's own `resolveAuthoredTimingWindow`: a - * positive `data-duration` closes the host at start + duration; `data-end` - * applies only when `data-duration` is absent or non-positive, and only when - * it lies past the start. This is 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 — and a zero duration no longer collapses - * the window to `hostStart` and drops every nested clip. + * Where a composition host closes in its parent's time, or null when unbounded. + * Delegates to `resolveAuthoredTimingWindow`, the same window the runtime uses to hide descendants. */ function resolveHostEnd(host: Element, hostStart: number): number | null { return (