Skip to content

Commit 3eee9cc

Browse files
committed
fix(producer): retain scene trims for discovered speed ramps
1 parent c7a9fdf commit 3eee9cc

6 files changed

Lines changed: 149 additions & 45 deletions

File tree

‎docs/reference/speed-ramps.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ which the preview and the renderer both use, so the render shows the frames the
3939
preview showed. Set `data-duration` on the clip to fix how long it holds the
4040
timeline; without it the length follows from the source and the speed.
4141

42+
If a browser probe discovers a rate lane on an existing clip, export preserves
43+
the scene's trim in both the ramp's position and the source media in-point.
44+
4245
Pitch is kept. The preview leaves the browser's default pitch correction on.
4346
The render time-stretches audio in short constant-speed slices (a quarter
4447
second, coarser on clips longer than a minute), which approximates the curve

‎packages/engine/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ export {
266266
parseAudioElements,
267267
parseMasterFxChain,
268268
processCompositionAudio,
269+
videoAudioId,
269270
} from "./services/audioMixer.js";
270271
export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
271272
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";

‎packages/engine/src/services/audioMixer.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,10 @@ export function parseMasterFxChain(html: string): string | undefined {
557557
return readMasterFxChain(parseHTML(unwrapTemplate(html)).document);
558558
}
559559

