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..c497280ce20 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,20 @@ import { */ const COMPOSITION_HOST_ATTR = "data-composition-file"; +/** + * 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 ( + 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 +66,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 +93,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; }