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;
}