560+
export function videoAudioId(videoId: string): string {
561+
return `${videoId}-audio`;
562+
}
563+
560564
export function parseAudioElements(html: string): AudioElement[] {
561565
const elements: AudioElement[] = [];
562566
const { document } = parseHTML(unwrapTemplate(html));
@@ -656,7 +660,7 @@ export function parseAudioElements(html: string): AudioElement[] {
656660
const src = resolveMediaElementSrc(el);
657661
if (!id || !src || isHidden(el) || memberGroupHidden(el)) continue;
658662
if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue;
659-
elements.push(build(el, `${id}-audio`, src, "video", true));
663+
elements.push(build(el, videoAudioId(id), src, "video", true));
660664
}
661665

662666
return elements;

‎packages/producer/src/services/render/stages/extractVideosStage.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
resolveProjectRelativeSrc,
5454
runVideoExtractionWithRetry,
5555
safeVideoExtractionSourceIdentity,
56+
videoAudioId,
5657
} from "@hyperframes/engine";
5758
import {
5859
collectVideoMetadataHints,
@@ -579,7 +580,7 @@ export function appendAutoDetectedVideoAudio(
579580
const video = composition.videos.find((v) => v.id === ext.videoId);
580581
if (!video || !video.hasAudio || video.hidden || existingAudioSrcs.has(video.src)) continue;
581582
composition.audios.push({
582-
id: `${video.id}-audio`,
583+
id: videoAudioId(video.id),
583584
src: video.src,
584585
start: video.start,
585586
end: video.end,

‎packages/producer/src/services/render/stages/probeStage.test.ts‎

Lines changed: 109 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { mkdtempSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import type { RateSpec } from "@hyperframes/core";
6+
import { parseAudioElements, videoAudioId } from "../../../../../engine/src/services/audioMixer.js";
7+
import {
8+
parseVideoElements,
9+
parseImageElements,
10+
} from "../../../../../engine/src/services/videoFrameExtractor.js";
11+
import { resolveReferencedStart } from "../../../../../engine/src/services/referenceResolver.js";
12+
import { collectRenderMedia } from "../../renderMediaCollector.js";
613

714
const workDir = mkdtempSync(join(tmpdir(), "hf-stage-test-"));
815
afterAll(() => rmSync(workDir, { recursive: true, force: true }));
@@ -100,6 +107,11 @@ mock.module("../../assetMediaType.js", () => ({
100107
}));
101108

102109
mock.module("@hyperframes/engine", () => ({
110+
parseAudioElements,
111+
parseVideoElements,
112+
parseImageElements,
113+
resolveReferencedStart,
114+
videoAudioId,
103115
createCaptureSession: async (
104116
_url: string,
105117
_dir: string,
@@ -529,49 +541,124 @@ describe("runProbeStage — forceScreenshot threading", () => {
529541
},
530542
);
531543

532-
it.each(runtimeRates)(
533-
"preserves a collected speed ramp during browser rate %j reconciliation",
534-
async (browserRate) => {
544+
it.each([
545+
{ tagName: "video", pageStart: 1, expected: 3 },
546+
{ tagName: "video", pageStart: 3, expected: 5 },
547+
{ tagName: "audio", pageStart: 1, expected: 3 },
548+
{ tagName: "audio", pageStart: 3, expected: 5 },
549+
])(
550+
"applies a scene trim when adopting a runtime $tagName lane (page start $pageStart)",
551+
async ({ tagName, pageStart, expected }) => {
535552
resetRetryMocks();
553+
const playbackRate = {
554+
target: "rate",
555+
points: [
556+
{ t: 0, v: 1 },
557+
{ t: 2, v: 1 },
558+
{ t: 4, v: 2 },
559+
],
560+
} satisfies RateSpec;
536561
browserMediaResults = [
537562
{
538563
id: "clip",
539-
tagName: "video",
564+
tagName,
540565
src: "clip.mp4",
541566
start: 0,
542-
end: 3,
543-
duration: 3,
544-
mediaStart: 0,
545-
playbackRate: browserRate,
567+
end: 5,
568+
duration: 5,
569+
mediaStart: pageStart,
570+
playbackRate,
546571
loop: false,
547-
hasAudio: false,
572+
hasAudio: true,
548573
volume: 1,
549574
muted: false,
550575
},
551576
];
552577
const { runProbeStage } = await import("./probeStage.js");
553578
const input = makeProbeInput({});
554-
const playbackRate = {
579+
input.compiled.html = `<div data-composition-id="scene" data-start="5" data-duration="3" data-playback-start="2">
580+
<${tagName} id="clip" src="clip.mp4" data-start="0" data-duration="5" data-playback-start="1" data-playback-rate="2" data-has-audio="true"></${tagName}>
581+
</div>`;
582+
const collected = collectRenderMedia(input.compiled.html);
583+
input.composition.videos = collected.videos;
584+
input.composition.audios = collected.audios;
585+
const before = tagName === "video" ? collected.videos : collected.audios;
586+
expect(before[0]).toMatchObject({ start: 5, end: 8, mediaStart: 5, playbackRate: 2 });
587+
588+
await runProbeStage(input);
589+
590+
const shiftedRate = {
555591
target: "rate",
556592
points: [
557-
{ t: 0, v: 1.25 },
593+
{ t: 0, v: 1 },
558594
{ t: 2, v: 2 },
559595
],
560-
} satisfies RateSpec;
561-
input.composition.videos.push({
562-
id: "clip",
563-
src: "clip.mp4",
564-
start: 0,
565-
end: 3,
566-
mediaStart: 0,
567-
playbackRate,
568-
loop: false,
569-
hasAudio: false,
596+
};
597+
const media = tagName === "video" ? input.composition.videos : input.composition.audios;
598+
expect(media[0]).toMatchObject({
599+
start: 5,
600+
end: 8,
601+
mediaStart: expected,
602+
playbackRate: shiftedRate,
570603
});
604+
if (tagName === "video") {
605+
expect(input.composition.audios[0]).toMatchObject({
606+
mediaStart: expected,
607+
playbackRate: shiftedRate,
608+
});
609+
}
610+
},
611+
);
612+
613+
it.each(
614+
runtimeRates.flatMap((playbackRate) => [
615+
{ tagName: "video", playbackRate },
616+
{ tagName: "audio", playbackRate },
617+
]),
618+
)(
619+
"preserves a collected $tagName speed ramp during reconciliation (browser rate: $playbackRate)",
620+
async ({ tagName, playbackRate }) => {
621+
resetRetryMocks();
622+
browserMediaResults = [
623+
{
624+
id: "clip",
625+
tagName,
626+
src: "clip.mp4",
627+
start: 0,
628+
end: 5,
629+
duration: 5,
630+
mediaStart: 1,
631+
playbackRate,
632+
loop: false,
633+
hasAudio: true,
634+
volume: 1,
635+
muted: false,
636+
},
637+
];
638+
const { runProbeStage } = await import("./probeStage.js");
639+
const input = makeProbeInput({});
640+
const authoredRate = {
641+
target: "rate",
642+
points: [
643+
{ t: 0, v: 1 },
644+
{ t: 2, v: 1 },
645+
{ t: 4, v: 2 },
646+
],
647+
} satisfies RateSpec;
648+
input.compiled.html = `<div data-composition-id="scene" data-start="5" data-duration="3" data-playback-start="2">
649+
<${tagName} id="clip" src="clip.mp4" data-start="0" data-duration="5" data-playback-start="1" data-has-audio="true" data-automation='${JSON.stringify({ version: 1, lanes: [authoredRate] })}'></${tagName}>
650+
</div>`;
651+
const collected = collectRenderMedia(input.compiled.html);
652+
input.composition.videos = collected.videos;
653+
input.composition.audios = collected.audios;
654+
const before = structuredClone({ videos: collected.videos, audios: collected.audios });
655+
const media = tagName === "video" ? collected.videos : collected.audios;
656+
expect(media[0]).toMatchObject({ mediaStart: 3 });
571657

572658
await runProbeStage(input);
573659

574-
expect(input.composition.videos[0]?.playbackRate).toEqual(playbackRate);
660+
expect(input.composition.videos).toEqual(before.videos);
661+
expect(input.composition.audios).toEqual(before.audios);
575662
},
576663
);
577664

‎packages/producer/src/services/render/stages/probeStage.ts‎

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,17 @@ import {
4343
initializeSession,
4444
isTransientBrowserError,
4545
probeBeginFrameLiveness,
46+
videoAudioId,
4647
} from "@hyperframes/engine";
47-
import { MEDIA_RENDER_ID_ATTR, fpsToNumber, readMediaStart } from "@hyperframes/core";
48+
import {
49+
MEDIA_RENDER_ID_ATTR,
50+
fpsToNumber,
51+
normalizeRateSpec,
52+
readMediaStart,
53+
shiftRateLane,
54+
sourceTimeAt,
55+
timeAtSourceTime,
56+
} from "@hyperframes/core";
4857
import { extractMediaSrcMutations } from "@hyperframes/parsers";
4958
import type { BrowserMediaElement, CompiledComposition } from "../../htmlCompiler.js";
5059
import {
@@ -193,28 +202,39 @@ function authoredMediaStarts(html: string): Map<string, number> {
193202
}
194203

195204
function isVideoAudio(audio: CompositionMetadata["audios"][number], videoId: string): boolean {
196-
return audio.type === "video" && audio.id === `${videoId}-audio`;
205+
return audio.type === "video" && audio.id === videoAudioId(videoId);
197206
}
198207

199208
/**
200-
* Take a start point the page changed by moving the collected one by the same amount, so the
201-
* in-point its scene added survives, and a video's sound track moves with its picture.
209+
* Adopt browser timing without losing the scene trim the collector applied to picture and sound.
202210
*/
203-
function adoptRuntimeMediaStart(
211+
function adoptRuntimeMediaTiming(
204212
composition: Pick<CompositionMetadata, "videos" | "audios">,
205213
el: BrowserMediaElement,
206214
authored: number,
207215
): void {
208216
const shift = el.mediaStart - authored;
209-
if (el.mediaStart <= 0 || Math.abs(shift) <= BROWSER_MEDIA_EPSILON) return;
210217
const entries =
211218
el.tagName === "video"
212219
? [
213220
...composition.videos.filter((video) => video.id === el.id),
214221
...composition.audios.filter((audio) => isVideoAudio(audio, el.id)),
215222
]
216223
: composition.audios.filter((audio) => audio.type !== "video" && audio.id === el.id);
217-
for (const entry of entries) entry.mediaStart += shift;
224+
for (const entry of entries) {
225+
// Collected lanes already carry their scene trim. Only newly discovered lanes need it.
226+
if (typeof entry.playbackRate !== "object") {
227+
if (typeof el.playbackRate === "object") {
228+
const consumed = Math.max(0, entry.mediaStart - authored);
229+
const head = timeAtSourceTime(normalizeRateSpec(entry.playbackRate), consumed);
230+
entry.mediaStart += sourceTimeAt(el.playbackRate, head) - consumed;
231+
entry.playbackRate = shiftRateLane(el.playbackRate, head);
232+
} else {
233+
entry.playbackRate = el.playbackRate;
234+
}
235+
}
236+
if (el.mediaStart > 0 && Math.abs(shift) > BROWSER_MEDIA_EPSILON) entry.mediaStart += shift;
237+
}
218238
}
219239

220240
function reconcileBrowserMediaEnd(
@@ -597,20 +617,11 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
597617
sourceChanged,
598618
el.durationInferred,
599619
);
600-
adoptRuntimeMediaStart(
620+
adoptRuntimeMediaTiming(
601621
composition,
602622
el,
603623
authoredStarts.get(el.id) ?? existing.mediaStart,
604624
);
605-
// A collected rate lane already includes scene trimming; retain its phase.
606-
if (typeof existing.playbackRate !== "object") {
607-
existing.playbackRate = el.playbackRate;
608-
for (const audio of composition.audios) {
609-
if (isVideoAudio(audio, el.id) && typeof audio.playbackRate !== "object") {
610-
audio.playbackRate = el.playbackRate;
611-
}
612-
}
613-
}
614625
if (el.hasAudio && !el.muted && !existing.hasAudio) {
615626
existing.hasAudio = true;
616627
}
@@ -656,15 +667,12 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
656667
sourceChanged,
657668
el.durationInferred,
658669
);
659-
adoptRuntimeMediaStart(
670+
adoptRuntimeMediaTiming(
660671
composition,
661672
el,
662673
authoredStarts.get(el.id) ?? existing.mediaStart,
663674
);
664675
existing.loop = el.loop;
665-
if (typeof existing.playbackRate !== "object") {
666-
existing.playbackRate = el.playbackRate;
667-
}
668676
if (
669677
el.volume > 0 &&
670678
Math.abs((existing.volume ?? 1) - el.volume) > BROWSER_MEDIA_EPSILON

0 commit comments

Comments
 (0)