Compare commits

..
18 Commits
Author SHA1 Message Date
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
12 changed files with 130 additions and 71 deletions
+1 -1
View File
@@ -200,7 +200,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,
+22 -3
View File
@@ -14,6 +14,24 @@
source: new Mediabunny.BlobSource(file), source: new Mediabunny.BlobSource(file),
}); });
const videoTrack = await input.getPrimaryVideoTrack();
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 +40,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 +57,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 +66,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();
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"version": "1.24.0", "version": "1.24.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mediabunny", "name": "mediabunny",
"version": "1.24.0", "version": "1.24.3",
"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.2",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.23.0.tgz", "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.2.tgz",
"integrity": "sha512-mWdhhdRquePfTgZ+18DLCFWmqwO6oGP/YudmRU8Puh+whMUJ3VSb9KMAHPl2utim66ixKdZw+DbmdqBfBxRp0A==", "integrity": "sha512-+H2Jha7D8C92F1fysdN+nmBBAOgVV+pNv/Pxc+c//qo2E/idnv8MsZrft7VJsFE5WjK30ESkwW5ezM0oceFIkg==",
"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.3",
"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.3",
"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.3",
"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",
+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
+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);
+23 -18
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) {
@@ -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;
}
} }
} }
traverseElement(slice: FileSlice): boolean { return slice.filePos;
}
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,11 +2202,8 @@ 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 // Search for the next element at level 0 or 1
const nextElementPos = await searchForNextElementId( const nextElementPos = await searchForNextElementId(
demuxer.reader, demuxer.reader,
@@ -2207,10 +2216,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
} }
const endPos = dataStartPos + size; const endPos = dataStartPos + size;
if (segment.elementEndPos !== null && endPos > segment.elementEndPos - MIN_HEADER_SIZE) { if (segment.elementEndPos === null) {
// No more elements fit in this segment
break;
} else {
// Check the next element. If it's a new segment, we know this segment ends here. The new // 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 // segment is just ignored, since we're likely in a livestreamed file and thus only care about
// the first segment. // the first segment.
@@ -2221,13 +2227,12 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
const elementId = readElementId(slice); const elementId = readElementId(slice);
if (elementId === EBMLId.Segment) { if (elementId === EBMLId.Segment) {
segment.elementEndPos = endPos; segment.elementEndPos = endPos; // We now know the segment's size
break; break;
} }
} }
}
currentPos = dataStartPos + size; currentPos = endPos;
} }
// Catch faulty cue points // Catch faulty cue points
+8 -4
View File
@@ -1159,7 +1159,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;
@@ -1978,17 +1978,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 +2028,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',
+30 -6
View File
@@ -234,11 +234,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 +465,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 +542,10 @@ export class UrlSource extends Source {
} }
} }
if (worker.aborted) {
break;
}
const { done, value } = readResult; const { done, value } = readResult;
if (done) { if (done) {
@@ -552,6 +564,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 +812,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 +853,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 +1444,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 +1500,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;
+9 -7
View File
@@ -139,17 +139,18 @@ 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 index1 = (100 + 100 * probeCanvas.width) * 4;
expect(imageData.data[index1 + 3]).toBe(0); // Transparent
const pos = { x: 300, y: 300 }; // Dead center in the red square const pos = { x: 300, y: 300 }; // Dead center in the red square
const index = (pos.x + pos.y * probeCanvas.width) * 4; const index2 = (pos.x + pos.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(2);
expect(imageData.data[index + 2]).lessThanOrEqual(2); expect(imageData.data[index2 + 2]).lessThanOrEqual(2);
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 +170,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 () => {