Skip to content

Commit b7cc024

Browse files
authored
Merge pull request #5962 from remotion-dev/on-frame-callback
2 parents 809156b + 5778898 commit b7cc024

File tree

10 files changed

+358
-31
lines changed

10 files changed

+358
-31
lines changed

packages/core/src/Artifact.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type React from 'react';
2-
import {useContext, useEffect, useState} from 'react';
2+
import {useContext, useLayoutEffect, useState} from 'react';
33
import {RenderAssetManager} from './RenderAssetManager';
44
import type {DownloadBehavior} from './download-behavior';
55
import {useCurrentFrame} from './use-current-frame';
@@ -25,7 +25,7 @@ export const Artifact: React.FC<{
2525
return String(Math.random());
2626
});
2727

28-
useEffect(() => {
28+
useLayoutEffect(() => {
2929
if (!env.isRendering) {
3030
return;
3131
}

packages/web-renderer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export type {
1414
RenderStillOnWebImageFormat as RenderStillImageFormat,
1515
RenderStillOnWebOptions,
1616
} from './render-still-on-web';
17+
export type {OnFrameCallback} from './validate-video-frame';

packages/web-renderer/src/render-media-on-web.tsx

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type {
2121
InferProps,
2222
} from './props-if-has-props';
2323
import {createFrame} from './take-screenshot';
24+
import {createThrottledProgressCallback} from './throttle-progress';
25+
import {validateVideoFrame, type OnFrameCallback} from './validate-video-frame';
2426
import {waitForReady} from './wait-for-ready';
2527

2628
export type InputPropsIfHasProps<
@@ -78,6 +80,7 @@ type OptionalRenderMediaOnWebOptions<Schema extends AnyZodObject> = {
7880
frameRange: FrameRange | null;
7981
transparent: boolean;
8082
onArtifact: OnArtifact | null;
83+
onFrame: OnFrameCallback | null;
8184
};
8285

8386
export type RenderMediaOnWebOptions<
@@ -96,13 +99,10 @@ type InternalRenderMediaOnWebOptions<
9699

97100
// TODO: More containers
98101
// TODO: Audio
99-
// TODO: onFrame
100102
// TODO: Metadata
101103
// TODO: Validating inputs
102104
// TODO: Web file system API
103105
// TODO: Apply defaultCodec
104-
// TODO: Throttle onProgress
105-
// TODO: getStaticFiles()
106106

107107
const internalRenderMediaOnWeb = async <
108108
Schema extends AnyZodObject,
@@ -124,6 +124,7 @@ const internalRenderMediaOnWeb = async <
124124
frameRange,
125125
transparent,
126126
onArtifact,
127+
onFrame,
127128
}: InternalRenderMediaOnWebOptions<Schema, Props>) => {
128129
const cleanupFns: (() => void)[] = [];
129130
const format = containerToMediabunnyContainer(container);
@@ -249,6 +250,8 @@ const internalRenderMediaOnWeb = async <
249250
encodedFrames: 0,
250251
};
251252

253+
const throttledOnProgress = createThrottledProgressCallback(onProgress);
254+
252255
for (let i = realFrameRange[0]; i <= realFrameRange[1]; i++) {
253256
timeUpdater.current?.update(i);
254257
await waitForReady({
@@ -274,25 +277,42 @@ const internalRenderMediaOnWeb = async <
274277
throw new Error('renderMediaOnWeb() was cancelled');
275278
}
276279

280+
const timestamp = Math.round(
281+
((i - realFrameRange[0]) / resolved.fps) * 1_000_000,
282+
);
277283
const videoFrame = new VideoFrame(imageData, {
278-
timestamp: Math.round(
279-
((i - realFrameRange[0]) / resolved.fps) * 1_000_000,
280-
),
284+
timestamp,
281285
});
282286
progress.renderedFrames++;
283-
onProgress?.({...progress});
287+
throttledOnProgress?.({...progress});
288+
289+
// Process frame through onFrame callback if provided
290+
let frameToEncode = videoFrame;
291+
if (onFrame) {
292+
const returnedFrame = await onFrame(videoFrame);
293+
frameToEncode = validateVideoFrame({
294+
originalFrame: videoFrame,
295+
returnedFrame,
296+
expectedWidth: resolved.width,
297+
expectedHeight: resolved.height,
298+
expectedTimestamp: timestamp,
299+
});
300+
}
284301

285-
await videoSampleSource.add(new VideoSample(videoFrame));
302+
await videoSampleSource.add(new VideoSample(frameToEncode));
286303
progress.encodedFrames++;
287-
onProgress?.({...progress});
304+
throttledOnProgress?.({...progress});
288305

289-
videoFrame.close();
306+
frameToEncode.close();
290307

291308
if (signal?.aborted) {
292309
throw new Error('renderMediaOnWeb() was cancelled');
293310
}
294311
}
295312

313+
// Call progress one final time to ensure final state is reported
314+
onProgress?.({...progress});
315+
296316
videoSampleSource.close();
297317
await output.finalize();
298318

@@ -328,5 +348,6 @@ export const renderMediaOnWeb = <
328348
frameRange: options.frameRange ?? null,
329349
transparent: options.transparent ?? false,
330350
onArtifact: options.onArtifact ?? null,
351+
onFrame: options.onFrame ?? null,
331352
});
332353
};

