Compare commits

..
33 Commits
Author SHA1 Message Date
Vanilagy 8b39eb0d43 Revert Matroska default duration flooring 2025-11-10 21:14:43 +01:00
Vanilagy 6022781e8c Bump patch 2025-11-10 20:53:28 +01:00
Vanilagy c0da28fb6c Floor Matroska default packet duration (fixes #170) 2025-11-10 16:14:25 +01:00
Vanilagy fd8c74d4db Fix invalid encoder AAC description on WebKit (fixes #206) 2025-11-10 14:44:35 +01:00
David P.andGitHub 6fb3103c01 Merge pull request #215 from hexcowboy/issue-#184
Use `ArrayBuffer` instead of `ReadableStreamReader`on WebKit browsers
2025-11-10 11:36:13 +01:00
Vanilagy d969266662 Simplify isWebKit, get rid of isSafari 2025-11-10 11:30:26 +01:00
hex e0dda582b1 fix webkit video conversion bug 2025-11-07 16:58:20 -07:00
Vanilagy 5aabf6350a Clarify docs 2025-11-06 16:31:02 +01:00
Vanilagy 21cff32831 Bump patch 2025-11-06 10:02:23 +01:00
David P.andGitHub 73e936f325 Merge pull request #210 from JonnyBurger/increase-tolerance-by-one
Increase tolerance of pixels by 1 in tests
2025-11-06 09:01:40 +00:00
Jonny BurgerandGitHub 3c8d503158 Merge pull request #209 from JonnyBurger/flac-consecutive-headers
Ensure FLAC headers are consecutive
2025-11-06 10:00:37 +01:00
JonnyBurger fe4eebd487 Increase tolerance of pixels by 1 in tests 2025-11-05 10:54:03 +01:00
Vanilagy b5f5b5588c Update RVE logo 2025-11-03 15:24:17 +01:00
Vanilagy b1f17b25e3 Add Jellypod & fix RVE logo 2025-11-03 12:12:12 +01:00
Vanilagy efcf5d0069 Add vjeux 2025-11-02 22:03:35 +01:00
Vanilagy aad31009fe Make test more strict 2025-11-02 21:56:16 +01:00
Vanilagy 68c37b49dc Another test test 2025-11-02 21:50:49 +01:00
Vanilagy b12fee70a3 Log test 2025-11-02 21:47:49 +01:00
Vanilagy 90d5aed051 Test test 2025-11-02 21:46:24 +01:00
Vanilagy d2d893ff30 Bump patch 2025-11-02 21:36:09 +01:00
Vanilagy 3217490d81 Fix read worker abort bugs (fixes #197) 2025-11-02 14:03:54 +01:00
Vanilagy 5039c730bf Fix MediaStreamTracks not being stopped inside of the worker (fixes #204) 2025-11-02 13:00:38 +01:00
Vanilagy a14c0885d7 Bump patch 2025-10-21 16:28:03 +02:00
Vanilagy c7b45eabf0 Explanatory comment 2025-10-21 16:27:27 +02:00
Vanilagy 8629709cd7 < 8 2025-10-21 16:27:04 +02:00
David P.andGitHub 754af6f90f Merge pull request #186 from JonnyBurger/fix-185
Make Mediabunny more resilient against `data` boxes ending abruptly
2025-10-21 16:24:02 +02:00
David P.andGitHub fa1fc27577 Merge pull request #187 from JonnyBurger/explain-examples-server
Explain how to use examples dev server
2025-10-21 16:19:25 +02:00
Vanilagy e676de4129 Terminate reading a cluster early if another cluster is encountered (fixes #164) 2025-10-21 16:18:48 +02:00
Vanilagy 0c4c13cea2 Perform conversion API resampling even with process is set 2025-10-20 16:13:05 +02:00
JonnyBurger 3767aea7d9 Fix 2025-10-18 17:24:00 +02:00
JonnyBurger cdeeb6c6fa Explain how to use examples dev server 2025-10-18 11:25:51 +02:00
Jonny BurgerandGitHub c27042b24d Merge branch 'main' into fix-185 2025-10-18 11:24:44 +02:00
JonnyBurger f8cd66727c Make Mediabunny more resilient against data boxes ending abruptly 2025-10-18 11:23:44 +02:00
21 changed files with 297 additions and 100 deletions
+7 -3
View File
@@ -45,12 +45,16 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<div align="center"> <div align="center">
<a href="https://www.reactvideoeditor.com/" target="_blank" rel="sponsored"> <a href="https://www.reactvideoeditor.com/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/rve.svg" width="40" height="40" alt="React Video Editor"> <img src="./docs/public/sponsors/rve.png" width="40" height="40" alt="React Video Editor">
</a> </a>
&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mux.com/" target="_blank" rel="sponsored"> <a href="https://www.mux.com/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/mux.jpg" width="40" height="40" alt="Mux"> <img src="./docs/public/sponsors/mux.jpg" width="40" height="40" alt="Mux">
</a> </a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://jellypod.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/jellypod.png" width="40" height="40" alt="Jellypod">
</a>
</div> </div>
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy) [Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
@@ -200,7 +204,7 @@ npm run lint # ESLint
npm run docs:generate # Generates API docs npm run docs:generate # Generates API docs
npm run docs:dev # Start docs development server npm run docs:dev # Start docs development server
npm run dev # Start examples development server npm run dev # Start examples development server, will run at http://localhost:5173/examples/[name]/
npm run docs:build # Build docs and examples npm run docs:build # Build docs and examples
``` ```
+1 -1
View File
@@ -115,7 +115,7 @@
return ctx.canvas; return ctx.canvas;
}, },
*/ */
width: 300, //width: 300,
//alpha: 'keep', //alpha: 'keep',
//width: 320, //width: 320,
//discard: true, //discard: true,
+50 -3
View File
@@ -14,6 +14,52 @@
source: new Mediabunny.BlobSource(file), source: new Mediabunny.BlobSource(file),
}); });
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;
}
/*
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())
const sink2 = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink2.packets()) {
console.log(packet.data.join(', '))//, window.findNalUnitsInLengthPrefixed(packet.data, 4).map(x => window.extractNalUnitTypeForAvc(x)));
if (packet.timestamp > 1) break;
}
//return;
for await (const sample of sink.samples()) {
console.log(sample);
}
*/
/*
let total = 0; let total = 0;
input.source.onread = (start, end) => { input.source.onread = (start, end) => {
total += end - start; total += end - start;
@@ -22,6 +68,7 @@
const videoTrack = await input.getPrimaryVideoTrack(); const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack); const sink = new Mediabunny.EncodedPacketSink(videoTrack);
*/
//console.log(await sink.getPacket(0)); //console.log(await sink.getPacket(0));
/* /*
@@ -38,7 +85,7 @@
const stats = await videoTrack.computePacketStats(); //const stats = await videoTrack.computePacketStats();
/* /*
const sink = new Mediabunny.EncodedPacketSink(videoTrack); const sink = new Mediabunny.EncodedPacketSink(videoTrack);
@@ -47,9 +94,9 @@
} }
*/ */
//console.log(await videoTrack.computeDuration()); //console.log(await videoTrack.computeDuration());
console.log("Done", stats, total, file.size) //console.log("Done", stats, total, file.size)
console.log(input); //console.log(input);
/* /*
const videoTrack = await input.getPrimaryVideoTrack(); const videoTrack = await input.getPrimaryVideoTrack();
+3 -1
View File
@@ -100,10 +100,12 @@ const sponsors = {
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' }, { image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
], ],
bronze: [ bronze: [
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' }, { image: '/sponsors/rve.png', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
{ image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' }, { image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' },
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
], ],
individual: [ individual: [
{ image: 'https://avatars.githubusercontent.com/u/197597', name: 'Christopher Chedeau', url: 'https://github.com/vjeux' },
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' }, { image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' }, { image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' }, { image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg width="52" height="51" viewBox="0 0 52 51" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.1186 1.50109C23.3805 0.147773 21.1954 -0.253724 19.1203 0.397437L5.08373 4.80289C2.19204 5.71105 0.248047 8.44236 0.248047 11.6022V42.9894C0.248047 45.2558 1.24465 47.3313 2.98266 48.6825C4.72099 50.036 6.9084 50.4373 8.98111 49.7863L19.4595 46.4967C19.1318 45.366 18.9658 44.1862 18.9658 42.9894V28.735C18.9658 25.7417 20.0093 22.8829 21.9088 20.6899C23.303 19.0744 25.1116 17.8826 27.1329 17.2463L27.8534 17.0309V7.19654C27.8534 4.93006 26.8568 2.85458 25.1185 1.50109H25.1186Z" fill="#1C2AC5"/>
<path d="M51.2479 24.3293V38.5836C51.2479 41.7411 49.3061 44.4749 46.4122 45.3806L32.3756 49.7862C31.7253 49.9917 31.0632 50.0922 30.4081 50.0922C28.9739 50.0922 27.5703 49.6125 26.3771 48.6824C25.832 48.2565 25.3595 47.7645 24.9711 47.2139C24.4986 46.553 24.1453 45.8164 23.923 45.0257C23.9206 45.0257 23.9184 45.0257 23.916 45.0281L23.9206 45.0232C23.7406 44.3746 23.6423 43.6917 23.6423 42.9893V28.7349C23.6423 27.2712 24.0588 25.8983 24.7935 24.7576C25.528 23.6169 26.5807 22.7066 27.8534 22.1656C28.057 22.08 28.2652 22.0015 28.4782 21.9356L30.1953 21.3971L42.515 17.53C44.5899 16.8788 46.7751 17.2803 48.5132 18.6338C50.2516 19.9873 51.248 22.0628 51.248 24.3293H51.2479Z" fill="#3E8AF5"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"version": "1.24.0", "version": "1.24.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mediabunny", "name": "mediabunny",
"version": "1.24.0", "version": "1.24.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@@ -7749,9 +7749,9 @@
} }
}, },
"node_modules/mediabunny": { "node_modules/mediabunny": {
"version": "1.23.0", "version": "1.24.4",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.23.0.tgz", "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.4.tgz",
"integrity": "sha512-mWdhhdRquePfTgZ+18DLCFWmqwO6oGP/YudmRU8Puh+whMUJ3VSb9KMAHPl2utim66ixKdZw+DbmdqBfBxRp0A==", "integrity": "sha512-dpWYBPTtMg152yNLXZQ7xb6hsXdYbKp9EuK8qq4npS+SZ08FVc1XHlXYhrOm31T+tUVJKgm95Yaqy69wTpZP9Q==",
"license": "MPL-2.0", "license": "MPL-2.0",
"peer": true, "peer": true,
"workspaces": [ "workspaces": [
@@ -12242,7 +12242,7 @@
}, },
"packages/mp3-encoder": { "packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"version": "1.24.0", "version": "1.24.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"devDependencies": { "devDependencies": {
"@types/emscripten": "^1.40.1" "@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.24.0", "version": "1.24.5",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.24.0", "version": "1.24.5",
"description": "MP3 encoder extension for Mediabunny, based on LAME.", "description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+48
View File
@@ -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; export const OPUS_SAMPLE_RATE = 48_000;
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/; const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
+3 -3
View File
@@ -186,7 +186,7 @@ export type ConversionVideoOptions = {
* *
* This function can also be used to manually resize frames. When doing so, you should signal the post-process * This function can also be used to manually resize frames. When doing so, you should signal the post-process
* dimensions using the `processedWidth` and `processedHeight` fields, which enables the encoder to better know what * dimensions using the `processedWidth` and `processedHeight` fields, which enables the encoder to better know what
* to expect. * to expect. If these fields aren't set, Mediabunny will assume you won't perform any resizing.
*/ */
process?: (sample: VideoSample) => MaybePromise< process?: (sample: VideoSample) => MaybePromise<
CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null
@@ -229,7 +229,8 @@ export type ConversionAudioOptions = {
* *
* This function can also be used to manually perform remixing or resampling. When doing so, you should signal the * This function can also be used to manually perform remixing or resampling. When doing so, you should signal the
* post-process parameters using the `processedNumberOfChannels` and `processedSampleRate` fields, which enables the * post-process parameters using the `processedNumberOfChannels` and `processedSampleRate` fields, which enables the
* encoder to better know what to expect. * encoder to better know what to expect. If these fields aren't set, Mediabunny will assume you won't perform
* remixing or resampling.
*/ */
process?: (sample: AudioSample) => MaybePromise< process?: (sample: AudioSample) => MaybePromise<
AudioSample | AudioSample[] | null AudioSample | AudioSample[] | null
@@ -1350,7 +1351,6 @@ export class Conversion {
!encodableCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec)) !encodableCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
&& audioCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec)) && audioCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
&& (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE) && (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)
&& !trackOptions.process
) { ) {
// We could not find a compatible non-PCM codec despite the container supporting them. This can be // We could not find a compatible non-PCM codec despite the container supporting them. This can be
// caused by strange channel count or sample rate configurations. Therefore, let's try again but with // caused by strange channel count or sample rate configurations. Therefore, let's try again but with
+25 -2
View File
@@ -339,16 +339,34 @@ export class FlacDemuxer extends Demuxer {
slice.skip(-2); slice.skip(-2);
const lengthIfNextFlacFrameHeaderIsLegit = slice.filePos - startPos; const lengthIfNextFlacFrameHeaderIsLegit = slice.filePos - startPos;
const nextIsLegit = this.readFlacFrameHeader({ const nextFrameHeader = this.readFlacFrameHeader({
slice, slice,
isFirstPacket: false, isFirstPacket: false,
}); });
if (!nextIsLegit) { if (!nextFrameHeader) {
slice.skip(-1); slice.skip(-1);
continue; continue;
} }
// Ensure the frameOrSampleNum is consecutive.
// https://github.com/Vanilagy/mediabunny/issues/194
if (this.blockingBit === 0) {
// Case A: If the stream is fixed block size, this is the frame number, which increments by 1
if (nextFrameHeader.num - frameHeader.num !== 1) {
slice.skip(-1);
continue;
}
} else {
// Case B: If the stream is variable block size, this is the sample number, which increments by
// amount of samples in a frame.
if (nextFrameHeader.num - frameHeader.num !== frameHeader.blockSize) {
slice.skip(-1);
continue;
}
}
return { return {
num: frameHeader.num, num: frameHeader.num,
blockSize: frameHeader.blockSize, blockSize: frameHeader.blockSize,
@@ -442,6 +460,11 @@ export class FlacDemuxer extends Demuxer {
return null; return null;
} }
if (sampleRate !== this.audioInfo.sampleRate) {
// This cannot be a valid FLAC frame, the sample rate is not the same as in the stream info
return null;
}
const size = slice.filePos - startOffset; const size = slice.filePos - startOffset;
const crc = readU8(slice); const crc = readU8(slice);
+5
View File
@@ -70,6 +70,11 @@ export const readDataBox = (slice: FileSlice) => {
return null; return null;
} }
if (slice.remainingLength < 8) {
// Box is too small
return null;
}
const typeIndicator = readU32Be(slice); const typeIndicator = readU32Be(slice);
slice.skip(4); // Locale indicator slice.skip(4); // Locale indicator
const data = readBytes(slice, header.contentSize - 8); const data = readBytes(slice, header.contentSize - 8);
+44 -39
View File
@@ -637,7 +637,10 @@ export class MatroskaDemuxer extends Demuxer {
this.currentCluster = cluster; this.currentCluster = cluster;
if (dataSlice) { if (dataSlice) {
this.readContiguousElements(dataSlice); // Read the children of the cluster, stopping early at level 0 or 1 EBML elements. We do this because some
// clusters have incorrect sizes that are too large
const endPos = this.readContiguousElements(dataSlice, LEVEL_0_AND_1_EBML_IDS);
cluster.elementEndPos = endPos;
} }
for (const [, trackData] of cluster.trackData) { for (const [, trackData] of cluster.trackData) {
@@ -841,13 +844,13 @@ export class MatroskaDemuxer extends Demuxer {
blocks.splice(blockIndex, 1); // Remove the original block 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 // Now, let's insert each frame as its own block
for (let i = 0; i < frameCount; i++) { for (let i = 0; i < frameCount; i++) {
const frameSize = frameSizes[i]!; const frameSize = frameSizes[i]!;
const frameData = readBytes(slice, frameSize); const frameData = readBytes(slice, frameSize);
const blockDuration = originalBlock.duration || (frameCount * (track.defaultDuration ?? 0));
// Distribute timestamps evenly across the block duration // Distribute timestamps evenly across the block duration
const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount); const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount);
const frameDuration = blockDuration / frameCount; const frameDuration = blockDuration / frameCount;
@@ -913,24 +916,31 @@ export class MatroskaDemuxer extends Demuxer {
} }
} }
readContiguousElements(slice: FileSlice) { readContiguousElements(slice: FileSlice, stopIds?: number[]) {
const startIndex = slice.filePos; const startIndex = slice.filePos;
while (slice.filePos - startIndex <= slice.length - MIN_HEADER_SIZE) { while (slice.filePos - startIndex <= slice.length - MIN_HEADER_SIZE) {
const foundElement = this.traverseElement(slice); const startPos = slice.filePos;
const foundElement = this.traverseElement(slice, stopIds);
if (!foundElement) { if (!foundElement) {
break; return startPos;
} }
} }
return slice.filePos;
} }
traverseElement(slice: FileSlice): boolean { traverseElement(slice: FileSlice, stopIds?: number[]): boolean {
const header = readElementHeader(slice); const header = readElementHeader(slice);
if (!header) { if (!header) {
return false; return false;
} }
if (stopIds && stopIds.includes(header.id)) {
return false;
}
const { id, size } = header; const { id, size } = header;
const dataStartPos = slice.filePos; const dataStartPos = slice.filePos;
assertDefinedSize(size); assertDefinedSize(size);
@@ -2174,6 +2184,8 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
if (id === EBMLId.Cluster) { if (id === EBMLId.Cluster) {
currentCluster = await demuxer.readCluster(elementStartPos, segment); currentCluster = await demuxer.readCluster(elementStartPos, segment);
// readCluster computes the proper size even if it's undefined in the header, so let's use that instead
size = currentCluster.elementEndPos - dataStartPos;
const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster); const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);
if (correctBlockFound) { if (correctBlockFound) {
@@ -2190,44 +2202,37 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
// Undefined element size (can happen in livestreamed files). In this case, we need to do some // Undefined element size (can happen in livestreamed files). In this case, we need to do some
// searching to determine the actual size of the element. // searching to determine the actual size of the element.
if (id === EBMLId.Cluster) { assert(id !== EBMLId.Cluster); // Undefined cluster sizes are fixed further up
// The cluster should have already computed its length, we can just copy that result
assert(currentCluster);
size = currentCluster.elementEndPos - dataStartPos;
} else {
// Search for the next element at level 0 or 1
const nextElementPos = await searchForNextElementId(
demuxer.reader,
dataStartPos,
LEVEL_0_AND_1_EBML_IDS,
segment.elementEndPos,
);
size = nextElementPos.pos - dataStartPos; // Search for the next element at level 0 or 1
} const nextElementPos = await searchForNextElementId(
demuxer.reader,
dataStartPos,
LEVEL_0_AND_1_EBML_IDS,
segment.elementEndPos,
);
const endPos = dataStartPos + size; size = nextElementPos.pos - dataStartPos;
if (segment.elementEndPos !== null && endPos > segment.elementEndPos - MIN_HEADER_SIZE) { }
// No more elements fit in this segment
const endPos = dataStartPos + size;
if (segment.elementEndPos === null) {
// Check the next element. If it's a new segment, we know this segment ends here. The new
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
// the first segment.
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
const elementId = readElementId(slice);
if (elementId === EBMLId.Segment) {
segment.elementEndPos = endPos; // We now know the segment's size
break; break;
} else {
// Check the next element. If it's a new segment, we know this segment ends here. The new
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
// the first segment.
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
const elementId = readElementId(slice);
if (elementId === EBMLId.Segment) {
segment.elementEndPos = endPos;
break;
}
} }
} }
currentPos = dataStartPos + size; currentPos = endPos;
} }
// Catch faulty cue points // Catch faulty cue points
+4 -4
View File
@@ -26,7 +26,7 @@ import {
insertSorted, insertSorted,
isFirefox, isFirefox,
isNumber, isNumber,
isSafari, isWebKit,
last, last,
mapAsyncGenerator, mapAsyncGenerator,
promiseWithResolvers, promiseWithResolvers,
@@ -918,7 +918,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
} else { } else {
assert(this.decoder); assert(this.decoder);
if (!isSafari()) { if (!isWebKit()) {
insertSorted(this.inputTimestamps, packet.timestamp, x => x); insertSorted(this.inputTimestamps, packet.timestamp, x => x);
} }
@@ -1049,7 +1049,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
/** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */ /** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */
sampleHandler(sample: VideoSample) { 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 // 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 // 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 // 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<VideoSample> {
this.alphaRaslSkipped = false; this.alphaRaslSkipped = false;
} }
if (isSafari()) { if (isWebKit()) {
for (const sample of this.sampleQueue) { for (const sample of this.sampleQueue) {
this.finalizeAndEmitSample(sample); this.finalizeAndEmitSample(sample);
} }
+38 -4
View File
@@ -9,6 +9,8 @@
import { import {
AUDIO_CODECS, AUDIO_CODECS,
AudioCodec, AudioCodec,
buildAacAudioSpecificConfig,
parseAacAudioSpecificConfig,
parsePcmCodec, parsePcmCodec,
PCM_AUDIO_CODECS, PCM_AUDIO_CODECS,
PcmAudioCodec, PcmAudioCodec,
@@ -24,9 +26,11 @@ import {
CallSerializer, CallSerializer,
clamp, clamp,
isFirefox, isFirefox,
last,
promiseWithResolvers, promiseWithResolvers,
setInt24, setInt24,
setUint24, setUint24,
toUint8Array,
} from './misc'; } from './misc';
import { Muxer } from './muxer'; import { Muxer } from './muxer';
import { SubtitleParser } from './subtitles'; import { SubtitleParser } from './subtitles';
@@ -1159,7 +1163,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
type: 'videoTrack', type: 'videoTrack',
trackId: this._workerTrackId, trackId: this._workerTrackId,
track: this._track, track: this._track,
}, [this._track]); });
this._workerListener = (event: MessageEvent) => { this._workerListener = (event: MessageEvent) => {
const message = event.data as MediaStreamTrackProcessorWorkerMessage; const message = event.data as MediaStreamTrackProcessorWorkerMessage;
@@ -1515,6 +1519,32 @@ class AudioEncoderWrapper {
this.encoder = new AudioEncoder({ this.encoder = new AudioEncoder({
output: (chunk, meta) => { 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); const packet = EncodedPacket.fromEncodedChunk(chunk);
this.encodingConfig.onEncodedPacket?.(packet, meta); this.encodingConfig.onEncodedPacket?.(packet, meta);
@@ -1978,17 +2008,19 @@ const mediaStreamTrackProcessorWorkerCode = () => {
}); });
const abortControllers = new Map<number, AbortController>(); const abortControllers = new Map<number, AbortController>();
const stoppedTracks = new Set<number>(); const activeTracks = new Map<number, MediaStreamVideoTrack>();
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
const message = event.data as MediaStreamTrackProcessorControllerMessage; const message = event.data as MediaStreamTrackProcessorControllerMessage;
switch (message.type) { switch (message.type) {
case 'videoTrack': { case 'videoTrack': {
activeTracks.set(message.trackId, message.track);
const processor = new MediaStreamTrackProcessor({ track: message.track }); const processor = new MediaStreamTrackProcessor({ track: message.track });
const consumer = new WritableStream<VideoFrame>({ const consumer = new WritableStream<VideoFrame>({
write: (videoFrame) => { write: (videoFrame) => {
if (stoppedTracks.has(message.trackId)) { if (!activeTracks.has(message.trackId)) {
videoFrame.close(); videoFrame.close();
return; return;
} }
@@ -2026,7 +2058,9 @@ const mediaStreamTrackProcessorWorkerCode = () => {
abortControllers.delete(message.trackId); abortControllers.delete(message.trackId);
} }
stoppedTracks.add(message.trackId); const track = activeTracks.get(message.trackId);
track?.stop();
activeTracks.delete(message.trackId);
sendMessage({ sendMessage({
type: 'trackStopped', type: 'trackStopped',
+7 -12
View File
@@ -658,21 +658,16 @@ export class CallSerializer {
} }
} }
let isSafariCache: boolean | null = null; let isWebKitCache: boolean | null = null;
export const isSafari = () => { export const isWebKit = () => {
if (isSafariCache !== null) { if (isWebKitCache !== null) {
return isSafariCache; return isWebKitCache;
} }
const result = !!( // This even returns true for WebKit-wrapping browsers such as Chrome on iOS
typeof navigator !== 'undefined' const result = !!(typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i));
&& navigator.vendor?.match(/apple/i)
&& !navigator.userAgent?.match(/crios/i)
&& !navigator.userAgent?.match(/fxios/i)
&& !navigator.userAgent?.match(/Opera|OPT\//)
);
isSafariCache = result; isWebKitCache = result;
return result; return result;
}; };
+4 -1
View File
@@ -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() { get isMetadataOnly() {
return this.data === PLACEHOLDER_DATA; return this.data === PLACEHOLDER_DATA;
} }
+39 -7
View File
@@ -12,6 +12,7 @@ import {
binarySearchLessOrEqual, binarySearchLessOrEqual,
closedIntervalsOverlap, closedIntervalsOverlap,
isNumber, isNumber,
isWebKit,
MaybePromise, MaybePromise,
mergeRequestInit, mergeRequestInit,
promiseWithResolvers, promiseWithResolvers,
@@ -209,7 +210,14 @@ export class BlobSource extends Source {
private async _runWorker(worker: ReadWorker) { private async _runWorker(worker: ReadWorker) {
let reader = this._readers.get(worker); let reader = this._readers.get(worker);
if (reader === undefined) { if (reader === undefined) {
if ('stream' in this._blob) { // 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)
// - 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 // Get a reader of the blob starting at the required offset, and then keep it around
const slice = this._blob.slice(worker.currentPos); const slice = this._blob.slice(worker.currentPos);
reader = slice.stream().getReader(); reader = slice.stream().getReader();
@@ -234,11 +242,19 @@ export class BlobSource extends Source {
break; break;
} }
if (worker.aborted) {
break;
}
this.onread?.(worker.currentPos, worker.currentPos + value.length); this.onread?.(worker.currentPos, worker.currentPos + value.length);
this._orchestrator.supplyWorkerData(worker, value); this._orchestrator.supplyWorkerData(worker, value);
} else { } else {
const data = await this._blob.slice(worker.currentPos, worker.targetPos).arrayBuffer(); const data = await this._blob.slice(worker.currentPos, worker.targetPos).arrayBuffer();
if (worker.aborted) {
break;
}
this.onread?.(worker.currentPos, worker.currentPos + data.byteLength); this.onread?.(worker.currentPos, worker.currentPos + data.byteLength);
this._orchestrator.supplyWorkerData(worker, new Uint8Array(data)); this._orchestrator.supplyWorkerData(worker, new Uint8Array(data));
} }
@@ -457,7 +473,7 @@ export class UrlSource extends Source {
/** @internal */ /** @internal */
private async _runWorker(worker: ReadWorker) { private async _runWorker(worker: ReadWorker) {
// The outer loop is for resuming a request if it dies mid-response // The outer loop is for resuming a request if it dies mid-response
while (!worker.aborted) { while (true) {
const existing = this._existingResponses.get(worker); const existing = this._existingResponses.get(worker);
this._existingResponses.delete(worker); this._existingResponses.delete(worker);
@@ -534,6 +550,10 @@ export class UrlSource extends Source {
} }
} }
if (worker.aborted) {
break;
}
const { done, value } = readResult; const { done, value } = readResult;
if (done) { if (done) {
@@ -552,6 +572,10 @@ export class UrlSource extends Source {
this.onread?.(worker.currentPos, worker.currentPos + value.length); this.onread?.(worker.currentPos, worker.currentPos + value.length);
this._orchestrator.supplyWorkerData(worker, value); this._orchestrator.supplyWorkerData(worker, value);
} }
if (worker.aborted) {
break;
}
} }
worker.running = false; worker.running = false;
@@ -796,6 +820,10 @@ export class StreamSource extends Source {
let data = this._options.read(worker.currentPos, originalTargetPos); let data = this._options.read(worker.currentPos, originalTargetPos);
if (data instanceof Promise) data = await data; if (data instanceof Promise) data = await data;
if (worker.aborted) {
break;
}
if (data instanceof Uint8Array) { if (data instanceof Uint8Array) {
data = toUint8Array(data); // Normalize things like Node.js Buffer to Uint8Array data = toUint8Array(data); // Normalize things like Node.js Buffer to Uint8Array
@@ -833,6 +861,10 @@ export class StreamSource extends Source {
throw new TypeError('ReadableStream returned by options.read must yield Uint8Array chunks.'); throw new TypeError('ReadableStream returned by options.read must yield Uint8Array chunks.');
} }
if (worker.aborted) {
break;
}
const data = toUint8Array(value); // Normalize things like Node.js Buffer to Uint8Array const data = toUint8Array(value); // Normalize things like Node.js Buffer to Uint8Array
this.onread?.(worker.currentPos, worker.currentPos + data.length); this.onread?.(worker.currentPos, worker.currentPos + data.length);
@@ -1420,7 +1452,10 @@ class ReadOrchestrator {
currentPos: startPos, currentPos: startPos,
targetPos, targetPos,
running: false, running: false,
aborted: false, // Due to async shenanigans, it can happen that workers are started after disposal. In this case, instead of
// simply not creating the worker, we allow it to run but immediately label it as aborted, so it can then
// shut itself down.
aborted: this.disposed,
pendingSlices: [], pendingSlices: [],
age: this.nextAge++, age: this.nextAge++,
}; };
@@ -1473,10 +1508,7 @@ class ReadOrchestrator {
/** Called by a worker when it has read some data. */ /** Called by a worker when it has read some data. */
supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) { supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) {
if (this.disposed) { assert(!worker.aborted);
// Writes may still come in after disposal, but we just ignore those
return;
}
const start = worker.currentPos; const start = worker.currentPos;
const end = start + bytes.length; const end = start + bytes.length;
+11 -8
View File
@@ -139,17 +139,19 @@ test('Can encode transparent video', async () => {
probeContext.drawImage(video, 0, 0); probeContext.drawImage(video, 0, 0);
let imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height); let imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height);
expect(imageData.data[3]).lessThanOrEqual(2); // Transparent (within error) const pos1 = { x: 100, y: 100 };
const index1 = (pos1.x + pos1.y * probeCanvas.width) * 4;
expect(imageData.data[index1 + 3]).toBe(0); // Transparent
const pos = { x: 300, y: 300 }; // Dead center in the red square const pos2 = { x: 300, y: 300 }; // Dead center in the red square
const index = (pos.x + pos.y * probeCanvas.width) * 4; const index2 = (pos2.x + pos2.y * probeCanvas.width) * 4;
// Red (within error) // Red (within error)
expect(imageData.data[index + 0]).greaterThanOrEqual(253); expect(imageData.data[index2 + 0]).greaterThanOrEqual(253);
expect(imageData.data[index + 1]).lessThanOrEqual(2); expect(imageData.data[index2 + 1]).lessThanOrEqual(3);
expect(imageData.data[index + 2]).lessThanOrEqual(2); expect(imageData.data[index2 + 2]).lessThanOrEqual(3);
expect(imageData.data[index + 3]).greaterThanOrEqual(253); // Opaque (within error) expect(imageData.data[index2 + 3]).toBe(255); // Opaque
// Let's also check it's read correctly by Mediabunny // Let's also check it's read correctly by Mediabunny
using input = new Input({ using input = new Input({
@@ -169,7 +171,8 @@ test('Can encode transparent video', async () => {
firstSample.draw(probeContext, 0, 0); firstSample.draw(probeContext, 0, 0);
imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height); imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height);
expect(imageData.data[3]).lessThanOrEqual(2); // Transparent (within error)
expect(imageData.data[3]).toBe(0); // Transparent
}); });
test('Can encode video with alternating transparency', async () => { test('Can encode video with alternating transparency', async () => {