From 62a1ded73395265390933167280c3c8a309cc7f6 Mon Sep 17 00:00:00 2001 From: Alec Watts Date: Mon, 26 Oct 2026 15:03:13 -0700 Subject: [PATCH] fix(web-renderer): release completed render results from queues --- .../web-renderer/src/render-media-on-web.tsx | 10 +++- .../src/render-operations-queue.ts | 4 +- .../web-renderer/src/render-still-on-web.tsx | 10 +++- .../src/test/cancel-render.test.tsx | 8 +++ .../src/test/render-queue-result.test.tsx | 60 +++++++++++++++++++ 5 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 packages/web-renderer/src/test/render-queue-result.test.tsx diff --git a/packages/web-renderer/src/render-media-on-web.tsx b/packages/web-renderer/src/render-media-on-web.tsx index 1140a67aab6..4a7f62d9a1b 100644 --- a/packages/web-renderer/src/render-media-on-web.tsx +++ b/packages/web-renderer/src/render-media-on-web.tsx @@ -860,7 +860,7 @@ export const renderMediaOnWeb = < const codec = options.videoCodec ?? getDefaultVideoCodecForContainer(container) ?? null; - onlyOneMediaRenderAtATimeQueue.ref = onlyOneMediaRenderAtATimeQueue.ref + const render = onlyOneMediaRenderAtATimeQueue.ref .catch(() => Promise.resolve()) .then(() => internalRenderMediaOnWeb({ @@ -896,5 +896,11 @@ export const renderMediaOnWeb = < }), ); - return onlyOneMediaRenderAtATimeQueue.ref as Promise; + // The queue only owns completion; callers own the result or rejection reason. + onlyOneMediaRenderAtATimeQueue.ref = render.then( + () => {}, + () => {}, + ); + + return render; }; diff --git a/packages/web-renderer/src/render-operations-queue.ts b/packages/web-renderer/src/render-operations-queue.ts index a33fd2bd3be..dda2d39d030 100644 --- a/packages/web-renderer/src/render-operations-queue.ts +++ b/packages/web-renderer/src/render-operations-queue.ts @@ -1,7 +1,7 @@ export const onlyOneMediaRenderAtATimeQueue = { - ref: Promise.resolve() as Promise, + ref: Promise.resolve(), }; export const onlyOneStillRenderAtATimeQueue = { - ref: Promise.resolve() as Promise, + ref: Promise.resolve(), }; diff --git a/packages/web-renderer/src/render-still-on-web.tsx b/packages/web-renderer/src/render-still-on-web.tsx index 6e346ef3e3e..b97ee190526 100644 --- a/packages/web-renderer/src/render-still-on-web.tsx +++ b/packages/web-renderer/src/render-still-on-web.tsx @@ -261,7 +261,7 @@ export const renderStillOnWeb = < >( options: RenderStillOnWebOptions, ): Promise => { - onlyOneStillRenderAtATimeQueue.ref = onlyOneStillRenderAtATimeQueue.ref + const render = onlyOneStillRenderAtATimeQueue.ref .catch(() => Promise.resolve()) .then(() => internalRenderStillOnWeb({ @@ -281,5 +281,11 @@ export const renderStillOnWeb = < }), ); - return onlyOneStillRenderAtATimeQueue.ref as Promise; + // The queue only owns completion; callers own the result or rejection reason. + onlyOneStillRenderAtATimeQueue.ref = render.then( + () => {}, + () => {}, + ); + + return render; }; diff --git a/packages/web-renderer/src/test/cancel-render.test.tsx b/packages/web-renderer/src/test/cancel-render.test.tsx index 88d24cbc20f..f11b0cbdd15 100644 --- a/packages/web-renderer/src/test/cancel-render.test.tsx +++ b/packages/web-renderer/src/test/cancel-render.test.tsx @@ -2,6 +2,10 @@ import {useEffect, useState} from 'react'; import {useDelayRender} from 'remotion'; import {expect, test} from 'vitest'; import {renderMediaOnWeb} from '../render-media-on-web'; +import { + onlyOneMediaRenderAtATimeQueue, + onlyOneStillRenderAtATimeQueue, +} from '../render-operations-queue'; import {renderStillOnWeb} from '../render-still-on-web'; import '../symbol-dispose'; @@ -52,6 +56,8 @@ test('preserves the original cancellation error and its causes', async () => { }), ).rejects.toBe(error); + await expect(onlyOneStillRenderAtATimeQueue.ref).resolves.toBeUndefined(); + await expect( renderMediaOnWeb({ licenseKey: 'free-license', @@ -60,4 +66,6 @@ test('preserves the original cancellation error and its causes', async () => { container: 'webm', }), ).rejects.toBe(error); + + await expect(onlyOneMediaRenderAtATimeQueue.ref).resolves.toBeUndefined(); }); diff --git a/packages/web-renderer/src/test/render-queue-result.test.tsx b/packages/web-renderer/src/test/render-queue-result.test.tsx new file mode 100644 index 00000000000..46f8779d5f4 --- /dev/null +++ b/packages/web-renderer/src/test/render-queue-result.test.tsx @@ -0,0 +1,60 @@ +import {expect, test} from 'vitest'; +import {renderMediaOnWeb} from '../render-media-on-web'; +import { + onlyOneMediaRenderAtATimeQueue, + onlyOneStillRenderAtATimeQueue, +} from '../render-operations-queue'; +import {renderStillOnWeb} from '../render-still-on-web'; +import '../symbol-dispose'; + +const composition = { + component: () =>
, + id: 'queue-result', + width: 32, + height: 32, + fps: 30, + durationInFrames: 1, +}; + +test.each(['still', 'media'] as const)( + '%s queue does not keep completed results and leaves caller results usable', + async (kind) => { + const queue = + kind === 'still' + ? onlyOneStillRenderAtATimeQueue + : onlyOneMediaRenderAtATimeQueue; + const render = () => + kind === 'still' + ? renderStillOnWeb({ + composition, + inputProps: {}, + frame: 0, + licenseKey: 'free-license', + }) + : renderMediaOnWeb({ + composition, + inputProps: {}, + container: 'webm', + videoCodec: 'vp8', + muted: true, + outputTarget: 'arraybuffer', + licenseKey: 'free-license', + }); + + const first = render(); + const second = render(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + await expect(queue.ref).resolves.toBeUndefined(); + for (const result of [firstResult, secondResult]) { + if ('canvas' in result) { + const canvas = await result.canvas(); + expect(canvas.width).toBe(32); + expect(canvas.height).toBe(32); + expect((await result.blob()).size).toBeGreaterThan(0); + } else { + expect((await result.getBlob()).size).toBeGreaterThan(0); + } + } + }, +);