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/docs/guide/output-formats.md b/docs/guide/output-formats.md
index a626734..e4831be 100644
--- a/docs/guide/output-formats.md
+++ b/docs/guide/output-formats.md
@@ -194,9 +194,12 @@ This format ensures [append-only writing](#append-only-writing).
The following options are available:
```ts
type OggOutputFormatOptions = {
+ maximumPageDuration?: number;
onPage?: (data: Uint8Array, position: number, source: MediaSource) => unknown;
};
```
+- `maximumPageDuration`\
+ The maximum duration in seconds of each Ogg page. Pages will be flushed early if adding another packet would cause the page to exceed this duration. This is useful for streaming contexts where more frequent page output is desired. By default, pages are only flushed when they exceed a certain size.
- `onPage`\
Will be called for each finalized Ogg page of the output file. The [media source](./media-sources) backing the page's track (logical bitstream) is also passed.
diff --git a/docs/index.md b/docs/index.md
index c380cec..118e8ef 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -128,6 +128,8 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/3709646', name: 'Rodrigo Belfiore', url: 'https://github.com/roprgm' },
{ image: 'https://avatars.githubusercontent.com/u/31102694', name: 'Aiden Liu', url: 'https://github.com/aidenlx' },
{ image: 'https://avatars.githubusercontent.com/u/41021374', name: 'arthco', url: 'https://github.com/arthtyagi' },
+ { image: 'https://avatars.githubusercontent.com/u/36898190', name: 'alakhpc', url: 'https://github.com/alakhpc' },
+ { image: 'https://avatars.githubusercontent.com/u/5907357', name: 'Harvey Zhao', url: 'https://github.com/zhw2590582' },
],
};
diff --git a/package-lock.json b/package-lock.json
index 62971f4..7d847a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
- "version": "1.27.3",
+ "version": "1.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
- "version": "1.27.3",
+ "version": "1.28.0",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7739,9 +7739,9 @@
}
},
"node_modules/mediabunny": {
- "version": "1.27.2",
- "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.27.2.tgz",
- "integrity": "sha512-0g/vmb6X0xmnzqW0U44weF9CmzcUFFQRHrjzoVpSL2KXuhWIWcW3u9wIcohHiTY78jfr0SauYKLxjOQReaMxXQ==",
+ "version": "1.27.6",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.27.6.tgz",
+ "integrity": "sha512-Y6QLjH5lAea9swaLcfXzcK/Xw1cFW4KDLvkm+0fUFcwfp8SEnAiMh8RSBtMBVADoE7hTCA9yn7JoYJv3VBd/yQ==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12065,7 +12065,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
- "version": "1.27.3",
+ "version": "1.28.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
diff --git a/package.json b/package.json
index daaf2d5..2946933 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
- "version": "1.27.3",
+ "version": "1.28.0",
"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 b416c9b..65c2f55 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.3",
+ "version": "1.28.0",
"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 d01f782..db0f55a 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 4c4563b..18e0b0c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -194,6 +194,7 @@ export {
ConversionOptions,
ConversionVideoOptions,
ConversionAudioOptions,
+ ConversionCanceledError,
DiscardedTrack,
} from './conversion';
export {
diff --git a/src/input-format.ts b/src/input-format.ts
index 5bf09fe..11108e5 100644
--- a/src/input-format.ts
+++ b/src/input-format.ts
@@ -156,7 +156,7 @@ export class MatroskaInputFormat extends InputFormat {
}
const dataSize = readElementSize(headerSlice);
- if (dataSize === null) {
+ if (typeof dataSize !== 'number') {
return false; // Miss me with that shit
}
@@ -172,7 +172,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 119841e..e764e81 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 8a63223..69b40df 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
@@ -1149,7 +1161,6 @@ export class MatroskaDemuxer extends Demuxer {
const enabled = readUnsignedInt(slice, size);
if (!enabled) {
- this.currentSegment!.tracks.pop();
this.currentTrack = null;
}
}; break;
@@ -1204,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: {
@@ -2223,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-sink.ts b/src/media-sink.ts
index 55cc91b..0f2243d 100644
--- a/src/media-sink.ts
+++ b/src/media-sink.ts
@@ -480,23 +480,13 @@ export abstract class BaseMediaSampleSink<
let currentPacket: EncodedPacket | null = keyPacket;
- let endPacket: EncodedPacket | undefined = undefined;
- if (endTimestamp < Infinity) {
- // When an end timestamp is set, we cannot simply use that for the packet iterator due to out-of-order
- // frames (B-frames). Instead, we'll need to keep decoding packets until we get a frame that exceeds
- // this end time. However, we can still put a bound on it: Since key frames are by definition never
- // out of order, we can stop at the first key frame after the end timestamp.
- const packet = await packetSink.getPacket(endTimestamp);
- const keyPacket = !packet
- ? null
- : packet.type === 'key' && packet.timestamp === endTimestamp
- ? packet
- : await packetSink.getNextKeyPacket(packet, { verifyKeyPackets: true });
-
- if (keyPacket) {
- endPacket = keyPacket;
- }
- }
+ // B-frames make it exceedingly difficult to properly define an upper bound for packet iteration if an end
+ // timestamp is set, so we just don't do it. The case that makes it especially tricky is when the frames
+ // following a key frame have a lower timestamp than the keyframe; something that quite frequently happens
+ // in HEVC streams. The price to pay for not upper-bounding the packet iterator is a slight increase in
+ // decoder work at the end of the range, but the added correctness and reliability makes this tradeoff worth
+ // it.
+ const endPacket = undefined;
const packets = packetSink.packets(keyPacket ?? undefined, endPacket);
await packets.next(); // Skip the start packet as we already have it
diff --git a/src/media-source.ts b/src/media-source.ts
index 1145571..362733c 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/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index be489c1..2dec7de 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -322,6 +322,10 @@ export class OggDemuxer extends Demuxer {
}
const totalPacketSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ if (totalPacketSize === 0) {
+ return null; // Invalid packet, treat it as end of stream
+ }
+
const packetData = new Uint8Array(totalPacketSize);
let offset = 0;
diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts
index a854ab5..7bceb75 100644
--- a/src/ogg/ogg-muxer.ts
+++ b/src/ogg/ogg-muxer.ts
@@ -47,12 +47,13 @@ type OggTrackData = {
currentPageData: Uint8Array[];
currentPageSize: number;
currentPageStartsWithFreshPacket: boolean;
+ currentPageStartTimestampInSamples: number;
};
type Packet = {
data: Uint8Array;
- endGranulePosition: number;
- timestamp: number;
+ timestampInSamples: number;
+ durationInSamples: number;
forcePageFlush: boolean;
};
@@ -132,6 +133,7 @@ export class OggMuxer extends Muxer {
currentPageData: [],
currentPageSize: 27,
currentPageStartsWithFreshPacket: true,
+ currentPageStartTimestampInSamples: 0,
};
this.queueHeaderPackets(newTrackData, meta);
@@ -199,18 +201,18 @@ export class OggMuxer extends Muxer {
trackData.packetQueue.push({
data: identificationHeader,
- endGranulePosition: 0,
- timestamp: 0,
+ timestampInSamples: 0,
+ durationInSamples: 0,
forcePageFlush: true,
}, {
data: commentHeader,
- endGranulePosition: 0,
- timestamp: 0,
+ timestampInSamples: 0,
+ durationInSamples: 0,
forcePageFlush: false,
}, {
data: setupHeader,
- endGranulePosition: 0,
- timestamp: 0,
+ timestampInSamples: 0,
+ durationInSamples: 0,
forcePageFlush: true, // The last header packet must flush the page
});
@@ -239,13 +241,13 @@ export class OggMuxer extends Muxer {
trackData.packetQueue.push({
data: identificationHeader,
- endGranulePosition: 0,
- timestamp: 0,
+ timestampInSamples: 0,
+ durationInSamples: 0,
forcePageFlush: true,
}, {
data: commentHeader,
- endGranulePosition: 0,
- timestamp: 0,
+ timestampInSamples: 0,
+ durationInSamples: 0,
forcePageFlush: true, // The last header packet must flush the page
});
@@ -275,8 +277,8 @@ export class OggMuxer extends Muxer {
trackData.packetQueue.push({
data: packet.data,
- endGranulePosition: trackData.currentTimestampInSamples,
- timestamp: currentTimestampInSamples / trackData.internalSampleRate,
+ timestampInSamples: currentTimestampInSamples,
+ durationInSamples,
forcePageFlush: false,
});
@@ -338,10 +340,10 @@ export class OggMuxer extends Muxer {
if (
trackData.packetQueue.length > 0
- && trackData.packetQueue[0]!.timestamp < minTimestamp
+ && trackData.packetQueue[0]!.timestampInSamples < minTimestamp
) {
trackWithMinTimestamp = trackData;
- minTimestamp = trackData.packetQueue[0]!.timestamp;
+ minTimestamp = trackData.packetQueue[0]!.timestampInSamples;
}
}
@@ -361,6 +363,20 @@ export class OggMuxer extends Muxer {
}
writePacket(trackData: OggTrackData, packet: Packet, isFinalPacket: boolean) {
+ const packetEndTimestampInSamples = packet.timestampInSamples + packet.durationInSamples;
+
+ if (this.format._options.maximumPageDuration !== undefined) {
+ const maxDurationInSamples = this.format._options.maximumPageDuration * trackData.internalSampleRate;
+
+ if (
+ trackData.currentLacingValues.length > 0
+ && packetEndTimestampInSamples - trackData.currentPageStartTimestampInSamples > maxDurationInSamples
+ ) {
+ // Flush the current page early to avoid exceeding the maximum page duration
+ this.writePage(trackData, false);
+ }
+ }
+
let remainingLength = packet.data.length;
let dataStartOffset = 0;
let dataOffset = 0;
@@ -401,7 +417,7 @@ export class OggMuxer extends Muxer {
const slice = packet.data.subarray(dataStartOffset);
trackData.currentPageData.push(slice);
trackData.currentPageSize += slice.length;
- trackData.currentGranulePosition = packet.endGranulePosition;
+ trackData.currentGranulePosition = packetEndTimestampInSamples;
if (trackData.currentPageSize >= PAGE_SIZE_TARGET || packet.forcePageFlush) {
this.writePage(trackData, isFinalPacket);
@@ -452,6 +468,7 @@ export class OggMuxer extends Muxer {
trackData.currentPageData.length = 0;
trackData.currentPageSize = 27;
trackData.currentPageStartsWithFreshPacket = true;
+ trackData.currentPageStartTimestampInSamples = trackData.currentGranulePosition;
if (this.format._options.onPage) {
this.writer.startTrackingWrites();
diff --git a/src/output-format.ts b/src/output-format.ts
index 99c428b..d9dee08 100644
--- a/src/output-format.ts
+++ b/src/output-format.ts
@@ -730,6 +730,12 @@ export class WavOutputFormat extends OutputFormat {
* @public
*/
export type OggOutputFormatOptions = {
+ /**
+ * The maximum duration of each Ogg page, in seconds. This is useful for streaming contexts where more frequent page
+ * output is desired. By default, pages are only flushed when they exceed a certain size.
+ */
+ maximumPageDuration?: number;
+
/**
* Will be called for each Ogg page that is written.
*
@@ -754,6 +760,12 @@ export class OggOutputFormat extends OutputFormat {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
+ if (
+ options.maximumPageDuration !== undefined
+ && (!Number.isFinite(options.maximumPageDuration) || options.maximumPageDuration <= 0)
+ ) {
+ throw new TypeError('options.maximumPageDuration, when provided, must be a positive number.');
+ }
if (options.onPage !== undefined && typeof options.onPage !== 'function') {
throw new TypeError('options.onPage, when provided, must be a function.');
}
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
+});
diff --git a/test/browser/ogg-demuxer.test.ts b/test/browser/ogg-demuxer.test.ts
new file mode 100644
index 0000000..388da44
--- /dev/null
+++ b/test/browser/ogg-demuxer.test.ts
@@ -0,0 +1,26 @@
+import { expect, test } from 'vitest';
+import { Input } from '../../src/input.js';
+import { UrlSource } from '../../src/source.js';
+import { ALL_FORMATS } from '../../src/input-format.js';
+import { AudioBufferSink } from '../../src/media-sink.js';
+import { assert } from '../../src/misc.js';
+
+// VLC creates OGG files with an empty EOS page, which previously caused decoding errors
+test('can decode OGG Vorbis file with empty EOS page', async () => {
+ using input = new Input({
+ source: new UrlSource('/vorbis-eos.ogg'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = await input.getPrimaryAudioTrack();
+ assert(track);
+
+ const sink = new AudioBufferSink(track);
+ const buffers: AudioBuffer[] = [];
+
+ for await (const { buffer } of sink.buffers(4, 10)) {
+ buffers.push(buffer);
+ }
+
+ expect(buffers.length).toBeGreaterThan(0);
+});
diff --git a/test/browser/ogg-muxer.test.ts b/test/browser/ogg-muxer.test.ts
new file mode 100644
index 0000000..ea982e1
--- /dev/null
+++ b/test/browser/ogg-muxer.test.ts
@@ -0,0 +1,57 @@
+import { expect, test } from 'vitest';
+import { Output } from '../../src/output.js';
+import { OggOutputFormat } from '../../src/output-format.js';
+import { NullTarget } from '../../src/target.js';
+import { AudioBufferSource } from '../../src/media-source.js';
+
+test('maximumPageDuration option', async () => {
+ const sampleRate = 48000;
+ const durationSeconds = 2;
+ const audioBuffer = new AudioBuffer({ numberOfChannels: 1, length: sampleRate * durationSeconds, sampleRate });
+
+ // First, create an Ogg file without the maximumPageDuration option
+ let pageCountWithoutOption = 0;
+ {
+ const output = new Output({
+ format: new OggOutputFormat({
+ onPage: () => {
+ pageCountWithoutOption++;
+ },
+ }),
+ target: new NullTarget(),
+ });
+
+ const audioSource = new AudioBufferSource({ codec: 'opus', bitrate: 64000 });
+ output.addAudioTrack(audioSource);
+
+ await output.start();
+ await audioSource.add(audioBuffer);
+ audioSource.close();
+ await output.finalize();
+ }
+
+ // Then, create an Ogg file with maximumPageDuration set to 0.1 seconds
+ let pageCountWithOption = 0;
+ {
+ const output = new Output({
+ format: new OggOutputFormat({
+ maximumPageDuration: 0.1,
+ onPage: () => {
+ pageCountWithOption++;
+ },
+ }),
+ target: new NullTarget(),
+ });
+
+ const audioSource = new AudioBufferSource({ codec: 'opus', bitrate: 64000 });
+ output.addAudioTrack(audioSource);
+
+ await output.start();
+ await audioSource.add(audioBuffer);
+ audioSource.close();
+ await output.finalize();
+ }
+
+ expect(pageCountWithoutOption).toBe(3);
+ expect(pageCountWithOption).toBe(23); // It created more pages
+});
diff --git a/test/public/vorbis-eos.ogg b/test/public/vorbis-eos.ogg
new file mode 100644
index 0000000..9a32d4f
Binary files /dev/null and b/test/public/vorbis-eos.ogg differ