From 1183b542eee433808b1605c18f53061e13c03e87 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:30:50 +0200 Subject: [PATCH 01/10] Make WAVE demuxer throw for format tags that it doesn't support (fixes #440), add support for reading & writing float64 PCM in WAVE --- src/output-format.ts | 2 +- src/wave/wave-demuxer.ts | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/output-format.ts b/src/output-format.ts index f759896..4e64946 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -799,7 +799,7 @@ export class WavOutputFormat extends OutputFormat { getSupportedCodecs(): MediaCodec[] { return [ ...PCM_AUDIO_CODECS.filter(codec => - ['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-u8', 'ulaw', 'alaw'].includes(codec), + ['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-f64', 'pcm-u8', 'ulaw', 'alaw'].includes(codec), ), ]; } diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 1324c6f..ddf750a 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -173,6 +173,28 @@ export class WaveDemuxer extends Demuxer { bitsPerSample = 8; } + if ( + formatTag !== WaveFormat.PCM + && formatTag !== WaveFormat.IEEE_FLOAT + && formatTag !== WaveFormat.ALAW + && formatTag !== WaveFormat.MULAW + ) { + throw new Error( + `Unsupported WAVE codec (format tag ${formatTag}). Only integer/float PCM, A-law, and μ-law are` + + ` supported.`, + ); + } + if (formatTag === WaveFormat.PCM && ![8, 16, 24, 32].includes(bitsPerSample)) { + throw new Error( + `Unsupported WAVE PCM bit depth (${bitsPerSample}). Only 8, 16, 24, and 32 bits are supported.`, + ); + } + if (formatTag === WaveFormat.IEEE_FLOAT && ![32, 64].includes(bitsPerSample)) { + throw new Error( + `Unsupported WAVE float bit depth (${bitsPerSample}). Only 32 and 64 bits are supported.`, + ); + } + this.audioInfo = { format: formatTag, numberOfChannels: numChannels, @@ -322,10 +344,12 @@ export class WaveDemuxer extends Demuxer { if (this.audioInfo.format === WaveFormat.IEEE_FLOAT) { if (this.audioInfo.sampleSizeInBytes === 4) { return 'pcm-f32'; + } else if (this.audioInfo.sampleSizeInBytes === 8) { + return 'pcm-f64'; } } - return null; + assert(false); } async getMimeType() { From bfbd90e1d80341d54e7ec2fd5ccadb11ffb228a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Medeiros?= Date: Sat, 18 Jul 2026 09:05:31 -0300 Subject: [PATCH 02/10] Account for multi-frame Opus packets when computing packet duration (#439) * Account for multi-frame Opus packets when computing packet duration parseOpusTocByte only read the config field of the TOC byte and always assumed a single frame per packet. Per RFC 6716 section 3.2, a packet may carry 1, 2 or an arbitrary number of frames, encoded in the two low bits of the TOC byte (plus the frame count byte for code 3), and its duration is the frame duration times the frame count. As a result, the Ogg muxer wrote granule positions that advanced slower than the actual audio. Chromium's MediaRecorder packs three 20 ms frames per Opus packet, so remuxing WebM/Opus to Ogg/Opus produced files declaring a third of their real duration: a 5.7 s recording ended with a final granule position of 99840 (2.08 s). Decoders that trust the container then truncate the audio. * Clean up --------- Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- src/codec-data.ts | 15 +++++- test/browser/ogg-muxer.test.ts | 89 +++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/codec-data.ts b/src/codec-data.ts index 83a040d..8319af3 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -2207,9 +2207,22 @@ const OPUS_FRAME_DURATION_TABLE = [ export const parseOpusTocByte = (packet: Uint8Array) => { const config = packet[0]! >> 3; + const code = packet[0]! & 0b11; + + // A packet may pack more than one frame, in which case its duration is the frame duration times the number of + // frames it carries. See https://datatracker.ietf.org/doc/html/rfc6716, section 3.2. + let frameCount: number; + if (code === 0) { + frameCount = 1; + } else if (code === 1 || code === 2) { + frameCount = 2; + } else { + // Code 3: the frame count sits in the six low bits of the frame count byte + frameCount = packet[1]! & 0b111111; + } return { - durationInSamples: OPUS_FRAME_DURATION_TABLE[config]!, + durationInSamples: OPUS_FRAME_DURATION_TABLE[config]! * frameCount, }; }; diff --git a/test/browser/ogg-muxer.test.ts b/test/browser/ogg-muxer.test.ts index ea982e1..215dc7d 100644 --- a/test/browser/ogg-muxer.test.ts +++ b/test/browser/ogg-muxer.test.ts @@ -1,8 +1,14 @@ import { expect, test } from 'vitest'; import { Output } from '../../src/output.js'; import { OggOutputFormat } from '../../src/output-format.js'; -import { NullTarget } from '../../src/target.js'; -import { AudioBufferSource } from '../../src/media-source.js'; +import { BufferTarget, NullTarget } from '../../src/target.js'; +import { AudioBufferSource, EncodedAudioPacketSource } from '../../src/media-source.js'; +import { EncodedPacket } from '../../src/packet.js'; +import { assert } from '../../src/misc.js'; +import { Input } from '../../src/input.js'; +import { BufferSource } from '../../src/source.js'; +import { ALL_FORMATS, OggInputFormat } from '../../src/input-format.js'; +import { EncodedPacketSink } from '../../src/media-sink.js'; test('maximumPageDuration option', async () => { const sampleRate = 48000; @@ -55,3 +61,82 @@ test('maximumPageDuration option', async () => { expect(pageCountWithoutOption).toBe(3); expect(pageCountWithOption).toBe(23); // It created more pages }); + +test('Multi-frame Opus packets', async () => { + const SAMPLE_RATE = 48000; + const SAMPLES_PER_FRAME = 960; // 20 ms at 48 kHz + + const createOpusHead = () => { + const bytes = new Uint8Array(19); + const view = new DataView(bytes.buffer); + + bytes.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // 'OpusHead' + bytes[8] = 1; // Version + bytes[9] = 1; // Channel count + view.setUint16(10, 312, true); // Pre-skip + view.setUint32(12, SAMPLE_RATE, true); // Input sample rate + view.setInt16(16, 0, true); // Output gain + bytes[18] = 0; // Channel mapping family + + return bytes; + }; + + const createOpusPacket = (frameCount: number) => { + const data = new Uint8Array(2 + 3 * frameCount); + + data[0] = (31 << 3) | 0b11; // TOC byte: config 31 (CELT fullband, 20 ms), code 3 + data[1] = frameCount; // CBR, no padding, `frameCount` frames + + return data; + }; + + const framesPerPacket = 3; + const packetCount = 10; + const packetDuration = (framesPerPacket * SAMPLES_PER_FRAME) / SAMPLE_RATE; + + const output = new Output({ + format: new OggOutputFormat(), + target: new BufferTarget(), + }); + + const audioSource = new EncodedAudioPacketSource('opus'); + output.addAudioTrack(audioSource); + + await output.start(); + + for (let i = 0; i < packetCount; i++) { + await audioSource.add( + new EncodedPacket( + createOpusPacket(framesPerPacket), + 'key', + i * packetDuration, + packetDuration, + ), + { + decoderConfig: { + codec: 'opus', + numberOfChannels: 1, + sampleRate: SAMPLE_RATE, + description: createOpusHead(), + }, + }, + ); + } + + audioSource.close(); + await output.finalize(); + + assert(output.target.buffer); + + const input = new Input({ + source: new BufferSource(output.target.buffer), + formats: ALL_FORMATS, + }); + + expect(await input.getFormat()).toBeInstanceOf(OggInputFormat); + + const sink = new EncodedPacketSink((await input.getPrimaryAudioTrack())!); + const packet = await sink.getFirstPacket(); + + expect(packet?.duration).toBe(packetDuration); +}); From 0554696505f5389d3d1935e6174ffefb9b8e03cc Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:44:57 +0200 Subject: [PATCH 03/10] Fix nested worker randomly shutting off in Firefox (closes #435) --- packages/aac-encoder/src/encode.worker.ts | 4 ++++ packages/ac3/src/codec.worker.ts | 4 ++++ packages/ac3/src/worker-client.ts | 1 + packages/flac-encoder/src/encode.worker.ts | 4 ++++ packages/mp3-encoder/src/encode.worker.ts | 4 ++++ 5 files changed, 17 insertions(+) diff --git a/packages/aac-encoder/src/encode.worker.ts b/packages/aac-encoder/src/encode.worker.ts index 76fea2f..5627280 100644 --- a/packages/aac-encoder/src/encode.worker.ts +++ b/packages/aac-encoder/src/encode.worker.ts @@ -192,3 +192,7 @@ if (parentPort) { } else { self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand })); } + +// Prevents the worker for being randomly closed by Firefox +// https://github.com/Vanilagy/mediabunny/issues/435 +setInterval(() => {}, 1000); diff --git a/packages/ac3/src/codec.worker.ts b/packages/ac3/src/codec.worker.ts index 8bfc2fa..e474da1 100644 --- a/packages/ac3/src/codec.worker.ts +++ b/packages/ac3/src/codec.worker.ts @@ -304,3 +304,7 @@ if (parentPort) { } else { self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand })); } + +// Prevents the worker for being randomly closed by Firefox +// https://github.com/Vanilagy/mediabunny/issues/435 +setInterval(() => {}, 1000); diff --git a/packages/ac3/src/worker-client.ts b/packages/ac3/src/worker-client.ts index 74a8bab..15c6b8f 100644 --- a/packages/ac3/src/worker-client.ts +++ b/packages/ac3/src/worker-client.ts @@ -46,6 +46,7 @@ export const unrefWorker = async () => { if (worker) { if (worker.unref) { worker.unref(); // If we don't do this, then the Node process never terminates by itself + // Keep the worker around tho } else if (typeof window === 'undefined') { // Non-browser environment without unref - terminate instead worker.terminate(); diff --git a/packages/flac-encoder/src/encode.worker.ts b/packages/flac-encoder/src/encode.worker.ts index fe881d3..cfd6484 100644 --- a/packages/flac-encoder/src/encode.worker.ts +++ b/packages/flac-encoder/src/encode.worker.ts @@ -192,3 +192,7 @@ if (parentPort) { } else { self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand })); } + +// Prevents the worker for being randomly closed by Firefox +// https://github.com/Vanilagy/mediabunny/issues/435 +setInterval(() => {}, 1000); diff --git a/packages/mp3-encoder/src/encode.worker.ts b/packages/mp3-encoder/src/encode.worker.ts index ee24db8..715816d 100644 --- a/packages/mp3-encoder/src/encode.worker.ts +++ b/packages/mp3-encoder/src/encode.worker.ts @@ -219,3 +219,7 @@ if (parentPort) { } else { self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand })); } + +// Prevents the worker for being randomly closed by Firefox +// https://github.com/Vanilagy/mediabunny/issues/435 +setInterval(() => {}, 1000); From 94d42667f143e42cf136547eb55fc8db68b2a870 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Sat, 18 Jul 2026 15:26:31 +0200 Subject: [PATCH 04/10] Add extension decoders to media player example (#433) * Add extension decoders to media player example * Enable extension decoders in input examples * Simplify media player file filter * Align input file filters across examples --- examples/file-compression/file-compression.ts | 8 +++++++- examples/hls-transcoding/hls-transcoding.ts | 8 +++++++- examples/media-player/media-player.ts | 8 +++++++- examples/thumbnail-generation/thumbnail-generation.ts | 8 +++++++- tsconfig.vite.json | 8 +++++--- vite.config.ts | 2 ++ 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/examples/file-compression/file-compression.ts b/examples/file-compression/file-compression.ts index d4613fa..81ed19b 100644 --- a/examples/file-compression/file-compression.ts +++ b/examples/file-compression/file-compression.ts @@ -9,8 +9,14 @@ import { Conversion, QUALITY_VERY_LOW, } from 'mediabunny'; +import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; + +// Enable codecs that aren't natively supported by WebCodecs. +registerAc3Decoder(); +registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement; @@ -137,7 +143,7 @@ const compressFile = async (resource: File | string) => { selectMediaButton.addEventListener('click', () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; - fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac'; + fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac'; fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) { diff --git a/examples/hls-transcoding/hls-transcoding.ts b/examples/hls-transcoding/hls-transcoding.ts index 7444bff..4b5b0eb 100644 --- a/examples/hls-transcoding/hls-transcoding.ts +++ b/examples/hls-transcoding/hls-transcoding.ts @@ -15,8 +15,14 @@ import { QUALITY_LOW, QUALITY_VERY_LOW, } from 'mediabunny'; +import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; + +// Enable codecs that aren't natively supported by WebCodecs. +registerAc3Decoder(); +registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; declare global { @@ -233,7 +239,7 @@ selectDirectoryButton.addEventListener('click', async () => { selectMediaButton.addEventListener('click', () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; - fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts'; + fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts'; fileInput.addEventListener('change', () => { const file = fileInput.files![0]; if (file) { diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index 30f6040..d0943b6 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -8,8 +8,14 @@ import { WrappedAudioBuffer, WrappedCanvas, } from 'mediabunny'; +import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; + +// Enable codecs that aren't natively supported by WebCodecs. +registerAc3Decoder(); +registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement; @@ -732,7 +738,7 @@ window.addEventListener('resize', () => { selectMediaButton.addEventListener('click', () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; - fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac'; + fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac'; fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) { diff --git a/examples/thumbnail-generation/thumbnail-generation.ts b/examples/thumbnail-generation/thumbnail-generation.ts index 5681568..5ab3fc5 100644 --- a/examples/thumbnail-generation/thumbnail-generation.ts +++ b/examples/thumbnail-generation/thumbnail-generation.ts @@ -1,6 +1,12 @@ import { Input, ALL_FORMATS, BlobSource, UrlSource, CanvasSink } from 'mediabunny'; +import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; + +// Enable codecs that aren't natively supported by WebCodecs. +registerAc3Decoder(); +registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement; @@ -122,7 +128,7 @@ const generateThumbnails = async (resource: File | string) => { selectMediaButton.addEventListener('click', () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; - fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac'; + fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac'; fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) { diff --git a/tsconfig.vite.json b/tsconfig.vite.json index b8dad8d..eb5ae43 100644 --- a/tsconfig.vite.json +++ b/tsconfig.vite.json @@ -19,6 +19,8 @@ "./examples/**/*.ts" ], "references": [ - { "path": "./src" } - ] -} \ No newline at end of file + { "path": "./src" }, + { "path": "./packages/ac3" }, + { "path": "./packages/prores" } + ] +} diff --git a/vite.config.ts b/vite.config.ts index df28f91..576ccc9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,6 +21,8 @@ export default defineConfig({ resolve: { alias: { 'mediabunny': path.resolve(__dirname, './dist/bundles/mediabunny.mjs'), + '@mediabunny/ac3': + path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'), '@mediabunny/aac-encoder': path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'), '@mediabunny/flac-encoder': From d2aea552d9990128c0c158e190efce15b707ee09 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:27:06 +0200 Subject: [PATCH 05/10] Add spacing --- examples/file-compression/file-compression.ts | 1 + examples/hls-transcoding/hls-transcoding.ts | 1 + examples/media-player/media-player.ts | 1 + examples/thumbnail-generation/thumbnail-generation.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/examples/file-compression/file-compression.ts b/examples/file-compression/file-compression.ts index 81ed19b..1e4bf44 100644 --- a/examples/file-compression/file-compression.ts +++ b/examples/file-compression/file-compression.ts @@ -17,6 +17,7 @@ import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); registerProresDecoder(); + (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement; diff --git a/examples/hls-transcoding/hls-transcoding.ts b/examples/hls-transcoding/hls-transcoding.ts index 4b5b0eb..c2f9d67 100644 --- a/examples/hls-transcoding/hls-transcoding.ts +++ b/examples/hls-transcoding/hls-transcoding.ts @@ -23,6 +23,7 @@ import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); registerProresDecoder(); + (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; declare global { diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index d0943b6..6d851e2 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -16,6 +16,7 @@ import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); registerProresDecoder(); + (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement; diff --git a/examples/thumbnail-generation/thumbnail-generation.ts b/examples/thumbnail-generation/thumbnail-generation.ts index 5ab3fc5..69a995a 100644 --- a/examples/thumbnail-generation/thumbnail-generation.ts +++ b/examples/thumbnail-generation/thumbnail-generation.ts @@ -7,6 +7,7 @@ import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); registerProresDecoder(); + (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement; From 454476ab26fdab30529ceca0622664251f57275c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 18 Jul 2026 22:45:23 +0900 Subject: [PATCH 06/10] Fix WebVTT-in-MP4 output by starting the aux writer (#441) * Fix WebVTT-in-MP4 output by starting the aux writer The ISOBMFF muxer advertises WebVTT as a supported subtitle codec and maps it to the wvtt sample entry, but IsobmffMuxer.start() never calls auxWriter.start(). The aux writer builds subtitle sample boxes in memory, so the first box write for any subtitle track hits its started === false assert and muxing fails. Start the aux writer alongside the main writer so WebVTT subtitle tracks can be written to MP4/MOV. * Move code around, add simple WebVTT muxing test --------- Co-authored-by: hikari Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- src/isobmff/isobmff-muxer.ts | 2 ++ test/node/webvtt.test.ts | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 test/node/webvtt.test.ts diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index c9baf86..bf3a89e 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -206,6 +206,8 @@ export class IsobmffMuxer extends Muxer { this.isCmaf = format instanceof CmafOutputFormat; this.minimumFragmentDuration = format._options.minimumFragmentDuration ?? (format instanceof CmafOutputFormat ? Infinity : 1); + + this.auxWriter.start(); } async start() { diff --git a/test/node/webvtt.test.ts b/test/node/webvtt.test.ts new file mode 100644 index 0000000..5f3f72c --- /dev/null +++ b/test/node/webvtt.test.ts @@ -0,0 +1,57 @@ +import { test } from 'vitest'; +import { Output } from '../../src/output.js'; +import { MkvOutputFormat, Mp4OutputFormat } from '../../src/output-format.js'; +import { BufferTarget } from '../../src/target.js'; +import { TextSubtitleSource } from '../../src/media-source.js'; + +test('ISOBMFF muxing', async () => { + const output = new Output({ + format: new Mp4OutputFormat(), + target: new BufferTarget(), + }); + + const source = new TextSubtitleSource('webvtt'); + output.addSubtitleTrack(source); + + await output.start(); + + await source.add(`WEBVTT + +00:00.000 --> 00:00.900 +Hildy! + +00:01.000 --> 00:01.400 +How are you? + +00:01.500 --> 00:02.900 +Tell me, is the lord of the universe in? +`); + + await output.finalize(); +}); + +test('Matroska muxing', async () => { + const output = new Output({ + format: new MkvOutputFormat(), + target: new BufferTarget(), + }); + + const source = new TextSubtitleSource('webvtt'); + output.addSubtitleTrack(source); + + await output.start(); + + await source.add(`WEBVTT + +00:00.000 --> 00:00.900 +Hildy! + +00:01.000 --> 00:01.400 +How are you? + +00:01.500 --> 00:02.900 +Tell me, is the lord of the universe in? +`); + + await output.finalize(); +}); From bf592a87caeadcfa56188d5555b2177308b046b5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 18 Jul 2026 22:48:55 +0900 Subject: [PATCH 07/10] Give Matroska subtitle cues a BlockDuration (#442) Subtitle cues were written as SimpleBlocks, which carry no duration. A SimpleBlock tells the player when a cue starts but not how long to show it, so players such as VLC and libass-based renderers display nothing for S_TEXT/WEBVTT tracks muxed into Matroska/WebM. Route a subtitle chunk that has a positive duration through a BlockGroup so its BlockDuration is written, matching how additions are already handled. Non-subtitle tracks and zero-duration cues keep using SimpleBlocks. Co-authored-by: hikari --- src/matroska/matroska-muxer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 79e2e93..4ce0129 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -1168,7 +1168,12 @@ export class MatroskaMuxer extends Muxer { const msDuration = Math.round(1000 * chunk.duration); - if (!chunk.additions) { + // Subtitle cues need an explicit BlockDuration (a SimpleBlock has none), otherwise players + // like VLC/libass don't know how long to show the cue and render nothing. So a subtitle with + // a duration must go into a BlockGroup even when it has no additions. + const needsBlockGroup = !!chunk.additions || (trackData.type === 'subtitle' && msDuration > 0); + + if (!needsBlockGroup) { // No additions, we can write out a SimpleBlock view.setUint8(3, Number(chunk.type === 'key') << 7); // Flags (keyframe flag only present for SimpleBlock) From 3ec4061e0350c4d7effb5606968cea6db29e265f Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:49:57 +0200 Subject: [PATCH 08/10] Clean up --- src/matroska/matroska-muxer.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 4ce0129..c15ec98 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -1168,10 +1168,8 @@ export class MatroskaMuxer extends Muxer { const msDuration = Math.round(1000 * chunk.duration); - // Subtitle cues need an explicit BlockDuration (a SimpleBlock has none), otherwise players - // like VLC/libass don't know how long to show the cue and render nothing. So a subtitle with - // a duration must go into a BlockGroup even when it has no additions. - const needsBlockGroup = !!chunk.additions || (trackData.type === 'subtitle' && msDuration > 0); + // Subtitle cues need an explicit BlockDuration (a SimpleBlock has none) + const needsBlockGroup = !!chunk.additions || trackData.type === 'subtitle'; if (!needsBlockGroup) { // No additions, we can write out a SimpleBlock From 45c34f9dcd2c0342df871dbed67e1774c60e78f2 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:15:53 +0200 Subject: [PATCH 09/10] Bump patch --- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/aac-encoder/package.json | 2 +- packages/ac3/package.json | 2 +- packages/flac-encoder/package.json | 2 +- packages/mp3-encoder/package.json | 2 +- packages/prores/package.json | 2 +- packages/server/package.json | 4 ++-- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f88d24..4e6ab80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.50.8", + "version": "1.50.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.50.8", + "version": "1.50.9", "license": "MPL-2.0", "workspaces": [ ".", @@ -12894,7 +12894,7 @@ }, "packages/aac-encoder": { "name": "@mediabunny/aac-encoder", - "version": "1.50.8", + "version": "1.50.9", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12909,7 +12909,7 @@ }, "packages/ac3": { "name": "@mediabunny/ac3", - "version": "1.50.8", + "version": "1.50.9", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12924,7 +12924,7 @@ }, "packages/flac-encoder": { "name": "@mediabunny/flac-encoder", - "version": "1.50.8", + "version": "1.50.9", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12939,7 +12939,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.50.8", + "version": "1.50.9", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12954,7 +12954,7 @@ }, "packages/prores": { "name": "@mediabunny/prores", - "version": "1.50.8", + "version": "1.50.9", "license": "MPL-2.0", "dependencies": { "turbores": "^1.2.2" @@ -12969,10 +12969,10 @@ }, "packages/server": { "name": "@mediabunny/server", - "version": "1.50.8", + "version": "1.50.9", "license": "MPL-2.0", "dependencies": { - "@mediabunny/prores": "^1.50.8", + "@mediabunny/prores": "^1.50.9", "node-av": "^6.0.0" }, "funding": { diff --git a/package.json b/package.json index 37c5bfa..9c2cb05 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.50.8", + "version": "1.50.9", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/aac-encoder/package.json b/packages/aac-encoder/package.json index 8d2641d..4e11979 100644 --- a/packages/aac-encoder/package.json +++ b/packages/aac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/aac-encoder", "author": "Vanilagy", - "version": "1.50.8", + "version": "1.50.9", "description": "AAC encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-aac-encoder.mjs", "module": "./dist/bundles/mediabunny-aac-encoder.mjs", diff --git a/packages/ac3/package.json b/packages/ac3/package.json index bf60d87..66bd294 100644 --- a/packages/ac3/package.json +++ b/packages/ac3/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/ac3", "author": "Vanilagy", - "version": "1.50.8", + "version": "1.50.9", "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/flac-encoder/package.json b/packages/flac-encoder/package.json index c97ae6e..43e3680 100644 --- a/packages/flac-encoder/package.json +++ b/packages/flac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/flac-encoder", "author": "Vanilagy", - "version": "1.50.8", + "version": "1.50.9", "description": "FLAC encoder extension for Mediabunny, based on libFLAC.", "main": "./dist/bundles/mediabunny-flac-encoder.mjs", "module": "./dist/bundles/mediabunny-flac-encoder.mjs", diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index cf59128..0bc5480 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.50.8", + "version": "1.50.9", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", diff --git a/packages/prores/package.json b/packages/prores/package.json index d28c9de..7c0477a 100644 --- a/packages/prores/package.json +++ b/packages/prores/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/prores", "author": "Vanilagy", - "version": "1.50.8", + "version": "1.50.9", "description": "Apple ProRes decoder extension for Mediabunny, based on TurboRes.", "main": "./dist/bundles/mediabunny-prores.mjs", "module": "./dist/bundles/mediabunny-prores.mjs", diff --git a/packages/server/package.json b/packages/server/package.json index 944adc1..0f33ccc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/server", "author": "Vanilagy", - "version": "1.50.8", + "version": "1.50.9", "description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.", "main": "./dist/bundles/mediabunny-server.cjs", "module": "./dist/bundles/mediabunny-server.mjs", @@ -35,7 +35,7 @@ }, "dependencies": { "node-av": "^6.0.0", - "@mediabunny/prores": "^1.50.8" + "@mediabunny/prores": "^1.50.9" }, "peerDependencies": { "mediabunny": "^1.45.0" From 794b84884f1e23cb6241689b3563190d138bbd9a Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:54:24 +0200 Subject: [PATCH 10/10] Make sure that samples are always closed in the Conversion API, even on error cases --- package-lock.json | 60 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 8 +++---- src/conversion.ts | 31 +++++++++++++----------- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e6ab80..bf05182 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2715,6 +2715,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.9", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.0", + "@emnapi/runtime": "^1.4.0", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz", diff --git a/package.json b/package.json index 9c2cb05..f96aa99 100644 --- a/package.json +++ b/package.json @@ -56,10 +56,10 @@ "examples:build": "vite build", "fix-build-import-paths": "tsx scripts/add-import-extensions.ts", "append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts", - "bump-patch": "npm version patch --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i", - "bump-minor": "npm version minor --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i", - "bump-major": "npm version major --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i", - "set-version": "npm version --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i" + "bump-patch": "npm version patch --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only", + "bump-minor": "npm version minor --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only", + "bump-major": "npm version major --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only", + "set-version": "npm version --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only" }, "license": "MPL-2.0", "repository": { diff --git a/src/conversion.ts b/src/conversion.ts index bd15ba7..252e8cb 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -1321,7 +1321,7 @@ export class Conversion { await tempOutput.start(); const sink = new VideoSampleSink(track); - const firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample + using firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample if (firstSample) { try { @@ -1375,9 +1375,8 @@ export class Conversion { const sink = new VideoSampleSink(track); - for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) { + for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) { if (this._canceled) { - sample.close(); return; } @@ -1386,14 +1385,13 @@ export class Conversion { this._reportProgress(outputTrackId, sample.timestamp + sample.duration); await source.add(sample); + sample.close(); if (lastSampleTimestamp !== null) { if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) { await this._synchronizer.wait(lastSampleTimestamp); } } - - sample.close(); } source.close(); @@ -1595,9 +1593,8 @@ export class Conversion { await this._started; const sink = new AudioSampleSink(track); - for await (let sample of sink.samples(this._startTimestamp, this._endTimestamp)) { + for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) { if (this._canceled) { - sample.close(); return; } @@ -1612,7 +1609,7 @@ export class Conversion { data.fill(2 ** 7); // Fill it with the silent value } - const silentSample = new AudioSample({ + using silentSample = new AudioSample({ data, // Use the same format the decoder is spitting out. This avoids feeding changing sample // formats to the audio encoder. @@ -1636,22 +1633,28 @@ export class Conversion { endFrame = Math.round((this._endTimestamp - sample.timestamp) * sample.sampleRate); } + // Can't assign to "using" identifiers so we gotta do this + let finalSampleLet: AudioSample; if (startFrame > 0 || endFrame < sample.numberOfFrames) { // Trim the sample if it sticks out of the trim region on either end const trimmedSample = sample.trim(startFrame, endFrame); sample.close(); - sample = trimmedSample; + finalSampleLet = trimmedSample; - if (sample.numberOfFrames === 0) { - sample.close(); + if (trimmedSample.numberOfFrames === 0) { + trimmedSample.close(); continue; } + } else { + finalSampleLet = sample; } - // Offset the timestamp as needed - sample.setTimestamp(sample.timestamp - this._startTimestamp); + using finalSample = finalSampleLet; - await this._registerAudioSample(sample, source, outputTrackId, () => lastSampleTimestamp); + // Offset the timestamp as needed + finalSample.setTimestamp(finalSample.timestamp - this._startTimestamp); + + await this._registerAudioSample(finalSample, source, outputTrackId, () => lastSampleTimestamp); } source.close();