diff --git a/dev/convert.html b/dev/convert.html
index 36a2645..3778784 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -100,6 +100,7 @@
},
*/
video: () => ({
+ forceTranscode: true,
allowRotationMetadata: false,
//width: 720,
//frameRate: 30,
@@ -181,7 +182,7 @@
},
trim: {
start: 0,
- end: 4
+ //end: 4
},
});
console.log(conversion);
diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md
index 43d812a..8e11193 100644
--- a/docs/guide/converting-media-files.md
+++ b/docs/guide/converting-media-files.md
@@ -108,7 +108,7 @@ Sometimes, you may want to cancel an ongoing conversion process. For this, use t
await conversion.cancel(); // Resolves once the conversion is canceled
```
-This automatically frees up all resources used by the conversion process.
+This automatically frees up all resources used by the conversion process and will cause any ongoing call to `execute` to throw a `ConversionCanceledError`.
## Video options
diff --git a/package-lock.json b/package-lock.json
index 6c803d8..c56b8e8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
- "version": "1.27.4",
+ "version": "1.27.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
- "version": "1.27.4",
+ "version": "1.27.5",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7739,9 +7739,9 @@
}
},
"node_modules/mediabunny": {
- "version": "1.27.3",
- "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.27.3.tgz",
- "integrity": "sha512-hlzmgzMznp9DhA5fMJKS5yEAyfCUMxAc+DbSPxD4J1J2cYVl1L+pZLndkt5xLlD5aB5eHEnphHMW14ammMlUXg==",
+ "version": "1.27.4",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.27.4.tgz",
+ "integrity": "sha512-j69K0yiXYMHyb5g4XyhYjbz1VpllRVyyDGF5lqYTsrdF0XoDRoGReoRvon9lnENv5yY5A5u+09dnqISwXvS86Q==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12065,7 +12065,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
- "version": "1.27.4",
+ "version": "1.27.5",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
diff --git a/package.json b/package.json
index 94043f6..9b055b1 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
- "version": "1.27.4",
+ "version": "1.27.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 cc1be31..9f82bf0 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.27.4",
+ "version": "1.27.5",
"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/src/conversion.ts b/src/conversion.ts
index 862b6f0..a723d7b 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -822,7 +822,7 @@ export class Conversion {
}
if (this._canceled) {
- await new Promise(() => {}); // Never resolve
+ throw new ConversionCanceledError();
}
await this.output.finalize();
@@ -832,7 +832,10 @@ export class Conversion {
}
}
- /** Cancels the conversion process. Does nothing if the conversion is already complete. */
+ /**
+ * Cancels the conversion process, causing any ongoing `execute` call to throw a `ConversionCanceledError`.
+ * Does nothing if the conversion is already complete.
+ */
async cancel() {
if (this.output.state === 'finalizing' || this.output.state === 'finalized') {
return;
@@ -1159,6 +1162,7 @@ export class Conversion {
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
if (this._canceled) {
+ sample.close();
lastSample?.close();
return;
}
@@ -1441,6 +1445,7 @@ export class Conversion {
const sink = new AudioSampleSink(track);
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
if (this._canceled) {
+ sample.close();
return;
}
@@ -1551,6 +1556,7 @@ export class Conversion {
for await (const sample of iterator) {
if (this._canceled) {
+ sample.close();
return;
}
@@ -1589,6 +1595,19 @@ export class Conversion {
}
}
+/**
+ * Thrown when a conversion couldn't complete due to being canceled.
+ * @group Conversion
+ * @public
+ */
+export class ConversionCanceledError extends Error {
+ /** Creates a new {@link ConversionCanceledError}. */
+ constructor(message = 'Conversion has been canceled.') {
+ super(message);
+ this.name = 'ConversionCanceledError';
+ }
+}
+
const MAX_TIMESTAMP_GAP = 5;
/**
diff --git a/src/index.ts b/src/index.ts
index 6cd723f..4c5bee1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -190,6 +190,7 @@ export {
ConversionOptions,
ConversionVideoOptions,
ConversionAudioOptions,
+ ConversionCanceledError,
DiscardedTrack,
} from './conversion';
export {
diff --git a/src/input-format.ts b/src/input-format.ts
index 1748f27..db1e5b7 100644
--- a/src/input-format.ts
+++ b/src/input-format.ts
@@ -155,7 +155,7 @@ export class MatroskaInputFormat extends InputFormat {
}
const dataSize = readElementSize(headerSlice);
- if (dataSize === null) {
+ if (typeof dataSize !== 'number') {
return false; // Miss me with that shit
}
@@ -171,7 +171,7 @@ export class MatroskaInputFormat extends InputFormat {
const { id, size } = header;
const dataStartPos = dataSlice.filePos;
- if (size === null) return false;
+ if (size === undefined) return false;
switch (id) {
case EBMLId.EBMLVersion: {
diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts
index eb27c40..891adab 100644
--- a/src/matroska/ebml.ts
+++ b/src/matroska/ebml.ts
@@ -7,7 +7,7 @@
*/
import { MediaCodec } from '../codec';
-import { assertNever, textDecoder, textEncoder } from '../misc';
+import { assert, assertNever, textDecoder, textEncoder } from '../misc';
import { FileSlice, readBytes, Reader, readF32Be, readF64Be, readU8 } from '../reader';
import { Writer } from '../writer';
@@ -470,6 +470,10 @@ export const MIN_HEADER_SIZE = 2; // 1-byte ID and 1-byte size
export const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
export const readVarIntSize = (slice: FileSlice) => {
+ if (slice.remainingLength < 1) {
+ return null;
+ }
+
const firstByte = readU8(slice);
slice.skip(-1);
@@ -484,10 +488,19 @@ export const readVarIntSize = (slice: FileSlice) => {
mask >>= 1;
}
+ // Check if we have enough bytes to read the full varint
+ if (slice.remainingLength < width) {
+ return null;
+ }
+
return width;
};
export const readVarInt = (slice: FileSlice) => {
+ if (slice.remainingLength < 1) {
+ return null;
+ }
+
// Read the first byte to determine the width of the variable-length integer
const firstByte = readU8(slice);
@@ -503,6 +516,11 @@ export const readVarInt = (slice: FileSlice) => {
mask >>= 1;
}
+ if (slice.remainingLength < width - 1) {
+ // Not enough bytes
+ return null;
+ }
+
// First byte's value needs the marker bit cleared
let value = firstByte & (mask - 1);
@@ -563,39 +581,58 @@ export const readElementId = (slice: FileSlice) => {
return null;
}
+ if (slice.remainingLength < size) {
+ return null; // It don't fit
+ }
+
const id = readUnsignedInt(slice, size);
return id;
};
-export const readElementSize = (slice: FileSlice) => {
- let size: number | null = readU8(slice);
+/** Returns `undefined` to indicate the EBML undefined size. Returns `null` if the size couldn't be read. */
+export const readElementSize = (slice: FileSlice): number | undefined | null => {
+ // Need at least 1 byte to read the size
+ if (slice.remainingLength < 1) {
+ return null;
+ }
- if (size === 0xff) {
- size = null;
- } else {
- slice.skip(-1);
- size = readVarInt(slice);
+ const firstByte = readU8(slice);
- // In some (livestreamed) files, this is the value of the size field. While this technically is just a very
- // large number, it is intended to behave like the reserved size 0xFF, meaning the size is undefined. We
- // catch the number here. Note that it cannot be perfectly represented as a double, but the comparison works
- // nonetheless.
- // eslint-disable-next-line no-loss-of-precision
- if (size === 0x00ffffffffffffff) {
- size = null;
- }
+ if (firstByte === 0xff) {
+ return undefined;
+ }
+
+ slice.skip(-1);
+ const size = readVarInt(slice);
+
+ if (size === null) {
+ return null;
+ }
+
+ // In some (livestreamed) files, this is the value of the size field. While this technically is just a very
+ // large number, it is intended to behave like the reserved size 0xFF, meaning the size is undefined. We
+ // catch the number here. Note that it cannot be perfectly represented as a double, but the comparison works
+ // nonetheless.
+ // eslint-disable-next-line no-loss-of-precision
+ if (size === 0x00ffffffffffffff) {
+ return undefined;
}
return size;
};
export const readElementHeader = (slice: FileSlice) => {
+ assert(slice.remainingLength >= MIN_HEADER_SIZE);
+
const id = readElementId(slice);
if (id === null) {
return null;
}
const size = readElementSize(slice);
+ if (size === null) {
+ return null;
+ }
return { id, size };
};
@@ -720,8 +757,8 @@ export const CODEC_STRING_MAP: Partial> = {
'webvtt': 'S_TEXT/WEBVTT',
};
-export function assertDefinedSize(size: number | null): asserts size is number {
- if (size === null) {
+export function assertDefinedSize(size: number | undefined): asserts size is number {
+ if (size === undefined) {
throw new Error('Undefined element size is used in a place where it is not supported.');
}
};
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index db421b6..b373ff4 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -193,6 +193,7 @@ type InternalTrack = {
codecId: string | null;
codecPrivate: Uint8Array | null;
defaultDuration: number | null;
+ defaultDurationNs: number | null;
name: string | null;
languageCode: string;
decodingInstructions: DecodingInstruction[];
@@ -346,7 +347,7 @@ export class MatroskaDemuxer extends Demuxer {
} else if (id === EBMLId.Segment) { // Segment found!
await this.readSegment(dataStartPos, size);
- if (size === null) {
+ if (size === undefined) {
// Segment sizes can be undefined (common in livestreamed files), so assume this is the last
// and only segment
break;
@@ -364,7 +365,7 @@ export class MatroskaDemuxer extends Demuxer {
// doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if
// we find a top-level cluster, attribute it to the previous segment.
- if (size === null) {
+ if (size === undefined) {
// Just in case this is one of those weird sizeless clusters, let's do our best and still try to
// determine its size.
const nextElementPos = await searchForNextElementId(
@@ -389,7 +390,7 @@ export class MatroskaDemuxer extends Demuxer {
})();
}
- async readSegment(segmentDataStart: number, dataSize: number | null) {
+ async readSegment(segmentDataStart: number, dataSize: number | undefined) {
this.currentSegment = {
seekHeadSeen: false,
infoSeen: false,
@@ -406,7 +407,7 @@ export class MatroskaDemuxer extends Demuxer {
cuePoints: [],
dataStartPos: segmentDataStart,
- elementEndPos: dataSize === null
+ elementEndPos: dataSize === undefined
? null // Assume it goes until the end of the file
: segmentDataStart + dataSize,
clusterSeekStartPos: segmentDataStart,
@@ -483,7 +484,7 @@ export class MatroskaDemuxer extends Demuxer {
break; // Stop at the first cluster
}
- if (size === null) {
+ if (size === undefined) {
break;
} else {
currentPos = dataStartPos + size;
@@ -536,6 +537,13 @@ export class MatroskaDemuxer extends Demuxer {
this.currentSegment.timestampFactor = 1e9 / 1e6;
}
+ // Compute default duration for all tracks now that we have the timestamp factor
+ for (const track of this.currentSegment.tracks) {
+ if (track.defaultDurationNs !== null) {
+ track.defaultDuration = (this.currentSegment.timestampFactor * track.defaultDurationNs) / 1e9;
+ }
+ }
+
// Put default tracks first
this.currentSegment.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default));
@@ -606,7 +614,7 @@ export class MatroskaDemuxer extends Demuxer {
let size = elementHeader.size;
const dataStartPos = headerSlice.filePos;
- if (size === null) {
+ if (size === undefined) {
// The cluster's size is undefined (can happen in livestreamed files). We'd still like to know the size of
// it, so we have no other choice but to iterate over the EBML structure until we find an element at level
// 0 or 1, indicating the end of the cluster (all elements inside the cluster are at level 2).
@@ -908,9 +916,7 @@ export class MatroskaDemuxer extends Demuxer {
}
readContiguousElements(slice: FileSlice, stopIds?: number[]) {
- const startIndex = slice.filePos;
-
- while (slice.filePos - startIndex <= slice.length - MIN_HEADER_SIZE) {
+ while (slice.remainingLength >= MIN_HEADER_SIZE) {
const startPos = slice.filePos;
const foundElement = this.traverseElement(slice, stopIds);
@@ -996,6 +1002,7 @@ export class MatroskaDemuxer extends Demuxer {
codecId: null,
codecPrivate: null,
defaultDuration: null,
+ defaultDurationNs: null,
name: null,
languageCode: UNDETERMINED_LANGUAGE,
decodingInstructions: [],
@@ -1005,6 +1012,11 @@ export class MatroskaDemuxer extends Demuxer {
this.readContiguousElements(slice.slice(dataStartPos, size));
+ // Check if track was disabled during parsing (e.g., by FlagEnabled being 0)
+ if (!this.currentTrack) {
+ break;
+ }
+
if (this.currentTrack.decodingInstructions.some((instruction) => {
return instruction.data?.type !== 'decompress'
|| instruction.scope !== ContentEncodingScope.Block
@@ -1203,9 +1215,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.DefaultDuration: {
if (!this.currentTrack) break;
-
- this.currentTrack.defaultDuration
- = this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9;
+ this.currentTrack.defaultDurationNs = readUnsignedInt(slice, size);
}; break;
case EBMLId.Name: {
@@ -2222,7 +2232,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
}
- if (size === null) {
+ if (size === undefined) {
// 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.
diff --git a/src/media-source.ts b/src/media-source.ts
index 7b5cae6..ddf746b 100644
--- a/src/media-source.ts
+++ b/src/media-source.ts
@@ -134,12 +134,10 @@ export abstract class MediaSource {
/** @internal */
async _flushOrWaitForOngoingClose(forceClose: boolean) {
- if (this._closingPromise) {
- // Since closing also flushes, we don't want to do it twice
- return this._closingPromise;
- } else {
- return this._flushAndClose(forceClose);
- }
+ return this._closingPromise ??= (async () => {
+ await this._flushAndClose(forceClose);
+ this._closed = true;
+ })();
}
}
diff --git a/test/browser/media-sources.test.ts b/test/browser/media-sources.test.ts
new file mode 100644
index 0000000..cd218ba
--- /dev/null
+++ b/test/browser/media-sources.test.ts
@@ -0,0 +1,35 @@
+import { test } from 'vitest';
+import { Output } from '../../src/output.js';
+import { WebMOutputFormat } from '../../src/output-format.js';
+import { BufferTarget } from '../../src/target.js';
+import { VideoSampleSource } from '../../src/media-source.js';
+import { VideoSample } from '../../src/sample.js';
+import { QUALITY_MEDIUM } from '../../src/encode.js';
+
+test('VideoSampleSource.close() should be idempotent after finalize()', async () => {
+ const output = new Output({
+ format: new WebMOutputFormat(),
+ target: new BufferTarget(),
+ });
+
+ const videoSource = new VideoSampleSource({
+ codec: 'vp8',
+ bitrate: QUALITY_MEDIUM,
+ });
+
+ output.addVideoTrack(videoSource);
+ await output.start();
+
+ const canvas = new OffscreenCanvas(100, 100);
+ const ctx = canvas.getContext('2d')!;
+ ctx.fillStyle = 'red';
+ ctx.fillRect(0, 0, 100, 100);
+
+ const sample = new VideoSample(canvas, { timestamp: 0, duration: 1 / 30 });
+ await videoSource.add(sample);
+ sample.close();
+
+ await output.finalize();
+
+ videoSource.close(); // This previously threw
+});