From 31147d6e967f9deb7a13ea2b5f21dc864fd23c8f Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Tue, 24 Feb 2026 12:21:13 +0100 Subject: [PATCH 1/3] fix: surface StreamTarget write errors instead of swallowing them (#305) * fix: await StreamTarget writes to prevent overlapping OPFS operations * no need for flush * preserve fire and forget writes * Remove pendingWrites, add missing mutex acquire to ADTS muxer finalize method --------- Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- src/adts/adts-muxer.ts | 5 +- src/writer.ts | 19 ++++- test/browser/adts-muxing.test.ts | 130 +++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 test/browser/adts-muxing.test.ts diff --git a/src/adts/adts-muxer.ts b/src/adts/adts-muxer.ts index 0cadd8a..f004ae3 100644 --- a/src/adts/adts-muxer.ts +++ b/src/adts/adts-muxer.ts @@ -111,5 +111,8 @@ export class AdtsMuxer extends Muxer { throw new Error('ADTS does not support subtitles.'); } - async finalize() {} + async finalize() { + const release = await this.mutex.acquire(); // Required so that finalize() can't resolve before other calls + release(); + } } diff --git a/src/writer.ts b/src/writer.ts index da40e1b..73a62db 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -215,6 +215,7 @@ export class StreamTargetWriter extends Writer { private lastWriteEnd = 0; private lastFlushEnd = 0; private writer: WritableStreamDefaultWriter | null = null; + private writeError: unknown = null; // These variables regard chunked mode: private chunked: boolean; @@ -267,6 +268,11 @@ export class StreamTargetWriter extends Writer { } async flush() { + if (this.writeError !== null) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw this.writeError; + } + if (this.pos > this.lastWriteEnd) { // There's a "void" between the last written byte and the next byte we're about to write. Let's pad that // void with zeroes explicitly. @@ -329,11 +335,12 @@ export class StreamTargetWriter extends Writer { throw new Error('Internal error: Monotonicity violation.'); } - // Write out the data immediately void this.writer.write({ type: 'write', data: chunk.data, position: chunk.start, + }).catch((error) => { + this.writeError ??= error; }); this.lastFlushEnd = chunk.start + chunk.data.byteLength; @@ -440,6 +447,8 @@ export class StreamTargetWriter extends Writer { type: 'write', data: chunk.data.subarray(section.start, section.end), position, + }).catch((error) => { + this.writeError ??= error; }); this.lastFlushEnd = chunk.start + section.end; @@ -449,12 +458,18 @@ export class StreamTargetWriter extends Writer { } } - finalize() { + async finalize() { if (this.chunked) { this.tryToFlushChunks(true); } + if (this.writeError !== null) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw this.writeError; + } + assert(this.writer); + await this.writer.ready; return this.writer.close(); } diff --git a/test/browser/adts-muxing.test.ts b/test/browser/adts-muxing.test.ts new file mode 100644 index 0000000..51f98b1 --- /dev/null +++ b/test/browser/adts-muxing.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from 'vitest'; +import { Input } from '../../src/input.js'; +import { BufferSource, UrlSource } from '../../src/source.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; +import { EncodedPacketSink } from '../../src/media-sink.js'; +import { EncodedAudioPacketSource } from '../../src/media-source.js'; +import { Output } from '../../src/output.js'; +import { StreamTarget, type StreamTargetChunk } from '../../src/target.js'; +import { AdtsOutputFormat } from '../../src/output-format.js'; +import { assert } from '../../src/misc.js'; + +const createBufferingStreamTarget = () => { + const written = new Map(); + + const stream = new WritableStream({ + async write(chunk: StreamTargetChunk) { + written.set(chunk.position, chunk.data.slice()); + }, + }); + + const toBuffer = () => { + let maxEnd = 0; + for (const [offset, data] of written) { + maxEnd = Math.max(maxEnd, offset + data.byteLength); + } + const buffer = new Uint8Array(maxEnd); + for (const [offset, data] of written) { + buffer.set(data, offset); + } + return buffer; + }; + + return { stream, toBuffer }; +}; + +test('ADTS with metadata over StreamTarget', async () => { + const target = createBufferingStreamTarget(); + + const output = new Output({ + format: new AdtsOutputFormat(), + target: new StreamTarget(target.stream), + }); + + output.setMetadataTags({ comment: 'Remotion' }); + + const audioSource = new EncodedAudioPacketSource('aac'); + output.addAudioTrack(audioSource); + + await output.start(); + + using input = new Input({ + source: new UrlSource('/sample3.aac'), + formats: ALL_FORMATS, + }); + + const audioTrack = await input.getPrimaryAudioTrack(); + assert(audioTrack); + + const sink = new EncodedPacketSink(audioTrack); + + let isFirst = true; + for await (const packet of sink.packets()) { + await audioSource.add(packet, { + decoderConfig: isFirst ? (await audioTrack.getDecoderConfig())! : undefined, + }); + isFirst = false; + } + + await output.finalize(); + + const buffer = target.toBuffer(); + using outputAsInput = new Input({ + source: new BufferSource(buffer.buffer), + formats: ALL_FORMATS, + }); + + const readTags = await outputAsInput.getMetadataTags(); + expect(readTags.comment).toBe('Remotion'); + + const outputAudioTrack = await outputAsInput.getPrimaryAudioTrack(); + assert(outputAudioTrack); + expect(outputAudioTrack.codec).toBe('aac'); +}); + +// Previously, write handler rejections were silently swallowed and surfaced as +// "Cannot write to a closing writable stream" instead of the actual error. +test('StreamTarget write errors surface directly', async () => { + let writeCount = 0; + const stream = new WritableStream({ + async write() { + writeCount++; + if (writeCount === 2) { + throw new Error('OPFS write failed'); + } + }, + }); + + const output = new Output({ + format: new AdtsOutputFormat(), + target: new StreamTarget(stream), + }); + + const audioSource = new EncodedAudioPacketSource('aac'); + output.addAudioTrack(audioSource); + + await output.start(); + + using input = new Input({ + source: new UrlSource('/sample3.aac'), + formats: ALL_FORMATS, + }); + + const audioTrack = await input.getPrimaryAudioTrack(); + assert(audioTrack); + + const sink = new EncodedPacketSink(audioTrack); + + const run = async () => { + let isFirst = true; + for await (const packet of sink.packets()) { + await audioSource.add(packet, { + decoderConfig: isFirst ? (await audioTrack.getDecoderConfig())! : undefined, + }); + isFirst = false; + } + await output.finalize(); + }; + + await expect(run()).rejects.toThrow('OPFS write failed'); +}); From 89afa1e2a6b916966354d0ee900c9788c0ab4e96 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:11:16 +0100 Subject: [PATCH 2/3] Make worker_threads dynamic imports direct and keep them in the bundle (externalized), & mark them as ignored for browser builds (fixes #306) --- packages/ac3/package.json | 3 +++ packages/mp3-encoder/package.json | 3 +++ scripts/esbuild/inlined-workers.ts | 8 +++++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ac3/package.json b/packages/ac3/package.json index 225fc05..a05b297 100644 --- a/packages/ac3/package.json +++ b/packages/ac3/package.json @@ -18,6 +18,9 @@ "dist", "src" ], + "browser": { + "worker_threads": false + }, "sideEffects": false, "license": "MPL-2.0", "repository": { diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index 63e6769..8b5a498 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -18,6 +18,9 @@ "dist", "src" ], + "browser": { + "worker_threads": false + }, "sideEffects": false, "license": "MPL-2.0", "repository": { diff --git a/scripts/esbuild/inlined-workers.ts b/scripts/esbuild/inlined-workers.ts index f93098d..79a5bd8 100644 --- a/scripts/esbuild/inlined-workers.ts +++ b/scripts/esbuild/inlined-workers.ts @@ -39,11 +39,10 @@ export default async function inlineWorker(scriptText) { // Node, Bun (Bun's Worker is flaky, worker_threads works much better) let Worker; - const workerModule = 'node:worker_threads'; try { - Worker = (await import(workerModule)).Worker; + Worker = (await import('worker_threads')).Worker; } catch { - Worker = require(workerModule).Worker; + Worker = require('worker_threads').Worker; } const worker = new Worker(scriptText, { eval: true }); @@ -56,6 +55,9 @@ export default async function inlineWorker(scriptText) { build.onResolve({ filter: /^__inline-worker$/ }, ({ path }) => { return { path, namespace: 'inline-worker' }; }); + build.onResolve({ filter: /^worker_threads$/ }, ({ path }) => { + return { path, external: true }; // Keep it in the bundle + }); build.onLoad({ filter: /.*/, namespace: 'inline-worker' }, () => { return { contents: inlineWorkerFunctionCode, loader: 'js' }; }); From a7dc2c198bebe0ab6df6aa988212f7c209e4bfae Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:13:34 +0100 Subject: [PATCH 3/3] Bump patch --- package-lock.json | 14 +++++++------- package.json | 2 +- packages/ac3/package.json | 2 +- packages/mp3-encoder/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index e146134..5e26632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.34.4", + "version": "1.34.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.34.4", + "version": "1.34.5", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7743,9 +7743,9 @@ } }, "node_modules/mediabunny": { - "version": "1.34.3", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.34.3.tgz", - "integrity": "sha512-Jb0x3jyvadGVpo8aZSn53NZduPbPdbx9foQsAGOmKBz7Hai9B38Kf+7eXbxXFcXoXxcTyNaZeDhoA0agoBQldA==", + "version": "1.34.4", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.34.4.tgz", + "integrity": "sha512-f1B95A60YoCsZQO/JQYxPDorybEz2Sjasf4RrpwGSMmJW6JVyhI/iJDri9LF6kk5WwUovF8oiTvRNM6xGjWo5w==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12069,7 +12069,7 @@ }, "packages/ac3": { "name": "@mediabunny/ac3", - "version": "1.34.4", + "version": "1.34.5", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12084,7 +12084,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.34.4", + "version": "1.34.5", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index 6700376..b12a608 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.34.4", + "version": "1.34.5", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/ac3/package.json b/packages/ac3/package.json index a05b297..90b1776 100644 --- a/packages/ac3/package.json +++ b/packages/ac3/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/ac3", "author": "Vanilagy", - "version": "1.34.4", + "version": "1.34.5", "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-ac3.mjs", "module": "./dist/bundles/mediabunny-ac3.mjs", diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index 8b5a498..0be184a 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.34.4", + "version": "1.34.5", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs",