From 5854906a8778d0a000713be20b1daab12b823cfe Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 21:18:06 -0600 Subject: [PATCH] fix: dispose Input objects to prevent memory leaks (#656) Multiple functions were creating mediabunny Input objects without disposing them, causing WebCodecs decoder resources to leak. This led to RAM consumption spiraling until crash when importing video clips. Fixed by adding try/finally blocks to ensure input.dispose() is called: - getVideoInfo(): leaked Input when getting video metadata - generateThumbnail(): leaked Input when generating thumbnails - decodeAndMixAudioSource(): leaked Input when mixing audio Co-Authored-By: Claude Opus 4.5 --- apps/web/src/lib/media/mediabunny.ts | 86 +++++++++++++++------------- apps/web/src/lib/media/processing.ts | 56 +++++++++--------- 2 files changed, 77 insertions(+), 65 deletions(-) diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts index 11fe9837..2d4978f9 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -18,22 +18,26 @@ export async function getVideoInfo({ formats: ALL_FORMATS, }); - const duration = await input.computeDuration(); - const videoTrack = await input.getPrimaryVideoTrack(); + try { + const duration = await input.computeDuration(); + const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found in the file"); + if (!videoTrack) { + throw new Error("No video track found in the file"); + } + + const packetStats = await videoTrack.computePacketStats(100); + const fps = packetStats.averagePacketRate; + + return { + duration, + width: videoTrack.displayWidth, + height: videoTrack.displayHeight, + fps, + }; + } finally { + input.dispose(); } - - const packetStats = await videoTrack.computePacketStats(100); - const fps = packetStats.averagePacketRate; - - return { - duration, - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - fps, - }; } const SAMPLE_RATE = 44100; @@ -134,40 +138,44 @@ async function decodeAndMixAudioSource({ formats: ALL_FORMATS, }); - const audioTrack = await input.getPrimaryAudioTrack(); - if (!audioTrack) return; + try { + const audioTrack = await input.getPrimaryAudioTrack(); + if (!audioTrack) return; - const sink = new AudioBufferSink(audioTrack); - const trimEnd = source.trimStart + source.duration; + const sink = new AudioBufferSink(audioTrack); + const trimEnd = source.trimStart + source.duration; - for await (const { buffer, timestamp } of sink.buffers( - source.trimStart, - trimEnd, - )) { - const relativeTime = timestamp - source.trimStart; - const outputStartSample = Math.floor( - (source.startTime + relativeTime) * SAMPLE_RATE, - ); + for await (const { buffer, timestamp } of sink.buffers( + source.trimStart, + trimEnd, + )) { + const relativeTime = timestamp - source.trimStart; + const outputStartSample = Math.floor( + (source.startTime + relativeTime) * SAMPLE_RATE, + ); - // resample if needed - const resampleRatio = SAMPLE_RATE / buffer.sampleRate; + // resample if needed + const resampleRatio = SAMPLE_RATE / buffer.sampleRate; - for (let ch = 0; ch < NUM_CHANNELS; ch++) { - const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1); - const channelData = buffer.getChannelData(sourceChannel); - const outputChannel = mixBuffers[ch]; + for (let ch = 0; ch < NUM_CHANNELS; ch++) { + const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1); + const channelData = buffer.getChannelData(sourceChannel); + const outputChannel = mixBuffers[ch]; - const resampledLength = Math.floor(channelData.length * resampleRatio); - for (let i = 0; i < resampledLength; i++) { - const outputIdx = outputStartSample + i; - if (outputIdx < 0 || outputIdx >= totalSamples) continue; + const resampledLength = Math.floor(channelData.length * resampleRatio); + for (let i = 0; i < resampledLength; i++) { + const outputIdx = outputStartSample + i; + if (outputIdx < 0 || outputIdx >= totalSamples) continue; - const sourceIdx = Math.floor(i / resampleRatio); - if (sourceIdx < channelData.length) { - outputChannel[outputIdx] += channelData[sourceIdx]; + const sourceIdx = Math.floor(i / resampleRatio); + if (sourceIdx < channelData.length) { + outputChannel[outputIdx] += channelData[sourceIdx]; + } } } } + } finally { + input.dispose(); } } diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts index 6a6c7fd3..89c30b19 100644 --- a/apps/web/src/lib/media/processing.ts +++ b/apps/web/src/lib/media/processing.ts @@ -75,34 +75,38 @@ export async function generateThumbnail({ formats: ALL_FORMATS, }); - const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found in the file"); - } - - const canDecode = await videoTrack.canDecode(); - if (!canDecode) { - throw new Error("Video codec not supported for decoding"); - } - - const sink = new VideoSampleSink(videoTrack); - - const frame = await sink.getSample(timeInSeconds); - - if (!frame) { - throw new Error("Could not get frame at specified time"); - } - try { - return renderToThumbnailDataUrl({ - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - draw: ({ context, width, height }) => { - frame.draw(context, 0, 0, width, height); - }, - }); + const videoTrack = await input.getPrimaryVideoTrack(); + if (!videoTrack) { + throw new Error("No video track found in the file"); + } + + const canDecode = await videoTrack.canDecode(); + if (!canDecode) { + throw new Error("Video codec not supported for decoding"); + } + + const sink = new VideoSampleSink(videoTrack); + + const frame = await sink.getSample(timeInSeconds); + + if (!frame) { + throw new Error("Could not get frame at specified time"); + } + + try { + return renderToThumbnailDataUrl({ + width: videoTrack.displayWidth, + height: videoTrack.displayHeight, + draw: ({ context, width, height }) => { + frame.draw(context, 0, 0, width, height); + }, + }); + } finally { + frame.close(); + } } finally { - frame.close(); + input.dispose(); } }