From 5aabf6350a28a57896f6ad67579692b21f6db6b0 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:31:02 +0100 Subject: [PATCH 1/7] Clarify docs --- src/packet.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/packet.ts b/src/packet.ts index c71890c..f5de60e 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -126,7 +126,10 @@ export class EncodedPacket { } } - /** If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. */ + /** + * If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. They are the + * result of retrieving packets with {@link PacketRetrievalOptions.metadataOnly} set to `true`. + */ get isMetadataOnly() { return this.data === PLACEHOLDER_DATA; } From e0dda582b159894fda4c1326f5915e8e6117d838 Mon Sep 17 00:00:00 2001 From: hex Date: Fri, 7 Nov 2025 16:58:20 -0700 Subject: [PATCH 2/7] fix webkit video conversion bug --- src/misc.ts | 22 ++++++++++++++++++++++ src/source.ts | 9 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/misc.ts b/src/misc.ts index 6bdb52b..0ec4458 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -676,6 +676,28 @@ export const isSafari = () => { return result; }; +let isWebKitCache: boolean | null = null; +export const isWebKit = () => { + if (isWebKitCache !== null) return isWebKitCache; + if (typeof navigator === 'undefined') return (isWebKitCache = false); + + const ua = navigator.userAgent || ''; + const maxTouchPoints = navigator.maxTouchPoints || 0; + + // iOS/iPadOS detection: + // - All iOS/iPadOS browsers use WebKit (WKWebView) + // - iPadOS 13+ can report "Macintosh" in UA; detect via touch points + const isIOSLike + = /iPhone|iPad|iPod/i.test(ua) || (/Macintosh/i.test(ua) && maxTouchPoints > 1); + + // On iOS/iPadOS: always WebKit (even if UA says CriOS/Edg/OPR/etc.) + if (isIOSLike) return (isWebKitCache = true); + + // Off iOS: only Safari is WebKit on mainstream desktops + const result = isSafari(); + return (isWebKitCache = result); +}; + let isFirefoxCache: boolean | null = null; export const isFirefox = () => { if (isFirefoxCache !== null) { diff --git a/src/source.ts b/src/source.ts index adafa2e..91fe2b2 100644 --- a/src/source.ts +++ b/src/source.ts @@ -12,6 +12,7 @@ import { binarySearchLessOrEqual, closedIntervalsOverlap, isNumber, + isWebKit, MaybePromise, mergeRequestInit, promiseWithResolvers, @@ -209,7 +210,13 @@ export class BlobSource extends Source { private async _runWorker(worker: ReadWorker) { let reader = this._readers.get(worker); if (reader === undefined) { - if ('stream' in this._blob) { + // WebKit has critical bugs with blob.stream(): + // - WebKitBlobResource error 1 when streaming large files + // - Memory buildup and reload loops on iOS (network process crashes) + // - ReadableStream stalls under backpressure (especially video) + // Affects Safari and all iOS browsers (Chrome, Firefox, etc.). + // Use arrayBuffer() fallback for WebKit browsers. + if ('stream' in this._blob && !isWebKit()) { // Get a reader of the blob starting at the required offset, and then keep it around const slice = this._blob.slice(worker.currentPos); reader = slice.stream().getReader(); From d969266662da0157e03e2bb1551aa4f2628e0c71 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:30:26 +0100 Subject: [PATCH 3/7] Simplify isWebKit, get rid of isSafari --- src/media-sink.ts | 8 ++++---- src/misc.ts | 41 +++++++---------------------------------- src/source.ts | 1 + 3 files changed, 12 insertions(+), 38 deletions(-) diff --git a/src/media-sink.ts b/src/media-sink.ts index 1d0665a..195ff2a 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -26,7 +26,7 @@ import { insertSorted, isFirefox, isNumber, - isSafari, + isWebKit, last, mapAsyncGenerator, promiseWithResolvers, @@ -918,7 +918,7 @@ class VideoDecoderWrapper extends DecoderWrapper { } else { assert(this.decoder); - if (!isSafari()) { + if (!isWebKit()) { insertSorted(this.inputTimestamps, packet.timestamp, x => x); } @@ -1049,7 +1049,7 @@ class VideoDecoderWrapper extends DecoderWrapper { /** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */ sampleHandler(sample: VideoSample) { - if (isSafari()) { + if (isWebKit()) { // For correct B-frame handling, we don't just hand over the frames directly but instead add them to // a queue, because we want to ensure frames are emitted in presentation order. We flush the queue // each time we receive a frame with a timestamp larger than the highest we've seen so far, as we @@ -1137,7 +1137,7 @@ class VideoDecoderWrapper extends DecoderWrapper { this.alphaRaslSkipped = false; } - if (isSafari()) { + if (isWebKit()) { for (const sample of this.sampleQueue) { this.finalizeAndEmitSample(sample); } diff --git a/src/misc.ts b/src/misc.ts index 0ec4458..c6f5119 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -658,44 +658,17 @@ export class CallSerializer { } } -let isSafariCache: boolean | null = null; -export const isSafari = () => { - if (isSafariCache !== null) { - return isSafariCache; - } - - const result = !!( - typeof navigator !== 'undefined' - && navigator.vendor?.match(/apple/i) - && !navigator.userAgent?.match(/crios/i) - && !navigator.userAgent?.match(/fxios/i) - && !navigator.userAgent?.match(/Opera|OPT\//) - ); - - isSafariCache = result; - return result; -}; - let isWebKitCache: boolean | null = null; export const isWebKit = () => { - if (isWebKitCache !== null) return isWebKitCache; - if (typeof navigator === 'undefined') return (isWebKitCache = false); + if (isWebKitCache !== null) { + return isWebKitCache; + } - const ua = navigator.userAgent || ''; - const maxTouchPoints = navigator.maxTouchPoints || 0; + // This even returns true for WebKit-wrapping browsers such as Chrome on iOS + const result = !!(typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i)); - // iOS/iPadOS detection: - // - All iOS/iPadOS browsers use WebKit (WKWebView) - // - iPadOS 13+ can report "Macintosh" in UA; detect via touch points - const isIOSLike - = /iPhone|iPad|iPod/i.test(ua) || (/Macintosh/i.test(ua) && maxTouchPoints > 1); - - // On iOS/iPadOS: always WebKit (even if UA says CriOS/Edg/OPR/etc.) - if (isIOSLike) return (isWebKitCache = true); - - // Off iOS: only Safari is WebKit on mainstream desktops - const result = isSafari(); - return (isWebKitCache = result); + isWebKitCache = result; + return result; }; let isFirefoxCache: boolean | null = null; diff --git a/src/source.ts b/src/source.ts index 91fe2b2..68ebac1 100644 --- a/src/source.ts +++ b/src/source.ts @@ -210,6 +210,7 @@ export class BlobSource extends Source { private async _runWorker(worker: ReadWorker) { let reader = this._readers.get(worker); if (reader === undefined) { + // https://github.com/Vanilagy/mediabunny/issues/184 // WebKit has critical bugs with blob.stream(): // - WebKitBlobResource error 1 when streaming large files // - Memory buildup and reload loops on iOS (network process crashes) From fd8c74d4db4a5566d55b2add5a5c8622ca33fe38 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:44:35 +0100 Subject: [PATCH 4/7] Fix invalid encoder AAC description on WebKit (fixes #206) --- src/codec.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++ src/media-source.ts | 30 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/codec.ts b/src/codec.ts index 2e90cfd..a396e70 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -621,6 +621,54 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null): AacAudioS }; }; +export const buildAacAudioSpecificConfig = (config: { + objectType: number; + sampleRate: number; + numberOfChannels: number; +}) => { + let frequencyIndex = aacFrequencyTable.indexOf(config.sampleRate); + let customSampleRate: number | null = null; + + if (frequencyIndex === -1) { + frequencyIndex = 15; + customSampleRate = config.sampleRate; + } + + const channelConfiguration = aacChannelMap.indexOf(config.numberOfChannels); + if (channelConfiguration === -1) { + throw new TypeError(`Unsupported number of channels: ${config.numberOfChannels}`); + } + + let bitCount = 5 + 4 + 4; + if (config.objectType >= 32) { + bitCount += 6; + } + if (frequencyIndex === 15) { + bitCount += 24; + } + + const byteCount = Math.ceil(bitCount / 8); + const bytes = new Uint8Array(byteCount); + const bitstream = new Bitstream(bytes); + + if (config.objectType < 32) { + bitstream.writeBits(5, config.objectType); + } else { + bitstream.writeBits(5, 31); + bitstream.writeBits(6, config.objectType - 32); + } + + bitstream.writeBits(4, frequencyIndex); + + if (frequencyIndex === 15) { + bitstream.writeBits(24, customSampleRate!); + } + + bitstream.writeBits(4, channelConfiguration); + + return bytes; +}; + export const OPUS_SAMPLE_RATE = 48_000; const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/; diff --git a/src/media-source.ts b/src/media-source.ts index c89b24a..86989a2 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -9,6 +9,8 @@ import { AUDIO_CODECS, AudioCodec, + buildAacAudioSpecificConfig, + parseAacAudioSpecificConfig, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, @@ -24,9 +26,11 @@ import { CallSerializer, clamp, isFirefox, + last, promiseWithResolvers, setInt24, setUint24, + toUint8Array, } from './misc'; import { Muxer } from './muxer'; import { SubtitleParser } from './subtitles'; @@ -1515,6 +1519,32 @@ class AudioEncoderWrapper { this.encoder = new AudioEncoder({ output: (chunk, meta) => { + // WebKit emits an invalid description for AAC (https://bugs.webkit.org/show_bug.cgi?id=302253), + // which we try to detect here. If detected, we'll provide our own description instead, derived + // from the codec string and audio parameters. + if (this.encodingConfig.codec === 'aac' && meta?.decoderConfig) { + let needsDescriptionOverwrite = false; + if (!meta.decoderConfig.description || meta.decoderConfig.description.byteLength < 2) { + needsDescriptionOverwrite = true; + } else { + const audioSpecificConfig = parseAacAudioSpecificConfig( + toUint8Array(meta.decoderConfig.description), + ); + + needsDescriptionOverwrite = audioSpecificConfig.objectType === 0; + } + + if (needsDescriptionOverwrite) { + const objectType = Number(last(encoderConfig.codec.split('.'))); + + meta.decoderConfig.description = buildAacAudioSpecificConfig({ + objectType, + numberOfChannels: meta.decoderConfig.numberOfChannels, + sampleRate: meta.decoderConfig.sampleRate, + }); + } + } + const packet = EncodedPacket.fromEncodedChunk(chunk); this.encodingConfig.onEncodedPacket?.(packet, meta); From c0da28fb6c8aba0614bf852bd45c9558d3e2cef6 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:14:25 +0100 Subject: [PATCH 5/7] Floor Matroska default packet duration (fixes #170) --- dev/demux.html | 26 ++++++++++++++++++++++++++ src/matroska/matroska-demuxer.ts | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/dev/demux.html b/dev/demux.html index 2bca413..d53045c 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -15,6 +15,31 @@ }); const videoTrack = await input.getPrimaryVideoTrack(); + const sink = new Mediabunny.EncodedPacketSink(videoTrack); + + for await (const packet of sink.packets()) { + console.log(packet.timestamp, packet.duration, packet.timestamp + packet.duration); + } + + /* + const sink = new Mediabunny.VideoSampleSink(videoTrack); + + for await (const sample of sink.samples()) { + console.log(sample.timestamp, sample.duration); + sample.close(); + } + */ + + /* + const sink = new Mediabunny.EncodedPacketSink(videoTrack); + + for await (const packet of sink.packets()) { + console.log(packet.timestamp, packet.timestamp + packet.duration); + } + */ + + + /* const sink = new Mediabunny.VideoSampleSink(videoTrack); console.log(await videoTrack.getDecoderConfig()) @@ -30,6 +55,7 @@ for await (const sample of sink.samples()) { console.log(sample); } + */ /* let total = 0; diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index c9655d5..79c84f4 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -1181,8 +1181,9 @@ export class MatroskaDemuxer extends Demuxer { case EBMLId.DefaultDuration: { if (!this.currentTrack) break; + // Floored to reduce chance of overlapping packets (https://github.com/Vanilagy/mediabunny/issues/170) this.currentTrack.defaultDuration - = this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9; + = Math.floor(this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9); }; break; case EBMLId.Name: { From 6022781e8c518a1fd5776711bb4dbea4b31ae5b1 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:53:28 +0100 Subject: [PATCH 6/7] Bump patch --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/mp3-encoder/package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index a00879d..271da7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.24.4", + "version": "1.24.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.24.4", + "version": "1.24.5", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7749,9 +7749,9 @@ } }, "node_modules/mediabunny": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.3.tgz", - "integrity": "sha512-uQu5lXg6c3z2YUKLimAsem1qYPdGfGziQ5CKaAMJAs0C0NNw5T/Zkn0foPML0gWqQJ5ZeC0wTgCxpiBQY8m1Tw==", + "version": "1.24.4", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.4.tgz", + "integrity": "sha512-dpWYBPTtMg152yNLXZQ7xb6hsXdYbKp9EuK8qq4npS+SZ08FVc1XHlXYhrOm31T+tUVJKgm95Yaqy69wTpZP9Q==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12242,7 +12242,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.24.4", + "version": "1.24.5", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index f8fa949..3b11949 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.24.4", + "version": "1.24.5", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index f4840ff..b7d133b 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.24.4", + "version": "1.24.5", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", From 8b39eb0d43e85fc6b378a905112d2c10802479d8 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:14:43 +0100 Subject: [PATCH 7/7] Revert Matroska default duration flooring --- dev/demux.html | 4 +++- src/matroska/matroska-demuxer.ts | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dev/demux.html b/dev/demux.html index d53045c..1a864a3 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -14,11 +14,13 @@ source: new Mediabunny.BlobSource(file), }); - const videoTrack = await input.getPrimaryVideoTrack(); + const videoTrack = await input.getPrimaryAudioTrack(); const sink = new Mediabunny.EncodedPacketSink(videoTrack); for await (const packet of sink.packets()) { console.log(packet.timestamp, packet.duration, packet.timestamp + packet.duration); + + if (packet.timestamp > 10) break; } /* diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 79c84f4..e964bef 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -844,13 +844,13 @@ export class MatroskaDemuxer extends Demuxer { blocks.splice(blockIndex, 1); // Remove the original block + const blockDuration = originalBlock.duration || frameCount * (track.defaultDuration ?? 0); + // Now, let's insert each frame as its own block for (let i = 0; i < frameCount; i++) { const frameSize = frameSizes[i]!; const frameData = readBytes(slice, frameSize); - const blockDuration = originalBlock.duration || (frameCount * (track.defaultDuration ?? 0)); - // Distribute timestamps evenly across the block duration const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount); const frameDuration = blockDuration / frameCount; @@ -1181,9 +1181,8 @@ export class MatroskaDemuxer extends Demuxer { case EBMLId.DefaultDuration: { if (!this.currentTrack) break; - // Floored to reduce chance of overlapping packets (https://github.com/Vanilagy/mediabunny/issues/170) this.currentTrack.defaultDuration - = Math.floor(this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9); + = this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9; }; break; case EBMLId.Name: {