diff --git a/packages/docs/docs/web-renderer/render-media-on-web.mdx b/packages/docs/docs/web-renderer/render-media-on-web.mdx index 83420b3e5e6..1cbbf7aa8d4 100644 --- a/packages/docs/docs/web-renderer/render-media-on-web.mdx +++ b/packages/docs/docs/web-renderer/render-media-on-web.mdx @@ -104,9 +104,19 @@ React to render progress. The callback receives an object with the following pro ```tsx twoslash import type {RenderMediaOnWebProgressCallback} from '@remotion/web-renderer'; -const onProgress: RenderMediaOnWebProgressCallback = ({renderedFrames, encodedFrames}) => { - console.log(`Rendered ${renderedFrames} frames`); +const onProgress: RenderMediaOnWebProgressCallback = ({ + encodedFrames, + renderEstimatedTime, + progress, + doneIn, +}) => { console.log(`Encoded ${encodedFrames} frames`); + console.log(`Overall progress: ${Math.round(progress * 100)}%`); + console.log(`Estimated render time remaining: ${renderEstimatedTime}ms`); + + if (doneIn !== null) { + console.log(`Finished rendering in ${doneIn}ms`); + } }; ``` diff --git a/packages/docs/docs/web-renderer/types.mdx b/packages/docs/docs/web-renderer/types.mdx index ed19995ec04..6d25a735641 100644 --- a/packages/docs/docs/web-renderer/types.mdx +++ b/packages/docs/docs/web-renderer/types.mdx @@ -83,8 +83,26 @@ import type {RenderMediaOnWebProgress} from '@remotion/web-renderer'; // ^? ``` -- `renderedFrames`: The number of frames that have been rendered -- `encodedFrames`: The number of frames that have been encoded +### `renderedFrames` + +Deprecated and kept for backward compatibility. The number of frames that have been rendered. +Prefer `progress` for overall status updates. + +### `encodedFrames` + +The number of frames that have been encoded. + +### `doneIn` + +The total time in milliseconds from render start until all frames were encoded, or `null` while encoding is still in progress. + +### `renderEstimatedTime` + +Estimated time remaining in milliseconds until rendering is done. + +### `progress` + +Overall progress as a number between `0` and `1`. ## `RenderMediaOnWebProgressCallback` diff --git a/packages/promo-pages/src/components/homepage/CommunityStatsItems.tsx b/packages/promo-pages/src/components/homepage/CommunityStatsItems.tsx index a84d96e78d6..152efa99c13 100644 --- a/packages/promo-pages/src/components/homepage/CommunityStatsItems.tsx +++ b/packages/promo-pages/src/components/homepage/CommunityStatsItems.tsx @@ -177,7 +177,7 @@ export const GitHubStars: React.FC = () => { width="45px" /> { + onProgress: ({progress}) => { setState({ type: 'progress', - progress: renderedFrames / durationInFrames, + progress, }); }, }); diff --git a/packages/promo-pages/src/components/homepage/GitHubButton.tsx b/packages/promo-pages/src/components/homepage/GitHubButton.tsx index 9c67a474073..ccde04e935b 100644 --- a/packages/promo-pages/src/components/homepage/GitHubButton.tsx +++ b/packages/promo-pages/src/components/homepage/GitHubButton.tsx @@ -16,7 +16,7 @@ export const GithubButton: React.FC = () => {
GitHub{' '}
- {'38k'} + {'39k'}
); diff --git a/packages/renderer/src/test/render-still.test.ts b/packages/renderer/src/test/render-still.test.ts index 3327e789894..9d9363c4f41 100644 --- a/packages/renderer/src/test/render-still.test.ts +++ b/packages/renderer/src/test/render-still.test.ts @@ -34,7 +34,7 @@ test( ).toThrow(/not be NaN, but is NaN/); }, { - timeout: 30000, + timeout: 90000, }, ); @@ -66,7 +66,7 @@ test( expect(contentType).toBe('image/png'); }, { - timeout: 30000, + timeout: 90000, }, ); @@ -100,7 +100,7 @@ test( /Cannot use frame 200: Duration of composition is 30, therefore the highest frame that can be rendered is 29/, ); }, - {timeout: 30000}, + {timeout: 90000}, ); test( @@ -133,6 +133,6 @@ test( ).toThrow(/Image format should be one of: "png", "jpeg", "pdf", "webp"/); }, { - timeout: 30000, + timeout: 90000, }, ); diff --git a/packages/studio/src/components/RenderModal/ClientRenderProgress.tsx b/packages/studio/src/components/RenderModal/ClientRenderProgress.tsx index 43a9a203e38..e2807e8feae 100644 --- a/packages/studio/src/components/RenderModal/ClientRenderProgress.tsx +++ b/packages/studio/src/components/RenderModal/ClientRenderProgress.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {LIGHT_TEXT} from '../../helpers/colors'; import {Spacing} from '../layout'; import {CircularProgress} from '../RenderQueue/CircularProgress'; +import {getClientRenderProgressMessage} from '../RenderQueue/client-render-progress'; import type {ClientRenderJob} from '../RenderQueue/client-side-render-types'; import {SuccessIcon} from '../RenderQueue/SuccessIcon'; @@ -26,22 +27,28 @@ const right: React.CSSProperties = { flex: 1, }; -const EncodingProgress: React.FC<{ +const ProgressStatus: React.FC<{ readonly encodedFrames: number; readonly totalFrames: number; -}> = ({encodedFrames, totalFrames}) => { - const done = encodedFrames === totalFrames; - const progress = totalFrames > 0 ? encodedFrames / totalFrames : 0; + readonly doneIn: number | null; + readonly renderEstimatedTime: number; + readonly progress: number; +}> = ({encodedFrames, totalFrames, doneIn, renderEstimatedTime, progress}) => { + const done = doneIn !== null; + const message = getClientRenderProgressMessage({ + encodedFrames, + totalFrames, + doneIn, + renderEstimatedTime, + progress, + }); return (
{done ? : } -
- {done - ? `Encoded ${totalFrames} frames` - : `Encoding ${encodedFrames} / ${totalFrames} frames`} -
+
{message}
+ {doneIn !== null ?
{doneIn}ms
: null}
); }; @@ -92,15 +99,19 @@ export const ClientRenderProgress: React.FC<{ ); } - const {encodedFrames, totalFrames} = job.progress; + const {encodedFrames, totalFrames, doneIn, renderEstimatedTime, progress} = + job.progress; return (
{job.type === 'client-video' && ( - )} diff --git a/packages/studio/src/components/RenderQueue/ClientRenderQueueProcessor.tsx b/packages/studio/src/components/RenderQueue/ClientRenderQueueProcessor.tsx index 09281699822..32656eee9c9 100644 --- a/packages/studio/src/components/RenderQueue/ClientRenderQueueProcessor.tsx +++ b/packages/studio/src/components/RenderQueue/ClientRenderQueueProcessor.tsx @@ -137,6 +137,9 @@ export const ClientRenderQueueProcessor: React.FC = () => { onProgress(job.id, { encodedFrames: progress.encodedFrames, totalFrames, + doneIn: progress.doneIn, + renderEstimatedTime: progress.renderEstimatedTime, + progress: progress.progress, }); }, outputTarget: 'web-fs', diff --git a/packages/studio/src/components/RenderQueue/RenderQueueItemStatus.tsx b/packages/studio/src/components/RenderQueue/RenderQueueItemStatus.tsx index 251ece350ab..499256ca8ac 100644 --- a/packages/studio/src/components/RenderQueue/RenderQueueItemStatus.tsx +++ b/packages/studio/src/components/RenderQueue/RenderQueueItemStatus.tsx @@ -94,8 +94,7 @@ export const RenderQueueItemStatus: React.FC<{ if (job.status === 'running') { let progressValue: number; if (isClientJob) { - const {encodedFrames, totalFrames} = job.progress; - progressValue = totalFrames > 0 ? encodedFrames / totalFrames : 0; + progressValue = job.progress.progress; } else { progressValue = job.progress.value; } diff --git a/packages/studio/src/components/RenderQueue/RenderQueueProgressMessage.tsx b/packages/studio/src/components/RenderQueue/RenderQueueProgressMessage.tsx index d9f9b7725dc..93948773ec4 100644 --- a/packages/studio/src/components/RenderQueue/RenderQueueProgressMessage.tsx +++ b/packages/studio/src/components/RenderQueue/RenderQueueProgressMessage.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useContext} from 'react'; import {ModalsContext} from '../../state/modals'; import {useZIndex} from '../../state/z-index'; +import {getClientRenderProgressMessage} from './client-render-progress'; import type {AnyRenderJob} from './context'; import {isClientRenderJob} from './context'; import {renderQueueItemSubtitleStyle} from './item-style'; @@ -29,9 +30,7 @@ export const RenderQueueProgressMessage: React.FC<{ }, [job.id, setSelectedModal]); const message = isClientJob - ? job.progress.totalFrames === 0 - ? 'Getting composition' - : `Encoding frame ${job.progress.encodedFrames}/${job.progress.totalFrames}` + ? getClientRenderProgressMessage(job.progress) : job.progress.message; return ( diff --git a/packages/studio/src/components/RenderQueue/client-render-progress.ts b/packages/studio/src/components/RenderQueue/client-render-progress.ts new file mode 100644 index 00000000000..58f375fb43d --- /dev/null +++ b/packages/studio/src/components/RenderQueue/client-render-progress.ts @@ -0,0 +1,36 @@ +import type {ClientRenderJobProgress} from './client-side-render-types'; + +export const formatEtaString = (timeRemainingInMilliseconds: number) => { + const remainingTime = timeRemainingInMilliseconds / 1000; + const remainingTimeHours = Math.floor(remainingTime / 3600); + const remainingTimeMinutes = Math.floor((remainingTime % 3600) / 60); + const remainingTimeSeconds = Math.floor(remainingTime % 60); + + return [ + remainingTimeHours ? `${remainingTimeHours}h` : null, + remainingTimeMinutes ? `${remainingTimeMinutes}m` : null, + `${remainingTimeSeconds}s`, + ] + .filter((value): value is string => Boolean(value)) + .join(' '); +}; + +export const getClientRenderProgressMessage = ( + progress: ClientRenderJobProgress, +) => { + if (progress.totalFrames === 0) { + return 'Getting composition'; + } + + if (progress.doneIn !== null) { + return `Encoded ${progress.totalFrames}/${progress.totalFrames}`; + } + + if (progress.renderEstimatedTime > 0) { + const etaString = `, time remaining: ${formatEtaString(progress.renderEstimatedTime)}`; + + return `Rendering ${progress.encodedFrames}/${progress.totalFrames}${etaString}`; + } + + return `Encoded ${progress.encodedFrames}/${progress.totalFrames}`; +}; diff --git a/packages/studio/src/components/RenderQueue/client-side-render-types.ts b/packages/studio/src/components/RenderQueue/client-side-render-types.ts index e9a9d374dca..3f5f7b8365d 100644 --- a/packages/studio/src/components/RenderQueue/client-side-render-types.ts +++ b/packages/studio/src/components/RenderQueue/client-side-render-types.ts @@ -11,6 +11,9 @@ import type {LogLevel} from 'remotion'; export type ClientRenderJobProgress = { encodedFrames: number; totalFrames: number; + doneIn: number | null; + renderEstimatedTime: number; + progress: number; }; export type GetBlobCallback = () => Promise; diff --git a/packages/studio/src/components/RenderQueue/context.tsx b/packages/studio/src/components/RenderQueue/context.tsx index 8ef94544eab..f360b7ddeac 100644 --- a/packages/studio/src/components/RenderQueue/context.tsx +++ b/packages/studio/src/components/RenderQueue/context.tsx @@ -144,7 +144,13 @@ export const RenderQueueContextProvider: React.FC<{ ? ({ ...job, status: 'running', - progress: {encodedFrames: 0, totalFrames: 0}, + progress: { + encodedFrames: 0, + totalFrames: 0, + doneIn: null, + renderEstimatedTime: 0, + progress: 0, + }, } as ClientRenderJob) : job, ), diff --git a/packages/studio/src/test/client-render-progress.test.ts b/packages/studio/src/test/client-render-progress.test.ts new file mode 100644 index 00000000000..0bbd09fdddd --- /dev/null +++ b/packages/studio/src/test/client-render-progress.test.ts @@ -0,0 +1,47 @@ +import {expect, test} from 'bun:test'; +import { + formatEtaString, + getClientRenderProgressMessage, +} from '../components/RenderQueue/client-render-progress'; + +test('formats ETA strings like server-side renders', () => { + expect(formatEtaString(5_000)).toBe('5s'); + expect(formatEtaString(65_000)).toBe('1m 5s'); + expect(formatEtaString(3_725_000)).toBe('1h 2m 5s'); +}); + +test('formats client render progress message while rendering', () => { + expect( + getClientRenderProgressMessage({ + encodedFrames: 10, + totalFrames: 30, + doneIn: null, + renderEstimatedTime: 65_000, + progress: 0.55, + }), + ).toBe('Rendering 10/30, time remaining: 1m 5s'); +}); + +test('formats client render progress message while encoding', () => { + expect( + getClientRenderProgressMessage({ + encodedFrames: 24, + totalFrames: 30, + doneIn: null, + renderEstimatedTime: 0, + progress: 0.94, + }), + ).toBe('Encoded 24/30'); +}); + +test('returns getting composition before frame totals are known', () => { + expect( + getClientRenderProgressMessage({ + encodedFrames: 0, + totalFrames: 0, + doneIn: null, + renderEstimatedTime: 0, + progress: 0, + }), + ).toBe('Getting composition'); +}); diff --git a/packages/web-renderer/src/render-media-on-web.tsx b/packages/web-renderer/src/render-media-on-web.tsx index bcf14b349cd..e2b0255d663 100644 --- a/packages/web-renderer/src/render-media-on-web.tsx +++ b/packages/web-renderer/src/render-media-on-web.tsx @@ -75,10 +75,22 @@ type MandatoryRenderMediaOnWebOptions< composition: CompositionCalculateMetadataOrExplicit; }; +const MAX_RECENT_FRAME_TIMINGS = 150; + export type RenderMediaOnWebProgress = { + /** + * @deprecated Kept for backward compatibility. Use `progress` for overall + * status updates. + */ renderedFrames: number; encodedFrames: number; - // TODO: encodedDoneIn, renderEstimatedTime, progress + /** + * The total time in milliseconds from render start until all frames were + * encoded, or `null` while encoding is still in progress. + */ + doneIn: number | null; + renderEstimatedTime: number; + progress: number; }; export type RenderMediaOnWebResult = { @@ -335,6 +347,10 @@ const internalRenderMediaOnWeb = async < const totalFrames = realFrameRange[1] - realFrameRange[0] + 1; const durationInSeconds = totalFrames / resolved.fps; + const renderStart = Date.now(); + let doneIn: number | null = null; + let renderEstimatedTime = 0; + const recentFrameTimings: number[] = []; if (videoSampleSource) { outputWithCleanup.output.addVideoTrack( @@ -372,10 +388,26 @@ const internalRenderMediaOnWeb = async < throw new Error('renderMediaOnWeb() was cancelled'); } - const progress: RenderMediaOnWebProgress = { + let timeOfLastFrame = Date.now(); + const progress = { renderedFrames: 0, encodedFrames: 0, }; + const getProgressPayload = (): RenderMediaOnWebProgress => { + const overallProgress = + Math.round( + (70 * progress.renderedFrames + 30 * progress.encodedFrames) / + totalFrames, + ) / 100; + + return { + renderedFrames: progress.renderedFrames, + encodedFrames: progress.encodedFrames, + doneIn, + renderEstimatedTime, + progress: overallProgress, + }; + }; for (let frame = realFrameRange[0]; frame <= realFrameRange[1]; frame++) { if (signal?.aborted) { @@ -443,8 +475,25 @@ const internalRenderMediaOnWeb = async < } } + const now = Date.now(); + const timeToRenderInMilliseconds = now - timeOfLastFrame; + timeOfLastFrame = now; + progress.renderedFrames++; - throttledOnProgress?.({...progress}); + recentFrameTimings.push(timeToRenderInMilliseconds); + if (recentFrameTimings.length > MAX_RECENT_FRAME_TIMINGS) { + recentFrameTimings.shift(); + } + + const recentTimingsSum = recentFrameTimings.reduce( + (sum, time) => sum + time, + 0, + ); + const newAverage = recentTimingsSum / recentFrameTimings.length; + const remainingFrames = totalFrames - progress.renderedFrames; + renderEstimatedTime = Math.round(remainingFrames * newAverage); + + throttledOnProgress?.(getProgressPayload()); const audioCombineStart = performance.now(); const assets = collectAssets.current!.collectAssets(); @@ -487,7 +536,11 @@ const internalRenderMediaOnWeb = async < internalState.addAddSampleTime(performance.now() - addSampleStart); progress.encodedFrames++; - throttledOnProgress?.({...progress}); + if (progress.encodedFrames === totalFrames) { + doneIn = Date.now() - renderStart; + } + + throttledOnProgress?.(getProgressPayload()); if (signal?.aborted) { throw new Error('renderMediaOnWeb() was cancelled'); @@ -495,7 +548,7 @@ const internalRenderMediaOnWeb = async < } // Call progress one final time to ensure final state is reported - onProgress?.({...progress}); + onProgress?.(getProgressPayload()); videoSampleSource?.videoSampleSource.close(); audioSampleSource?.audioSampleSource.close(); diff --git a/packages/web-renderer/src/test/frame-range.test.tsx b/packages/web-renderer/src/test/frame-range.test.tsx index 33c329daf7c..402d80ebb59 100644 --- a/packages/web-renderer/src/test/frame-range.test.tsx +++ b/packages/web-renderer/src/test/frame-range.test.tsx @@ -57,9 +57,18 @@ test('should render with valid frame range', async (t) => { frameRange: [10, 15], licenseKey: 'free-license', }); - expect(finalProgress).toEqual({ + if (finalProgress === null) { + throw new Error('Expected final progress to be reported'); + } + + const resolvedProgress = finalProgress as RenderMediaOnWebProgress; + + expect(resolvedProgress).toEqual({ renderedFrames: 6, encodedFrames: 6, + doneIn: expect.any(Number), + renderEstimatedTime: 0, + progress: 1, }); }); diff --git a/packages/web-renderer/src/test/render-media.test.tsx b/packages/web-renderer/src/test/render-media.test.tsx index 39c49789d5f..cdf799813fd 100644 --- a/packages/web-renderer/src/test/render-media.test.tsx +++ b/packages/web-renderer/src/test/render-media.test.tsx @@ -2,6 +2,7 @@ import {ALL_FORMATS, BlobSource, Input} from 'mediabunny'; import {interpolateColors, useCurrentFrame} from 'remotion'; import {VERSION} from 'remotion/version'; import {expect, test} from 'vitest'; +import type {RenderMediaOnWebProgress} from '../render-media-on-web'; import {renderMediaOnWeb} from '../render-media-on-web'; import '../symbol-dispose'; @@ -130,7 +131,7 @@ test('should throttle onProgress callback to 250ms', {retry: 2}, async (t) => { const progressCalls: Array<{ time: number; - progress: {renderedFrames: number; encodedFrames: number}; + progress: RenderMediaOnWebProgress; }> = []; const startTime = Date.now(); @@ -157,8 +158,13 @@ test('should throttle onProgress callback to 250ms', {retry: 2}, async (t) => { // Final call should have all frames rendered and encoded const finalCall = progressCalls[progressCalls.length - 1]; - expect(finalCall.progress.renderedFrames).toBe(30); - expect(finalCall.progress.encodedFrames).toBe(30); + expect(finalCall.progress).toEqual({ + renderedFrames: 30, + encodedFrames: 30, + doneIn: expect.any(Number), + renderEstimatedTime: 0, + progress: 1, + }); // Check that calls are throttled (if we have multiple calls) if (progressCalls.length > 1) { @@ -171,6 +177,62 @@ test('should throttle onProgress callback to 250ms', {retry: 2}, async (t) => { } }); +test( + 'should provide progress estimates while rendering', + {retry: 2}, + async (t) => { + if (t.task.file.projectName === 'webkit') { + t.skip(); + return; + } + + const progressCalls: RenderMediaOnWebProgress[] = []; + const Component: React.FC = () => null; + + await renderMediaOnWeb({ + composition: { + component: Component, + id: 'progress-estimation-test', + width: 100, + height: 100, + fps: 30, + durationInFrames: 20, + }, + inputProps: {}, + onFrame: async (frame) => { + await new Promise((resolve) => { + setTimeout(resolve, 40); + }); + return frame; + }, + onProgress: (progress) => { + progressCalls.push({...progress}); + }, + }); + + expect(progressCalls.length).toBeGreaterThan(1); + + const intermediateCall = progressCalls.find((progress) => { + return progress.encodedFrames < 20; + }); + expect(intermediateCall).toBeDefined(); + expect(intermediateCall?.renderedFrames).toBeGreaterThan(0); + expect(intermediateCall?.renderEstimatedTime).toBeGreaterThan(0); + expect(intermediateCall?.doneIn).toBeNull(); + expect(intermediateCall?.progress).toBeGreaterThan(0); + expect(intermediateCall?.progress).toBeLessThan(1); + + const finalCall = progressCalls[progressCalls.length - 1]; + expect(finalCall).toEqual({ + renderedFrames: 20, + encodedFrames: 20, + doneIn: expect.any(Number), + renderEstimatedTime: 0, + progress: 1, + }); + }, +); + test('should include "Made with Remotion" metadata', async (t) => { if (t.task.file.projectName === 'webkit') { t.skip();