packages/web-renderer/src/test/delay-render.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {useEffect, useState} from 'react';
2+
import {flushSync} from 'react-dom';
23
import {useDelayRender} from 'remotion';
34
import {test} from 'vitest';
45
import {renderStillOnWeb} from '../render-still-on-web';
@@ -12,7 +13,9 @@ test('should be able to use delayRender()', async () => {
1213

1314
useEffect(() => {
1415
setTimeout(() => {
15-
setData(true);
16+
flushSync(() => {
17+
setData(true);
18+
});
1619
continueRender(handle);
1720
}, 50);
1821
}, [continueRender, handle]);
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {useCurrentFrame} from 'remotion';
2+
import {expect, test} from 'vitest';
3+
import {renderMediaOnWeb} from '../render-media-on-web';
4+
5+
test('onFrame callback returning same frame should work', async (t) => {
6+
if (t.task.file.projectName === 'webkit') {
7+
t.skip();
8+
return;
9+
}
10+
11+
let frameCount = 0;
12+
13+
const Component: React.FC = () => {
14+
const frame = useCurrentFrame();
15+
return (
16+
<div style={{width: 100, height: 100, backgroundColor: 'blue'}}>
17+
Frame {frame}
18+
</div>
19+
);
20+
};
21+
22+
await renderMediaOnWeb({
23+
composition: {
24+
component: Component,
25+
id: 'on-frame-same-test',
26+
width: 100,
27+
height: 100,
28+
fps: 30,
29+
durationInFrames: 3,
30+
},
31+
inputProps: {},
32+
onFrame: (frame) => {
33+
frameCount++;
34+
// Return the same frame
35+
return frame;
36+
},
37+
});
38+
39+
expect(frameCount).toBe(3);
40+
});
41+
42+
test('onFrame callback returning new frame with correct dimensions and timestamp should work', async (t) => {
43+
if (t.task.file.projectName === 'webkit') {
44+
t.skip();
45+
return;
46+
}
47+
48+
let frameCount = 0;
49+
50+
const Component: React.FC = () => {
51+
const frame = useCurrentFrame();
52+
return (
53+
<div style={{width: 100, height: 100, backgroundColor: 'yellow'}}>
54+
Frame {frame}
55+
</div>
56+
);
57+
};
58+
59+
await renderMediaOnWeb({
60+
composition: {
61+
component: Component,
62+
id: 'on-frame-new-valid-test',
63+
width: 100,
64+
height: 100,
65+
fps: 30,
66+
durationInFrames: 3,
67+
},
68+
inputProps: {},
69+
onFrame: (frame) => {
70+
frameCount++;
71+
// Create a new canvas with the same dimensions
72+
const canvas = new OffscreenCanvas(100, 100);
73+
const ctx = canvas.getContext('2d')!;
74+
ctx.fillStyle = 'purple';
75+
ctx.fillRect(0, 0, 100, 100);
76+
77+
// Create new frame with same timestamp and dimensions
78+
const newFrame = new VideoFrame(canvas, {
79+
timestamp: frame.timestamp,
80+
});
81+
82+
return newFrame;
83+
},
84+
});
85+
86+
expect(frameCount).toBe(3);
87+
});
88+
89+
test('onFrame callback returning frame with wrong dimensions should throw', async (t) => {
90+
if (t.task.file.projectName === 'webkit') {
91+
t.skip();
92+
return;
93+
}
94+
95+
const Component: React.FC = () => {
96+
const frame = useCurrentFrame();
97+
return (
98+
<div style={{width: 100, height: 100, backgroundColor: 'red'}}>
99+
Frame {frame}
100+
</div>
101+
);
102+
};
103+
104+
await expect(async () => {
105+
await renderMediaOnWeb({
106+
composition: {
107+
component: Component,
108+
id: 'on-frame-wrong-dimensions-test',
109+
width: 100,
110+
height: 100,
111+
fps: 30,
112+
durationInFrames: 2,
113+
},
114+
inputProps: {},
115+
onFrame: (frame) => {
116+
// Create a frame with wrong dimensions
117+
const canvas = new OffscreenCanvas(200, 200);
118+
const ctx = canvas.getContext('2d')!;
119+
ctx.fillStyle = 'red';
120+
ctx.fillRect(0, 0, 200, 200);
121+
122+
const newFrame = new VideoFrame(canvas, {
123+
timestamp: frame.timestamp,
124+
});
125+
126+
return newFrame;
127+
},
128+
});
129+
}).rejects.toThrow(
130+
/VideoFrame dimensions mismatch: expected 100x100, got 0x0/,
131+
);
132+
});

packages/web-renderer/src/test/render-media.test.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {interpolateColors, useCurrentFrame} from 'remotion';
2-
import {test} from 'vitest';
2+
import {expect, test} from 'vitest';
33
import {renderMediaOnWeb} from '../render-media-on-web';
44

55
test('should render media on web', async (t) => {
@@ -34,3 +34,66 @@ test('should render media on web', async (t) => {
3434
inputProps: {},
3535
});
3636
});
37+
38+
test('should throttle onProgress callback to 250ms', async (t) => {
39+
if (t.task.file.projectName === 'webkit') {
40+
t.skip();
41+
return;
42+
}
43+
44+
const Component: React.FC = () => {
45+
const frame = useCurrentFrame();
46+
return (
47+
<svg viewBox="0 0 100 100" style={{width: 400, height: 400}}>
48+
<circle
49+
cx="50"
50+
cy="50"
51+
r="50"
52+
fill={interpolateColors(frame, [0, 30], ['red', 'blue'])}
53+
/>
54+
</svg>
55+
);
56+
};
57+
58+
const progressCalls: Array<{
59+
time: number;
60+
progress: {renderedFrames: number; encodedFrames: number};
61+
}> = [];
62+
const startTime = Date.now();
63+
64+
await renderMediaOnWeb({
65+
composition: {
66+
component: Component,
67+
id: 'throttle-test',
68+
width: 400,
69+
height: 400,
70+
fps: 30,
71+
durationInFrames: 30,
72+
},
73+
inputProps: {},
74+
onProgress: (progress) => {
75+
progressCalls.push({
76+
time: Date.now() - startTime,
77+
progress: {...progress},
78+
});
79+
},
80+
});
81+
82+
// Should have at least one progress call
83+
expect(progressCalls.length).toBeGreaterThan(0);
84+
85+
// Final call should have all frames rendered and encoded
86+
const finalCall = progressCalls[progressCalls.length - 1];
87+
expect(finalCall.progress.renderedFrames).toBe(30);
88+
expect(finalCall.progress.encodedFrames).toBe(30);
89+
90+
// Check that calls are throttled (if we have multiple calls)
91+
if (progressCalls.length > 1) {
92+
for (let i = 1; i < progressCalls.length - 1; i++) {
93+
const timeDiff = progressCalls[i].time - progressCalls[i - 1].time;
94+
// Allow some variance but should be around 250ms
95+
// We use 200ms as lower bound to account for timing variations
96+
expect(timeDiff).toBeGreaterThanOrEqual(200);
97+
}
98+
}
99+
});

0 commit comments

Comments
 (0)