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
4 changes: 3 additions & 1 deletion packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface CompiledComposition {
videos: VideoElement[];
audios: AudioElement[];
images: ImageElement[];
droppedMediaIds: Set<string>;
masterFxChain?: string;
unresolvedCompositions: UnresolvedElement[];
/** Assets that resolve outside projectDir. Keys are the path used in HTML, values are absolute filesystem paths. */
Expand Down Expand Up @@ -2147,7 +2148,7 @@ export async function compileForRender(
// within one composition file: two scenes declaring `<video id="clip">` — or
// two bare `<video>`s, both auto-numbered `hf-video-0` — collapsed into one
// entry and injected frames onto whichever element came first. See #3340.
const { videos, audios, images } = collectRenderMedia(html);
const { videos, audios, images, droppedMediaIds } = collectRenderMedia(html);
const masterFxChain = parseMasterFxChain(html);

// Advisory video checks (sparse keyframes, VFR). Fire-and-forget — these spawn
Expand Down Expand Up @@ -2201,6 +2202,7 @@ export async function compileForRender(
videos,
audios,
images,
droppedMediaIds,
...(masterFxChain ? { masterFxChain } : {}),
unresolvedCompositions,
externalAssets,
Expand Down
101 changes: 101 additions & 0 deletions packages/producer/src/services/render/stages/probeStage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ function makeProbeInput(overrides: {
videos: [],
audios: [],
images: [],
droppedMediaIds: new Set<string>(),
unresolvedCompositions: [],
externalAssets: new Map(),
width: 1920,
Expand Down Expand Up @@ -411,6 +412,106 @@ describe("runProbeStage — forceScreenshot threading", () => {
expect(mediaPreflightComposition).toBe(input.composition);
});

// Scene in-point 5 and clip trim 1: the collector starts picture and sound at source 6.
it.each([
{ pageStart: 1, expected: 6 },
{ pageStart: 3, expected: 8 },
])(
"keeps a trimmed clip's picture and sound together inside a trimmed scene (page start $pageStart)",
async ({ pageStart, expected }) => {
resetRetryMocks();
browserMediaResults = [
{
id: "a",
tagName: "video",
src: "a.mp4",
start: 0,
end: 18,
duration: 18,
mediaStart: pageStart,
loop: false,
hasAudio: true,
volume: 1,
muted: false,
},
];
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({});
const window = { src: "a.mp4", start: 0, end: 6, mediaStart: 6 };
input.composition.videos.push({ id: "a", ...window, loop: false, hasAudio: true });
input.composition.audios.push({ id: "a-audio", ...window, layer: 0, type: "video" });
input.compiled.html = `<div data-composition-id="scene" data-start="0" data-duration="6" data-playback-start="5">
<video id="a" src="a.mp4" data-start="0" data-duration="18" data-playback-start="1" data-has-audio="true"></video>
</div>`;

await runProbeStage(input);

expect(input.composition.videos[0]?.mediaStart).toBe(expected);
expect(input.composition.audios[0]?.mediaStart).toBe(expected);
},
);

it("does not add back clips the collector dropped", async () => {
resetRetryMocks();
const common = { start: 1, end: 4, duration: 3, mediaStart: 0, volume: 1, muted: false };
browserMediaResults = [
{ ...common, id: "early", tagName: "audio", src: "early.m4a" },
{ ...common, id: "early__hf2", tagName: "audio", src: "early.m4a" },
{ ...common, id: "clip__hf2", tagName: "video", src: "clip.mp4" },
{ ...common, id: "pic__hf2", tagName: "image", src: "pic.png" },
];
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({});
input.composition.audios.push({
id: "early",
src: "early.m4a",
start: 1,
end: 4,
mediaStart: 0,
layer: 0,
type: "audio",
});
input.compiled.droppedMediaIds = new Set(["early__hf2", "clip__hf2", "pic__hf2"]);

await runProbeStage(input);

expect(input.composition.audios.map((audio) => audio.id)).toEqual(["early"]);
expect(input.composition.videos).toEqual([]);
expect(input.composition.images).toEqual([]);
});

it("moves an audio's start without moving a video track that shares its id", async () => {
resetRetryMocks();
browserMediaResults = [
{
id: "v-audio",
tagName: "audio",
src: "a.m4a",
start: 0,
end: 6,
duration: 6,
mediaStart: 4,
loop: false,
hasAudio: false,
volume: 1,
muted: false,
},
];
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({});
input.composition.audios.push(
{ id: "v-audio", src: "a.m4a", start: 0, end: 6, mediaStart: 2, layer: 0, type: "audio" },
{ id: "v-audio", src: "v.mp4", start: 0, end: 6, mediaStart: 0, layer: 0, type: "video" },
);
input.compiled.html =
`<video id="v" src="v.mp4" data-start="0" data-duration="6" data-has-audio="true"></video>` +
`<audio id="v-audio" src="a.m4a" data-start="0" data-duration="6" data-playback-start="2"></audio>`;

await runProbeStage(input);

expect(input.composition.audios.map((audio) => audio.mediaStart)).toEqual([4, 0]);
});

it("reconciles a nested source's selected runtime URL before media-type preflight", async () => {
resetRetryMocks();
browserMediaResults = [
Expand Down
69 changes: 50 additions & 19 deletions packages/producer/src/services/render/stages/probeStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ import {
isTransientBrowserError,
probeBeginFrameLiveness,
} from "@hyperframes/engine";
import { fpsToNumber } from "@hyperframes/core";
import { MEDIA_RENDER_ID_ATTR, fpsToNumber, readMediaStart } from "@hyperframes/core";
import { extractMediaSrcMutations } from "@hyperframes/parsers";
import type { CompiledComposition } from "../../htmlCompiler.js";
import type { BrowserMediaElement, CompiledComposition } from "../../htmlCompiler.js";
import {
discoverMediaFromBrowser,
discoverAudioVolumeAutomationFromTimeline,
Expand Down Expand Up @@ -181,6 +181,40 @@ export function hasVariableBoundMedia(
});
}

/** Each clip's own start point as the compiled markup gives it, keyed like the browser probe. */
function authoredMediaStarts(html: string): Map<string, number> {
const { document } = parseHTML(html);
const starts = new Map<string, number>();
for (const media of document.querySelectorAll("video, audio")) {
const id = media.getAttribute(MEDIA_RENDER_ID_ATTR) || media.getAttribute("id");
if (id && !starts.has(id)) starts.set(id, readMediaStart(media));
}
return starts;
}

/**
* Take a start point the page changed by moving the collected one by the same amount, so the
* in-point its scene added survives, and a video's sound track moves with its picture.
*/
function adoptRuntimeMediaStart(
composition: Pick<CompositionMetadata, "videos" | "audios">,
el: BrowserMediaElement,
authored: number,
): void {
const shift = el.mediaStart - authored;
if (el.mediaStart <= 0 || Math.abs(shift) <= BROWSER_MEDIA_EPSILON) return;
const entries =
el.tagName === "video"
? [
...composition.videos.filter((video) => video.id === el.id),
...composition.audios.filter(
(audio) => audio.type === "video" && audio.id === `${el.id}-audio`,
),
]
: composition.audios.filter((audio) => audio.type !== "video" && audio.id === el.id);
for (const entry of entries) entry.mediaStart += shift;
}

function reconcileBrowserMediaEnd(
existingEnd: number,
projectedEnd: number,
Expand Down Expand Up @@ -524,6 +558,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
const existingVideoIds = new Set(composition.videos.map((v) => v.id));
const existingAudioIds = new Set(composition.audios.map((a) => a.id));
const existingImageIds = new Set(composition.images.map((image) => image.id));
const authoredStarts = authoredMediaStarts(compiled.html);

pruneMutedBrowserMedia(composition, browserMedia, existingAudioIds);

Expand Down Expand Up @@ -558,21 +593,19 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
sourceChanged,
el.durationInferred,
);
if (
el.mediaStart > 0 &&
(existing.mediaStart <= 0 ||
Math.abs(existing.mediaStart - el.mediaStart) > BROWSER_MEDIA_EPSILON)
) {
existing.mediaStart = el.mediaStart;
}
adoptRuntimeMediaStart(
composition,
el,
authoredStarts.get(el.id) ?? existing.mediaStart,
);
if (el.hasAudio && !el.muted && !existing.hasAudio) {
existing.hasAudio = true;
}
if (el.loop && !existing.loop) {
existing.loop = true;
}
}
} else {
} else if (!compiled.droppedMediaIds.has(el.id)) {
// New video discovered from browser
composition.videos.push({
id: el.id,
Expand Down Expand Up @@ -605,21 +638,19 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
sourceChanged,
el.durationInferred,
);
if (
el.mediaStart > 0 &&
(existing.mediaStart <= 0 ||
Math.abs(existing.mediaStart - el.mediaStart) > BROWSER_MEDIA_EPSILON)
) {
existing.mediaStart = el.mediaStart;
}
adoptRuntimeMediaStart(
composition,
el,
authoredStarts.get(el.id) ?? existing.mediaStart,
);
if (
el.volume > 0 &&
Math.abs((existing.volume ?? 1) - el.volume) > BROWSER_MEDIA_EPSILON
) {
existing.volume = el.volume;
}
}
} else {
} else if (!compiled.droppedMediaIds.has(el.id)) {
composition.audios.push({
id: el.id,
src,
Expand Down Expand Up @@ -650,7 +681,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
existing.end = projectedEnd;
}
}
} else {
} else if (!compiled.droppedMediaIds.has(el.id)) {
composition.images.push({
id: el.id,
src,
Expand Down
Loading
Loading