) {
+ const dir = mkdtempSync(join(tmpdir(), "hf-media-"));
+ try {
+ for (const [name, html] of Object.entries(files)) writeFileSync(join(dir, name), html);
+ return await compileForRender(dir, join(dir, "index.html"), dir);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+const inlineScene = (sceneTiming: string, extra = "") =>
+ `` +
+ `` +
+ `
` +
+ `
` +
+ `
` +
+ extra +
+ `
`;
+
+const media = (attrs: string) =>
+ `` +
+ ``;
+
+describe("collectRenderMedia in-point hosts", () => {
+ it("plays a head-trimmed scene's media from its in-point", () => {
+ const html =
+ `` +
+ media(`data-start="0" data-duration="19" data-end="19"`) +
+ `
`;
+
+ const { videos, audios } = collectRenderMedia(html);
+ const video = videos.find((v) => v.id === "clip")!;
+ expect(video).toMatchObject({ start: 0, end: 12, mediaStart: 7 });
+ expect(resolveTimelineExtractionWindow(video, video.end - video.start, 12)).toMatchObject({
+ compositionStart: 0,
+ mediaStart: 7,
+ durationSeconds: 12,
+ });
+ expect(audios.find((a) => a.id === "vo")).toMatchObject({ start: 0, end: 12, mediaStart: 7 });
+ });
+
+ it("finds an inline in-point host in a compiled document with no sub-composition files", async () => {
+ const { videos, audios } = await compileProject({
+ "index.html": inlineScene(`data-start="0" data-duration="12" data-playback-start="7"`),
+ });
+ expect(videos.find((v) => v.id === "clip")).toMatchObject({ start: 0, end: 12, mediaStart: 7 });
+ expect(audios.find((a) => a.id === "vo")).toMatchObject({ start: 0, end: 12, mediaStart: 7 });
+ });
+
+ it("starts an inline scene's media at the scene's start in a compiled document", async () => {
+ const legacy = ``;
+ const { videos, audios } = await compileProject({
+ "index.html": inlineScene(`data-start="6" data-duration="6"`, legacy),
+ });
+ expect(videos.find((v) => v.id === "clip")).toMatchObject({ start: 6, end: 12, mediaStart: 0 });
+ expect(audios.find((a) => a.id === "vo")).toMatchObject({ start: 6, end: 12, mediaStart: 0 });
+ // Already root time: the scene's start is not added again.
+ expect(videos.find((v) => v.id === "legacy")).toMatchObject({ start: 6, end: 9 });
+ });
+
+ it("leaves media running past the root composition's end uncut", () => {
+ const html =
+ ``;
+
+ expect(collectRenderMedia(html).audios[0]).toMatchObject({ start: 0, end: 19 });
+ });
+
+ it("places an in-point scene inside a later inline scene", () => {
+ const html =
+ `` +
+ `
` +
+ media(`data-start="0" data-duration="19" data-end="19"`) +
+ `
`;
+
+ const { videos, audios } = collectRenderMedia(html);
+ expect(videos.find((v) => v.id === "clip")).toMatchObject({
+ start: 10,
+ end: 15,
+ mediaStart: 3,
+ });
+ expect(audios.find((a) => a.id === "vo")).toMatchObject({ start: 10, end: 15, mediaStart: 3 });
+ });
+
+ it("keeps a looping video inside its in-point scene's slot", () => {
+ const html =
+ `` +
+ `` +
+ `
`;
+
+ expect(collectRenderMedia(html).videos.find((v) => v.id === "bg")).toMatchObject({
+ start: 2,
+ end: 7,
+ mediaStart: 0,
+ });
+ });
+
+ it("never lets an author id take a render id's window", () => {
+ const html =
+ `` +
+ `` +
+ `
` +
+ ``;
+
+ expect(collectRenderMedia(html).videos[0]).toMatchObject({ src: "a.mp4", start: 5, end: 11 });
+ });
+
+ it("skips an element the parsers do not report when matching author ids", () => {
+ const html =
+ `![]()
` +
+ `
` +
+ `` +
+ `
`;
+
+ expect(collectRenderMedia(html).videos[0]).toMatchObject({ start: 5, end: 11 });
+ });
+
+ it("keeps a scene clip's window when a root element without a src shares its id", async () => {
+ const { videos, images } = await compileProject({
+ "index.html":
+ `` +
+ `` +
+ `
` +
+ `
![]()
![]()
` +
+ `
`,
+ "a.html":
+ `` +
+ `
` +
+ `

` +
+ `
`,
+ });
+ // The windows main exports for this project.
+ expect(videos.find((v) => v.id === "clip")).toMatchObject({ start: 5, end: 11 });
+ expect(images.find((i) => i.id === "pic")).toMatchObject({ start: 6, end: 8 });
+ });
+
+ it("cuts a split scene's media to the second half's slot", () => {
+ const html =
+ `` +
+ media(`data-start="0" data-duration="12" data-end="12" data-playback-start="1"`) +
+ `
` +
+ `
` +
+ `
` +
+ `
`;
+
+ const { videos, audios } = collectRenderMedia(html);
+ expect(videos.find((v) => v.id === "clip")).toMatchObject({ start: 5, end: 12, mediaStart: 6 });
+ expect(audios.find((a) => a.id === "vo")).toMatchObject({ start: 5, end: 12, mediaStart: 6 });
+ // Wholly before the in-point: never played.
+ expect(audios.find((a) => a.id === "early")).toBeUndefined();
+ expect(videos.find((v) => v.id === "early-clip")).toBeUndefined();
+ // Cut at the host's end, its 2 s head consumed at 2x.
+ expect(audios.find((a) => a.id === "fast")).toMatchObject({ start: 5, end: 12, mediaStart: 4 });
+ });
+
+ it("reports the clips a split scene's in-point cuts away", async () => {
+ const host = (timing: string) =>
+ ``;
+ const compiled = await compileProject({
+ "index.html":
+ `` +
+ `` +
+ host(`data-start="0" data-duration="5"`) +
+ host(`data-start="5" data-duration="7" data-playback-start="5"`) +
+ `
`,
+ "scene.html":
+ ``,
+ });
+
+ expect(compiled.audios.map(({ id, start, end }) => ({ id, start, end }))).toEqual([
+ { id: "early", start: 1, end: 4 },
+ ]);
+ expect([...compiled.droppedMediaIds]).toEqual(["early__hf2"]);
+ });
+
+ it("changes nothing for a host without an in-point", () => {
+ const scene = (inPoint: string) =>
+ `` +
+ media(`data-start="1" data-duration="9" data-end="10" data-playback-start="2"`) +
+ `
` +
+ `` +
+ `` +
+ `
`;
+
+ const absent = collectRenderMedia(scene(""));
+ expect(collectRenderMedia(scene(` data-playback-start="0"`))).toEqual(absent);
+ expect(absent.audios.find((a) => a.id === "nested-audio")).toMatchObject({
+ start: 3,
+ end: 8,
+ mediaStart: 0,
+ });
+ });
+});
diff --git a/packages/producer/src/services/renderMediaCollector.ts b/packages/producer/src/services/renderMediaCollector.ts
index c497280ce20..6414acec408 100644
--- a/packages/producer/src/services/renderMediaCollector.ts
+++ b/packages/producer/src/services/renderMediaCollector.ts
@@ -1,23 +1,25 @@
/**
* Collect the render pipeline's media list from the fully inlined document.
*
- * Sub-composition media used to be gathered from each composition FILE before
- * inlining, then merged with the main document's media and deduplicated by
- * element id. That merge is unsound: ids are unique per file, not per render
- * document, so two scenes that both declare `