From ee4ced3e9212353d260284457096561e2956d7b6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 23:11:27 -0700 Subject: [PATCH 1/9] fix(producer): export plays a split or trimmed scene's media from its in-point A sub-composition host with a non-zero data-playback-start now shifts and cuts the video and audio it holds in render, matching Studio preview. Hosts without an in-point resolve exactly as before. --- .../src/services/renderMediaCollector.test.ts | 63 ++++++++++++++ .../src/services/renderMediaCollector.ts | 87 +++++++++++++++---- 2 files changed, 131 insertions(+), 19 deletions(-) diff --git a/packages/producer/src/services/renderMediaCollector.test.ts b/packages/producer/src/services/renderMediaCollector.test.ts index 1e2af711956..8425642ad95 100644 --- a/packages/producer/src/services/renderMediaCollector.test.ts +++ b/packages/producer/src/services/renderMediaCollector.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core"; +import { resolveTimelineExtractionWindow } from "@hyperframes/engine"; import { collectRenderMedia } from "./renderMediaCollector.js"; describe("collectRenderMedia host windows", () => { @@ -96,3 +97,65 @@ describe("collectRenderMedia host windows", () => { expect(audios.find((audio) => audio.id === "global-audio")).toMatchObject({ start: 2, end: 8 }); }); }); + +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")!; + // Local 0 sits 7 s before the root; the engine trims that preroll from the source. + expect(video).toMatchObject({ start: -7, end: 12, mediaStart: 0 }); + 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("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); + // Video keeps its origin: frames before the host's start are hidden with the host. + expect(videos.find((v) => v.id === "clip")).toMatchObject({ start: 0, end: 12, mediaStart: 1 }); + 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("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..be97020f77c 100644 --- a/packages/producer/src/services/renderMediaCollector.ts +++ b/packages/producer/src/services/renderMediaCollector.ts @@ -15,9 +15,19 @@ */ import { parseHTML } from "linkedom"; -import { MEDIA_RENDER_ID_ATTR, resolveAuthoredTimingWindow } from "@hyperframes/core"; +import { + MEDIA_RENDER_ID_ATTR, + normalizeRateSpec, + resolveAuthoredTimingWindow, + shiftRateLane, + sourceTimeAt, +} from "@hyperframes/core"; import { MEDIA_START_BASIS_ATTR, + compositionOriginSeconds, + cutToHostSlots, + hostInPointSeconds, + type HostSlot, readMediaStartBasis, resolveAbsoluteMediaStartSeconds, type MediaStartBasis, @@ -62,9 +72,27 @@ interface HostWindow { limit: number; /** Whether authored media time is composition-local or legacy root-global. */ basis: MediaStartBasis; + /** Root-time slots of the enclosing hosts with an in-point. */ + slots: HostSlot[]; } -const ROOT_WINDOW: HostWindow = { offset: 0, limit: Infinity, basis: "local" }; +const ROOT_WINDOW: HostWindow = { offset: 0, limit: Infinity, basis: "local", slots: [] }; + +// Any composition host with an in-point counts, inline or anonymous, as in core's start resolver. +const isInPointHost = (element: Element): boolean => + element.matches("[data-composition-id], [data-composition-src]") && + hostInPointSeconds(element) > 0; + +/** Composition hosts that place `element`, root first: inlined files, plus any host with an in-point. */ +function enclosingHosts(element: Element): Element[] { + const hosts: Element[] = []; + for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { + if (ancestor.hasAttribute(COMPOSITION_HOST_ATTR) || isInPointHost(ancestor)) { + hosts.unshift(ancestor); + } + } + return hosts; +} /** * Fold a media element's chain of composition hosts into one window. @@ -82,20 +110,20 @@ function resolveHostWindow( startCache: Map, visiting: Set, ): HostWindow { - const hosts: Element[] = []; - for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { - if (ancestor.hasAttribute(COMPOSITION_HOST_ATTR)) hosts.push(ancestor); - } + const hosts = enclosingHosts(element); if (hosts.length === 0) return ROOT_WINDOW; let offset = 0; let limit = Infinity; - // parentElement walks leaf → root; the offsets accumulate root → leaf. - for (const host of hosts.reverse()) { + const slots: HostSlot[] = []; + for (const host of hosts) { const hostStart = resolveReferencedStart(document, host, startCache, visiting); const hostEnd = resolveHostEnd(host, hostStart); if (hostEnd != null) limit = Math.min(limit, offset + hostEnd); - offset += hostStart; + if (hostInPointSeconds(host) > 0) { + slots.push({ start: offset + hostStart, end: offset + (hostEnd ?? Infinity) }); + } + offset += compositionOriginSeconds(hostStart, host); } const tag = element.tagName.toLowerCase(); const basis = @@ -106,6 +134,7 @@ function resolveHostWindow( offset, limit, basis, + slots, }; } @@ -133,13 +162,14 @@ function collectHostWindows(html: string): Map { /** * Shift a scene-relative window onto the root timeline. * Returns null when the clip starts after its host has already ended, matching - * the `start < absoluteEnd` drop the file-tree walk applied. + * the `start < absoluteEnd` drop the file-tree walk applied, or when in-point hosts cut all of it. + * `start` stays the clip's origin; `played` is the part the in-point hosts' slots keep. */ function toAbsoluteWindow( start: number, end: number, window: HostWindow, -): { start: number; end: number } | null { +): { start: number; end: number; played: { start: number; end: number } } | null { const absoluteStart = resolveAbsoluteMediaStartSeconds({ authoredStart: start, hostStart: window.offset, @@ -151,7 +181,24 @@ function toAbsoluteWindow( hostStart: window.offset, basis: window.basis, }); - return { start: absoluteStart, end: Math.min(absoluteEnd, window.limit) }; + const absolute = { start: absoluteStart, end: Math.min(absoluteEnd, window.limit) }; + if (window.slots.length === 0) return { ...absolute, played: absolute }; + const played = cutToHostSlots(absolute, window.slots); + if (played.end === played.start && absolute.end > absolute.start) return null; + return { start: absolute.start, end: played.end, played }; +} + +/** Start audio where its in-point hosts let it play, that much further into its source. */ +function startAtPlayed(audio: T, played: { start: number }): T { + const head = played.start - audio.start; + if (head <= 0) return audio; + const rate = normalizeRateSpec(audio.playbackRate); + return { + ...audio, + start: played.start, + mediaStart: audio.mediaStart + sourceTimeAt(rate, head), + playbackRate: shiftRateLane(rate, head), + }; } export interface RenderMedia { @@ -172,16 +219,17 @@ export function collectRenderMedia(html: string): RenderMedia { const windows = collectHostWindows(html); const windowFor = (id: string): HostWindow => windows.get(id) ?? ROOT_WINDOW; + // Pictures keep their origin: the engine trims a negative start, and the host hides them before its own. const videos: VideoElement[] = []; for (const video of parseVideoElements(html)) { const absolute = toAbsoluteWindow(video.start, video.end, windowFor(video.id)); - if (absolute) videos.push({ ...video, ...absolute }); + if (absolute) videos.push({ ...video, start: absolute.start, end: absolute.end }); } const images: ImageElement[] = []; for (const image of parseImageElements(html)) { const absolute = toAbsoluteWindow(image.start, image.end, windowFor(image.id)); - if (absolute) images.push({ ...image, ...absolute }); + if (absolute) images.push({ ...image, start: absolute.start, end: absolute.end }); } // A