Skip to content
Open
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
3 changes: 3 additions & 0 deletions docs/reference/speed-ramps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ which the preview and the renderer both use, so the render shows the frames the
preview showed. Set `data-duration` on the clip to fix how long it holds the
timeline; without it the length follows from the source and the speed.

If a browser probe discovers a rate lane on an existing clip, export preserves
the scene's trim in both the ramp's position and the source media in-point.

Pitch is kept. The preview leaves the browser's default pitch correction on.
The render time-stretches audio in short constant-speed slices (a quarter
second, coarser on clips longer than a minute), which approximates the curve
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export {
parseAudioElements,
parseMasterFxChain,
processCompositionAudio,
videoAudioId,
} from "./services/audioMixer.js";
export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
Expand Down
6 changes: 5 additions & 1 deletion packages/engine/src/services/audioMixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,10 @@ export function parseMasterFxChain(html: string): string | undefined {
return readMasterFxChain(parseHTML(unwrapTemplate(html)).document);
}

export function videoAudioId(videoId: string): string {
return `${videoId}-audio`;
}

export function parseAudioElements(html: string): AudioElement[] {
const elements: AudioElement[] = [];
const { document } = parseHTML(unwrapTemplate(html));
Expand Down Expand Up @@ -632,7 +636,7 @@ export function parseAudioElements(html: string): AudioElement[] {
const src = resolveMediaElementSrc(el);
if (!id || !src || isHidden(el) || memberGroupHidden(el)) continue;
if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue;
elements.push(build(el, `${id}-audio`, src, "video", true));
elements.push(build(el, videoAudioId(id), src, "video", true));
}

return elements;
Expand Down
60 changes: 60 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe("discoverMediaFromBrowser", () => {
currentSrcById: Record<string, string>,
serializeCallback = false,
intrinsicDurationById: Record<string, number> = {},
defaultPlaybackRateById: Record<string, number> = {},
) {
const { document } = parseHTML(html);
for (const [id, currentSrc] of Object.entries(currentSrcById)) {
Expand All @@ -46,6 +47,13 @@ describe("discoverMediaFromBrowser", () => {
const element = document.getElementById(id);
if (element) Object.defineProperty(element, "duration", { value: duration });
}
for (const [id, defaultPlaybackRate] of Object.entries(defaultPlaybackRateById)) {
const element = document.getElementById(id);
if (element) {
Object.defineProperty(element, "defaultPlaybackRate", { value: defaultPlaybackRate });
Object.defineProperty(element, "playbackRate", { value: defaultPlaybackRate * 2 });
}
}
const previousDocument = Reflect.get(globalThis, "document");
Reflect.set(globalThis, "document", document);
try {
Expand Down Expand Up @@ -129,6 +137,58 @@ describe("discoverMediaFromBrowser", () => {
expect(media[0]).toMatchObject({ start: 0, end: 2, duration: 2, mediaStart: 1 });
});

it("reads authored rates and native defaults after the browser callback is serialized", async () => {
const media = await discover(
`<video id="authored" src="clip.mp4" data-start="0" data-duration="2" data-playback-rate="1.5"></video>
<audio id="native" src="clip.wav" data-start="0" data-duration="2"></audio>
<video id="invalid" src="clip.mp4" data-start="0" data-duration="2" data-playback-rate="0"></video>
<audio id="bounded" src="clip.wav" data-start="0" data-duration="2" data-playback-rate="20"></audio>
<video id="default" src="clip.mp4" data-start="0" data-duration="2"></video>`,
{},
true,
{},
{ authored: 0.5, native: 0.5, invalid: 1.25 },
);

expect(media.map((element) => element.playbackRate)).toEqual([1.5, 0.5, 1.25, 10, 1]);
});

it.each(["video", "audio"])(
"discovers a %s rate lane after the browser callback is serialized",
async (tag) => {
const lane = {
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 2 },
],
};
const automation = JSON.stringify({ version: 1, lanes: [lane] });
const media = await discover(
`<${tag} id="clip" src="clip.mp4" data-start="0" data-duration="1" data-playback-rate="3" data-automation='${automation}'></${tag}>`,
{},
true,
{},
{ clip: 0.5 },
);

expect(media[0]?.playbackRate).toEqual(lane);
},
);

it("uses scalar rates when automation is invalid or has no rate lane", async () => {
const media = await discover(
`<video id="invalid" src="clip.mp4" data-start="0" data-playback-rate="1.5" data-automation="{nope"></video>
<audio id="other" src="clip.wav" data-start="0" data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0.5}]}]}'></audio>`,
{},
true,
{},
{ other: 0.5 },
);

expect(media.map((element) => element.playbackRate)).toEqual([1.5, 0.5]);
});

it("reports colliding empty-src videos by render id, not author id", async () => {
const media = await discover(
`<video id="clip" data-hf-render-id="clip" src="" data-start="0" data-end="4" data-media-start="10"></video>` +
Expand Down
34 changes: 33 additions & 1 deletion packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
type UnresolvedElement,
} from "@hyperframes/core";
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
import { readPlaybackRate } from "@hyperframes/parsers/media-duration";
import { resolveRateSpec } from "@hyperframes/core/speed-ramp";
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
import {
assignBundledRuntimeCompositionIds,
Expand Down Expand Up @@ -2235,6 +2237,7 @@ export interface BrowserMediaElement {
/** True when compilation inferred duration from the fallback source. */
durationInferred: boolean;
mediaStart: number;
playbackRate: RateSpec;
loop: boolean;
hasAudio: boolean;
volume: number;
Expand All @@ -2260,6 +2263,9 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
durationInferred: boolean;
playbackStartRaw: string | null;
mediaStartRaw: string | null;
playbackRateRaw: string | null;
automationRaw: string | null;
defaultPlaybackRate: number;
loop: boolean;
hasAudio: boolean;
volume: number;
Expand Down Expand Up @@ -2310,6 +2316,12 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
: (htmlEl as HTMLVideoElement | HTMLAudioElement).duration;
const playbackStartRaw = htmlEl.getAttribute("data-playback-start");
const mediaStartRaw = htmlEl.getAttribute("data-media-start");
const playbackRateRaw = htmlEl.getAttribute("data-playback-rate");
const automationRaw = htmlEl.getAttribute("data-automation");
// playbackRate can include the transport speed; defaultPlaybackRate is the clip's own rate.
const defaultPlaybackRate = isImage
? 1
: (htmlEl as HTMLVideoElement | HTMLAudioElement).defaultPlaybackRate;
const loop = htmlEl.hasAttribute("loop");
const hasAudio = htmlEl.getAttribute("data-has-audio") === "true";
const volume = parseFloat(htmlEl.getAttribute("data-volume") || "1");
Expand All @@ -2328,6 +2340,9 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
durationInferred,
playbackStartRaw,
mediaStartRaw,
playbackRateRaw,
automationRaw,
defaultPlaybackRate,
loop,
hasAudio,
volume,
Expand All @@ -2339,7 +2354,17 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
});

return elements.map(
({ endRaw, durationRaw, intrinsicDuration, playbackStartRaw, mediaStartRaw, ...element }) => ({
({
endRaw,
durationRaw,
intrinsicDuration,
playbackStartRaw,
mediaStartRaw,
playbackRateRaw,
automationRaw,
defaultPlaybackRate,
...element
}) => ({
...element,
end: parseStrictFiniteTimingNumber(endRaw) ?? 0,
duration:
Expand All @@ -2353,6 +2378,13 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
return null;
},
}),
playbackRate: resolveRateSpec(
automationRaw,
readPlaybackRate(
(name) => (name === "data-playback-rate" ? playbackRateRaw : null),
defaultPlaybackRate,
),
),
}),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,34 @@ describe("appendAutoDetectedVideoAudio", () => {
});
});

it.each([
1.5,
{
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 3, v: 2 },
],
},
] satisfies NonNullable<VideoElement["playbackRate"]>[])(
"keeps the video's rate and trim in its detected audio (%j)",
(playbackRate) => {
const composition: Parameters<typeof appendAutoDetectedVideoAudio>[0] = {
videos: [makeVideo({ start: 2, end: 5, mediaStart: 0.5, playbackRate })],
audios: [],
};

appendAutoDetectedVideoAudio(composition, [makeExtracted("v1", true)]);

expect(composition.audios[0]).toMatchObject({
start: 2,
end: 5,
mediaStart: 0.5,
playbackRate,
});
},
);

it("skips a muted video even when the source file has audio", () => {
const composition = {
videos: [makeVideo({ hasAudio: false })],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
resolveProjectRelativeSrc,
runVideoExtractionWithRetry,
safeVideoExtractionSourceIdentity,
videoAudioId,
} from "@hyperframes/engine";
import {
collectVideoMetadataHints,
Expand Down Expand Up @@ -573,11 +574,12 @@ export function appendAutoDetectedVideoAudio(
const video = composition.videos.find((v) => v.id === ext.videoId);
if (!video || !video.hasAudio || video.hidden || existingAudioSrcs.has(video.src)) continue;
composition.audios.push({
id: `${video.id}-audio`,
id: videoAudioId(video.id),
src: video.src,
start: video.start,
end: video.end,
mediaStart: video.mediaStart,
playbackRate: video.playbackRate,
layer: 0,
volume: 1.0,
type: "video",
Expand Down
Loading
Loading