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 <noreply@anthropic.com>
This commit is contained in:
Abelino Chavez 2026-01-30 21:18:06 -06:00
parent edd2138c6b
commit 5854906a87
2 changed files with 77 additions and 65 deletions

View File

@ -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();
}
}

View File

@ -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();
}
}