Compare commits

...
11 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
Vanilagy 6a4e86698e Bump patch 2026-02-17 12:35:49 +01:00
Vanilagy 317193452c Fix Webpack erroring with worker_threads import, unref Worker so process can terminate 2026-02-17 12:34:45 +01:00
Vanilagy 4928779ba1 Add Screen Studio gold sponsor 2026-02-16 21:04:41 +01:00
Vanilagy 703858fc4e Bump patch, fix broken remote resource link 2026-02-16 20:59:33 +01:00
Vanilagy df5efa0442 Fix test 2026-02-16 15:34:42 +01:00
Vanilagy ddc826e6e3 MPEG-TS muxer: Write out DTS for video tracks (fixes buggy QuickTime behavior) 2026-02-16 15:30:23 +01:00
2876197631 Fix WaveDemuxer reading too far in corrupted ID3 chunk (#301)
* Update read-mp4.test.ts

* Fix the bug

* Delete read-wav.test.ts

* Submit test file

* Small adjustments

---------

Co-authored-by: Vanilagy <[email protected]>
2026-02-16 10:48:07 +00:00
27 changed files with 396 additions and 77 deletions
+8 -4
View File
@@ -34,15 +34,19 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<a href="https://kino.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/kino.jpg" width="60" height="60" alt="Kino">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<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
<div align="center">
<a href="https://pqina.nl/pintura/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/pintura-labs.png" width="50" height="50" alt="Pintura Labs">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://ponder.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/ponder.png" width="50" height="50" alt="Ponder">
</a>
+6 -4
View File
@@ -26,7 +26,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.Mp4OutputFormat({});
const outputFormat = new Mediabunny.MpegTsOutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -58,7 +58,8 @@
}),
output,
audio: (_, n) => ({
codec: 'eac3',
//codec: 'eac3',
//discard: true,
//discard: n > 1,
//codec: 'opus',
//codec: 'opus',
@@ -103,7 +104,8 @@
},
*/
video: () => ({
width: 320,
forceTranscode: true,
width: 1280,
//forceTranscode: true,
//allowRotationMetadata: false,
//width: 720,
@@ -186,7 +188,7 @@
},
trim: {
//start: 0,
end: 10
//end: 10
},
});
console.log(conversion);
+3 -1
View File
@@ -95,9 +95,10 @@ const sponsors = {
{ image: '/sponsors/gling.svg', name: 'Gling AI', url: 'https://www.gling.ai/' },
{ 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/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
{ image: '/sponsors/ponder.png', name: 'Ponder', url: 'https://ponder.ai/' },
],
bronze: [
@@ -107,6 +108,7 @@ const sponsors = {
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
],
individual: [
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
{ image: 'https://avatars.githubusercontent.com/u/82552321', name: 'Polotno', url: 'https://github.com/polotno-project' },
{ image: 'https://avatars.githubusercontent.com/u/489051', name: 'Roman Rädle', url: 'https://github.com/raedle' },
{ image: 'https://avatars.githubusercontent.com/u/197597', name: 'Christopher Chedeau', url: 'https://github.com/vjeux' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+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

@@ -143,7 +143,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://remotion.media/BigBuckBunny.mp4',
);
if (!url) {
return;
+1 -1
View File
@@ -680,7 +680,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://remotion.media/BigBuckBunny.mp4',
);
if (!url) {
return;
@@ -221,7 +221,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://remotion.media/BigBuckBunny.mp4',
);
if (!url) {
return;
@@ -137,7 +137,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://remotion.media/BigBuckBunny.mp4',
);
if (!url) {
return;
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.34.2",
"version": "1.34.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.34.2",
"version": "1.34.5",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7743,9 +7743,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.34.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.34.0.tgz",
"integrity": "sha512-mjn/7QVEPbPEakuNKD8OIPDhaoe2NlgWVyyqftuLVWvsgLol7g2MQzXWCjx523CP8Y9jE2H09hbYCuTwSI7pSQ==",
"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.2",
"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.2",
"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.2",
"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.2",
"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": {
+5 -2
View File
@@ -13,7 +13,7 @@ import {
EncodedPacket,
registerDecoder,
} from 'mediabunny';
import { sendCommand } from './worker-client';
import { sendCommand, refWorker, unrefWorker } from './worker-client';
class Ac3Decoder extends CustomAudioDecoder {
private ctx = 0;
@@ -23,6 +23,8 @@ class Ac3Decoder extends CustomAudioDecoder {
}
async init() {
await refWorker();
const result = await sendCommand({
type: 'init-decoder',
data: { codec: this.codec },
@@ -53,8 +55,9 @@ class Ac3Decoder extends CustomAudioDecoder {
await sendCommand({ type: 'flush-decoder', data: { ctx: this.ctx } });
}
close() {
async close() {
void sendCommand({ type: 'close-decoder', data: { ctx: this.ctx } });
await unrefWorker();
}
}
+7 -3
View File
@@ -13,7 +13,7 @@ import {
EncodedPacket,
registerEncoder,
} from 'mediabunny';
import { sendCommand } from './worker-client';
import { sendCommand, refWorker, unrefWorker } from './worker-client';
import { assert } from './shared';
import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../../../shared/ac3-misc';
@@ -38,11 +38,14 @@ class Ac3Encoder extends CustomAudioEncoder {
return (codec === 'ac3' || codec === 'eac3')
&& config.numberOfChannels >= 1
&& config.numberOfChannels <= 8
&& sampleRates.includes(config.sampleRate);
&& sampleRates.includes(config.sampleRate)
&& config.bitrate !== undefined;
}
async init() {
assert(this.config.bitrate);
await refWorker();
assert(this.config.bitrate !== undefined);
this.sampleRate = this.config.sampleRate;
this.numberOfChannels = this.config.numberOfChannels;
@@ -130,6 +133,7 @@ class Ac3Encoder extends CustomAudioEncoder {
close() {
void sendCommand({ type: 'close-encoder', data: { ctx: this.ctx } });
void unrefWorker();
}
private async encodeOneFrame() {
+41 -2
View File
@@ -10,13 +10,51 @@ import { assert, type WorkerCommand, type WorkerResponse, type WorkerResponseDat
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
import createWorker from './codec.worker';
let workerPromise: Promise<Worker> | null;
type ExtendedWorker = Worker & {
ref?: () => void;
unref?: () => void;
};
let workerPromise: Promise<ExtendedWorker> | null;
let nextMessageId = 0;
const pendingMessages = new Map<number, {
resolve: (value: WorkerResponseData) => void;
reject: (reason?: unknown) => void;
}>();
let refCount = 0;
let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
export const refWorker = async () => {
refCount++;
if (refCount === 1) {
keepAliveInterval = setInterval(() => {}, 2 ** 31 - 1);
const worker = await ensureWorker();
worker.ref?.();
}
};
export const unrefWorker = async () => {
refCount--;
if (refCount === 0) {
if (keepAliveInterval !== null) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
const worker = await workerPromise;
if (worker) {
if (worker.unref) {
worker.unref(); // If we don't do this, then the Node process never terminates by itself
} else if (typeof window === 'undefined') {
// Non-browser environment without unref - terminate instead
worker.terminate();
workerPromise = null;
}
}
}
};
export const sendCommand = async <T extends string>(
command: WorkerCommand & { type: T },
transferables?: Transferable[],
@@ -41,7 +79,8 @@ export const sendCommand = async <T extends string>(
const ensureWorker = () => {
return workerPromise ??= (async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const worker = (await createWorker()) as Worker;
const worker = (await createWorker()) as ExtendedWorker;
worker.unref?.(); // Start unreffed
const onMessage = (data: WorkerResponse) => {
const pending = pendingMessages.get(data.id);
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.34.2",
"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
@@ -28,7 +28,7 @@ export default function Worker() {
const inlineWorkerFunctionCode = `
export default async function inlineWorker(scriptText) {
if (typeof Worker !== 'undefined' && typeof Bun === 'undefined') {
// Browser, Deno
// Browser, Deno (Deno can't do dynamic import of worker_threads)
const blob = new Blob([scriptText], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
@@ -42,8 +42,7 @@ export default async function inlineWorker(scriptText) {
try {
Worker = (await import('worker_threads')).Worker;
} catch {
const workerModule = 'worker_threads';
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();
}
}
+3
View File
@@ -72,6 +72,9 @@ import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../r
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc';
// Resources:
// ISO/IEC 13818-1
type ElementaryStream = {
demuxer: MpegTsDemuxer;
pid: number;
+92 -34
View File
@@ -25,12 +25,15 @@ import {
} from '../codec-data';
import { assert, Bitstream, promiseWithResolvers, setUint24, toDataView, toUint8Array } from '../misc';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack, OutputVideoTrack } from '../output';
import { Output, OutputAudioTrack, OutputTrack, OutputVideoTrack } from '../output';
import { MpegTsOutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
// Resources:
// ISO/IEC 13818-1
const PAT_PID = 0x0000;
const PMT_PID = 0x1000;
const FIRST_TRACK_PID = 0x0100;
@@ -47,6 +50,7 @@ type MpegTsTrackData = {
streamType: MpegTsStreamType;
streamId: number;
codecString: string;
timestampProcessingQueue: QueuedPacket[];
packetQueue: QueuedPacket[];
inputIsAnnexB: boolean | null;
inputIsAdts: boolean | null;
@@ -59,7 +63,8 @@ type MpegTsTrackData = {
type QueuedPacket = {
data: Uint8Array;
timestamp: number;
presentationTimestamp: number;
decodeTimestamp: number | null;
isKeyframe: boolean;
};
@@ -77,9 +82,6 @@ export class MpegTsMuxer extends Muxer {
private videoTrackIndex = 0;
private audioTrackIndex = 0;
private pesHeaderBuffer = new Uint8Array(14);
private pesHeaderView = toDataView(this.pesHeaderBuffer);
private ptsBitstream = new Bitstream(this.pesHeaderBuffer.subarray(9, 14));
private adaptationFieldBuffer = new Uint8Array(184);
private payloadBuffer = new Uint8Array(184);
@@ -124,6 +126,7 @@ export class MpegTsMuxer extends Muxer {
streamType,
streamId,
codecString: meta.decoderConfig.codec,
timestampProcessingQueue: [],
packetQueue: [],
inputIsAnnexB: null,
inputIsAdts: null,
@@ -188,6 +191,7 @@ export class MpegTsMuxer extends Muxer {
streamType,
streamId,
codecString: meta.decoderConfig.codec,
timestampProcessingQueue: [],
packetQueue: [],
inputIsAnnexB: null,
inputIsAdts: null,
@@ -225,13 +229,16 @@ export class MpegTsMuxer extends Muxer {
const preparedData = this.prepareVideoPacket(trackData, packet, meta);
trackData.packetQueue.push({
if (packet.type === 'key') {
await this.flushTimestampQueue(trackData);
}
trackData.timestampProcessingQueue.push({
data: preparedData,
timestamp,
presentationTimestamp: timestamp,
decodeTimestamp: null,
isKeyframe: packet.type === 'key',
});
await this.interleavePackets();
} finally {
release();
}
@@ -255,13 +262,16 @@ export class MpegTsMuxer extends Muxer {
const preparedData = this.prepareAudioPacket(trackData, packet, meta);
trackData.packetQueue.push({
if (packet.type === 'key') {
await this.flushTimestampQueue(trackData);
}
trackData.timestampProcessingQueue.push({
data: preparedData,
timestamp,
presentationTimestamp: timestamp,
decodeTimestamp: null,
isKeyframe: packet.type === 'key',
});
await this.interleavePackets();
} finally {
release();
}
@@ -444,6 +454,28 @@ export class MpegTsMuxer extends Muxer {
return true;
}
private async flushTimestampQueue(trackData: MpegTsTrackData, alsoInterleave = true) {
if (trackData.timestampProcessingQueue.length === 0) {
return;
}
const sortedTimestamps = trackData.timestampProcessingQueue
.map(packet => packet.presentationTimestamp)
.sort((a, b) => a - b);
for (let i = 0; i < trackData.timestampProcessingQueue.length; i++) {
const queuedPacket = trackData.timestampProcessingQueue[i]!;
queuedPacket.decodeTimestamp = sortedTimestamps[i]!;
trackData.packetQueue.push(queuedPacket);
}
trackData.timestampProcessingQueue.length = 0;
if (alsoInterleave) {
await this.interleavePackets();
}
}
private async interleavePackets(isFinalCall = false) {
if (!this.tablesWritten) {
if (!this.allTracksAreKnown() && !isFinalCall) {
@@ -469,10 +501,10 @@ export class MpegTsMuxer extends Muxer {
if (
trackData.packetQueue.length > 0
&& trackData.packetQueue[0]!.timestamp < minTimestamp
&& trackData.packetQueue[0]!.presentationTimestamp < minTimestamp
) {
trackWithMinTimestamp = trackData;
minTimestamp = trackData.packetQueue[0]!.timestamp;
minTimestamp = trackData.packetQueue[0]!.presentationTimestamp;
}
}
@@ -526,10 +558,14 @@ export class MpegTsMuxer extends Muxer {
}
private writePesPacket(trackData: MpegTsTrackData, queuedPacket: QueuedPacket) {
const pesView = this.pesHeaderView;
const includeDts = trackData.track.type === 'video';
const headerDataLength = includeDts ? 10 : 5;
const pesHeaderBuffer = new Uint8Array(9 + headerDataLength);
const pesView = toDataView(pesHeaderBuffer);
const ptsDtsBitstream = new Bitstream(pesHeaderBuffer.subarray(9));
setUint24(pesView, 0, 0x000001, false); // packet_start_code_prefix
this.pesHeaderBuffer[3] = trackData.streamId; // stream_id
pesHeaderBuffer[3] = trackData.streamId; // stream_id
const pesPacketLength = trackData.track.type === 'video'
? 0 // Unbounded
@@ -539,20 +575,33 @@ export class MpegTsMuxer extends Muxer {
// '10' marker, PES_scrambling_control=0, PES_priority=0,
// data_alignment_indicator=1, copyright=0, original_or_copy=0
pesView.setUint8(6, 0x84);
pesView.setUint8(7, 0x80); // PTS_DTS_flags=10 (PTS only), other flags=0
pesView.setUint8(8, 5); // PES_header_data_length (5 bytes for PTS)
pesView.setUint8(7, includeDts ? 0xC0 : 0x80); // PTS_DTS_flags, other flags=0
pesView.setUint8(8, headerDataLength); // PES_header_data_length
const pts = Math.round(queuedPacket.timestamp * TIMESCALE);
this.ptsBitstream.pos = 0;
this.ptsBitstream.writeBits(4, 0b0010); // marker
this.ptsBitstream.writeBits(3, (pts >>> 30) & 0x7); // PTS[32:30]
this.ptsBitstream.writeBits(1, 1); // marker_bit
this.ptsBitstream.writeBits(15, (pts >>> 15) & 0x7FFF); // PTS[29:15]
this.ptsBitstream.writeBits(1, 1); // marker_bit
this.ptsBitstream.writeBits(15, pts & 0x7FFF); // PTS[14:0]
this.ptsBitstream.writeBits(1, 1); // marker_bit
const pts = Math.round(queuedPacket.presentationTimestamp * TIMESCALE);
ptsDtsBitstream.pos = 0;
ptsDtsBitstream.writeBits(4, includeDts ? 0b0011 : 0b0010); // marker
ptsDtsBitstream.writeBits(3, (pts >>> 30) & 0x7); // PTS[32:30]
ptsDtsBitstream.writeBits(1, 1); // marker_bit
ptsDtsBitstream.writeBits(15, (pts >>> 15) & 0x7FFF); // PTS[29:15]
ptsDtsBitstream.writeBits(1, 1); // marker_bit
ptsDtsBitstream.writeBits(15, pts & 0x7FFF); // PTS[14:0]
ptsDtsBitstream.writeBits(1, 1); // marker_bit
const totalLength = this.pesHeaderBuffer.length + queuedPacket.data.length;
if (includeDts) {
assert(queuedPacket.decodeTimestamp !== null);
const dts = Math.round(queuedPacket.decodeTimestamp * TIMESCALE);
ptsDtsBitstream.writeBits(4, 0b0001);
ptsDtsBitstream.writeBits(3, (dts >>> 30) & 0x7); // DTS[32:30]
ptsDtsBitstream.writeBits(1, 1); // marker_bit
ptsDtsBitstream.writeBits(15, (dts >>> 15) & 0x7FFF); // DTS[29:15]
ptsDtsBitstream.writeBits(1, 1); // marker_bit
ptsDtsBitstream.writeBits(15, dts & 0x7FFF); // DTS[14:0]
ptsDtsBitstream.writeBits(1, 1); // marker_bit
}
const totalLength = pesHeaderBuffer.length + queuedPacket.data.length;
let offset = 0;
let isFirstTsPacket = true;
@@ -593,13 +642,13 @@ export class MpegTsMuxer extends Muxer {
const payload = this.payloadBuffer.subarray(0, payloadSize);
let payloadOffset = 0;
if (offset < this.pesHeaderBuffer.length) {
const headerBytes = Math.min(this.pesHeaderBuffer.length - offset, payloadSize);
payload.set(this.pesHeaderBuffer.subarray(offset, offset + headerBytes), 0);
if (offset < pesHeaderBuffer.length) {
const headerBytes = Math.min(pesHeaderBuffer.length - offset, payloadSize);
payload.set(pesHeaderBuffer.subarray(offset, offset + headerBytes), 0);
payloadOffset = headerBytes;
}
const dataStart = Math.max(0, offset - this.pesHeaderBuffer.length);
const dataStart = Math.max(0, offset - pesHeaderBuffer.length);
const dataEnd = dataStart + (payloadSize - payloadOffset);
if (payloadOffset < payloadSize) {
payload.set(queuedPacket.data.subarray(dataStart, dataEnd), payloadOffset);
@@ -658,13 +707,18 @@ export class MpegTsMuxer extends Muxer {
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async onTrackClose() {
override async onTrackClose(track: OutputTrack) {
const release = await this.mutex.acquire();
if (this.allTracksAreKnown()) {
this.allTracksKnown.resolve();
}
const trackData = this.trackDatas.find(x => x.track === track);
if (trackData) {
await this.flushTimestampQueue(trackData, false);
}
await this.interleavePackets();
release();
@@ -675,6 +729,10 @@ export class MpegTsMuxer extends Muxer {
this.allTracksKnown.resolve();
for (const trackData of this.trackDatas) {
await this.flushTimestampQueue(trackData, false);
}
await this.interleavePackets(true);
release();
+9 -4
View File
@@ -15,7 +15,7 @@ import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
import { assert, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader';
import { parseId3V2Tag, readId3V2Header } from '../id3';
import { ID3_V2_HEADER_SIZE, parseId3V2Tag, readId3V2Header } from '../id3';
export enum WaveFormat {
PCM = 0x0001,
@@ -286,10 +286,15 @@ export class WaveDemuxer extends Demuxer {
const id3V2Header = readId3V2Header(slice);
if (id3V2Header) {
// Extract the content portion (skip the 10-byte header)
const contentSlice = slice.slice(startPos + 10, id3V2Header.size);
// Clamp to the available data in case the ID3 header claims more than the WAV chunk provides
// https://github.com/Vanilagy/mediabunny/issues/300
const availableSize = size - ID3_V2_HEADER_SIZE;
id3V2Header.size = Math.min(id3V2Header.size, availableSize);
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
if (id3V2Header.size > 0) {
const contentSlice = slice.slice(startPos + ID3_V2_HEADER_SIZE, id3V2Header.size);
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
}
}
}
+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');
});
+4 -2
View File
@@ -703,12 +703,14 @@ test('MPEG-TS muxing with StreamTarget', async () => {
const frameDuration = 1 / fps;
for (let i = 0; i < frameCount; i++) {
await videoSource.add(i * frameDuration, frameDuration);
await videoSource.add(i * frameDuration, frameDuration, {
keyFrame: true, // Otherwise all packets get written at once due to the DTS logic
});
}
await output.finalize();
expect(chunks.length).toBe(frameCount);
expect(chunks).toHaveLength(frameCount);
const buffer = new Uint8Array(nextPos);
nextPos = 0;
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'vitest';
import path from 'node:path';
import { ALL_FORMATS, EncodedPacketSink, Input, FilePathSource } from '../../src/index.js';
const __dirname = new URL('.', import.meta.url).pathname;
test('Should handle WAV file with oversized ID3 chunk', async () => {
const filePath = path.join(__dirname, '..', 'public/oversized-id3.wav');
using input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryAudioTrack();
if (!track) {
throw new Error('No audio track found');
}
const sink = new EncodedPacketSink(track);
for await (const packet of sink.packets()) {
void packet;
}
});
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
- Investigate quicktime stuttering with mediabunny-generated .ts file