Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});

Expand Down
69 changes: 67 additions & 2 deletions packages/producer/src/services/renderMediaCollector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
`<div data-composition-file="hook.html" data-composition-id="hook" data-start="0" data-duration="2">` +
`<audio ${MEDIA_RENDER_ID_ATTR}="hook-sound" id="hook-sound" src="hook.m4a" data-start="0" data-duration="4" data-end="4"></audio>` +
`<video ${MEDIA_RENDER_ID_ATTR}="late" id="late" src="late.mp4" data-start="2.5" data-duration="1" data-end="3.5"></video>` +
`</div>` +
`<div data-composition-file="body.html" data-composition-id="body" data-start="hook" data-duration="2">` +
`<audio ${MEDIA_RENDER_ID_ATTR}="body-sound" id="body-sound" src="body.m4a" data-start="0" data-duration="4" data-end="4"></audio>` +
`</div>`;

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 =
`<div data-composition-file="scene.html" data-composition-id="scene" data-start="2" data-duration="2" data-end="6">` +
`<audio ${MEDIA_RENDER_ID_ATTR}="scene-sound" id="scene-sound" src="scene.m4a" data-start="0" data-duration="4" data-end="4"></audio>` +
`<video ${MEDIA_RENDER_ID_ATTR}="scene-clip" id="scene-clip" src="scene.mp4" data-start="0" data-duration="4" data-end="4"></video>` +
`</div>`;

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 =
`<div data-composition-file="open.html" data-composition-id="open" data-start="1" data-duration="${duration}">` +
`<video ${MEDIA_RENDER_ID_ATTR}="open-clip" id="open-clip" src="open.mp4" data-start="0" data-duration="3" data-end="3"></video>` +
`</div>` +
`<div data-composition-file="capped.html" data-composition-id="capped" data-start="1" data-duration="${duration}" data-end="3">` +
`<video ${MEDIA_RENDER_ID_ATTR}="capped-clip" id="capped-clip" src="capped.mp4" data-start="0" data-duration="3" data-end="3"></video>` +
`</div>`;

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 =
`<div data-composition-file="scene.html" data-composition-id="scene" data-start="2" data-end="1">` +
`<video ${MEDIA_RENDER_ID_ATTR}="scene-clip" id="scene-clip" src="scene.mp4" data-start="0" data-duration="3" data-end="3"></video>` +
`</div>`;

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 =
`<div data-composition-file="scene.html" data-composition-id="scene" data-start="2" data-duration="6">` +
Expand All @@ -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 });
});
});
29 changes: 19 additions & 10 deletions packages/producer/src/services/renderMediaCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down
Loading