Compare commits

...
4 Commits
Author SHA1 Message Date
Vanilagy a7dc2c198b Bump patch 2026-02-24 13:13:34 +01:00
Vanilagy 89afa1e2a6 Make worker_threads dynamic imports direct and keep them in the bundle (externalized), & mark them as ignored for browser builds (fixes #306) 2026-02-24 13:11:16 +01:00
31147d6e96 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 <[email protected]>
2026-02-24 11:21:13 +00:00
Vanilagy 48f9bda91a Add Tella gold sponsor 2026-02-24 11:07:05 +01:00
11 changed files with 195 additions and 16 deletions
+4
View File
@@ -38,6 +38,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<a href="https://screen.studio/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/screen-studio.webp" width="60" height="60" alt="Screen Studio">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.tella.com/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/tella.svg" width="60" height="60" alt="Tella">
</a>
</div>
### Silver sponsors
+1
View File
@@ -96,6 +96,7 @@ const sponsors = {
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
{ image: '/sponsors/screen-studio.webp', name: 'Screen Studio', url: 'https://screen.studio/' },
{ image: '/sponsors/tella.svg', name: 'Tella', url: 'https://www.tella.com/' },
],
silver: [
{ image: '/sponsors/ponder.png', name: 'Ponder', url: 'https://ponder.ai/' },
+18
View File
@@ -0,0 +1,18 @@
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1993_857)">
<path d="M0 6C0 2.5 2.5 0 6 0H40C43.5 0 46 2.5 46 6V40C46 43.5 43.5 46 40 46C36.5 46 6 46 6 46C2.5 46 0 43.5 0 40V6Z" fill="url(#paint0_radial_1993_857)"/>
<path d="M37.1921 24.4673C36.3173 24.4276 26.9327 24.4276 26.0579 24.4673C25.1831 24.5071 24.5071 25.1831 24.4673 26.0579C24.4276 26.9327 24.4276 36.3173 24.4673 37.1921C24.5071 38.0669 25.1831 38.7429 26.0579 38.7827C26.9327 38.8224 36.3173 38.8224 37.1921 38.7827C38.0669 38.7429 38.7429 38.0669 38.7827 37.1921C38.8224 36.3173 38.8224 26.9327 38.7827 26.0579C38.7429 25.1831 38.0669 24.5071 37.1921 24.4673Z" fill="#F9F9FF"/>
<path d="M7.1875 25.2058C7.18745 24.6153 7.91356 24.2462 8.49451 24.5414L21.1268 30.9599C21.7077 31.2551 21.7078 31.9931 21.1269 32.2884L8.4957 38.7085C7.91483 39.0039 7.18866 38.6348 7.18861 38.0445L7.1875 25.2058Z" fill="#F9F9FF"/>
<path d="M14.375 21.5625C18.3446 21.5625 21.5625 18.3446 21.5625 14.375C21.5625 10.4054 18.3446 7.1875 14.375 7.1875C10.4054 7.1875 7.1875 10.4054 7.1875 14.375C7.1875 18.3446 10.4054 21.5625 14.375 21.5625Z" fill="#F9F9FF"/>
<path d="M25.875 7.1875C25.875 7.1875 35.9375 7.1875 37.375 7.1875C38.8125 7.1875 38.8125 8.625 38.8125 8.625C38.8125 8.625 38.8125 8.625 38.8125 10.0625C38.8125 11.5 37.375 11.5 37.375 11.5L33.7813 11.5V20.125C33.7813 20.125 33.7813 21.5625 32.3438 21.5625C30.9062 21.5625 32.3438 21.5625 30.9062 21.5625C29.4688 21.5625 29.4688 20.125 29.4688 20.125V11.5L25.875 11.5C25.875 11.5 24.4375 11.5 24.4375 10.0625C24.4375 8.625 24.4375 10.0625 24.4375 8.625C24.4375 7.1875 25.875 7.1875 25.875 7.1875Z" fill="#F9F9FF"/>
</g>
<defs>
<radialGradient id="paint0_radial_1993_857" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(23) rotate(90) scale(46 46)">
<stop stop-color="#8278FA"/>
<stop offset="1" stop-color="#5E51F8"/>
</radialGradient>
<clipPath id="clip0_1993_857">
<rect width="46" height="46" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+7 -7
View File
@@ -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"
+1 -1
View File
@@ -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": [
+4 -1
View File
@@ -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",
@@ -18,6 +18,9 @@
"dist",
"src"
],
"browser": {
"worker_threads": false
},
"sideEffects": false,
"license": "MPL-2.0",
"repository": {
+4 -1
View File
@@ -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",
@@ -18,6 +18,9 @@
"dist",
"src"
],
"browser": {
"worker_threads": false
},
"sideEffects": false,
"license": "MPL-2.0",
"repository": {
+5 -3
View File
@@ -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' };
});
+4 -1
View File
@@ -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();
}
}
+17 -2
View File
@@ -215,6 +215,7 @@ export class StreamTargetWriter extends Writer {
private lastWriteEnd = 0;
private lastFlushEnd = 0;
private writer: WritableStreamDefaultWriter<StreamTargetChunk> | 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();
}
+130
View File
@@ -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<number, Uint8Array>();
const stream = new WritableStream<StreamTargetChunk>({
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<StreamTargetChunk>({
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');
});