Compare commits

...
34 Commits
Author SHA1 Message Date
Vanilagy 868578685d Shoulda bumped minor, oops 2026-01-14 09:22:43 +01:00
Vanilagy e1478f8ebd Bump patch 2026-01-14 09:21:51 +01:00
David P.andGitHub 8aac07eb61 Merge pull request #279 from samohovets/bugfix/vorbis-eos
Fix OGG Vorbis decoding error for files with empty EOS pages
2026-01-13 17:36:09 +01:00
Vanilagy 267c09abf8 Return null earlier on 2026-01-13 17:34:33 +01:00
Igor Samokhovets a89101ff40 Fix OGG Vorbis decoding error for files with empty EOS pages (VLC-encoded) 2026-01-13 13:45:43 +01:00
Vanilagy 2ab1be3834 Add maximumPageDuration Ogg muxer option (closes #277) 2026-01-13 13:37:34 +01:00
Vanilagy 571fbb3198 Fix too many tracks being removed 2026-01-12 16:47:31 +01:00
Vanilagy 32d8b1544d Bump patch 2026-01-12 16:15:35 +01:00
Vanilagy b8f104d8b4 Fix disabled Matroska tracks crashing demuxer (closes #276) 2026-01-12 16:11:23 +01:00
Vanilagy 7db0583058 Fix samples not being closed sometimes in conversion (closes #272) 2026-01-12 15:20:10 +01:00
Vanilagy 24af125415 Make Conversion.cancel() cause execute() to throw (closes #271) 2026-01-12 15:13:00 +01:00
Vanilagy 78e78e5b4f Make _flushOrWaitForOngoingClose set closingPromise (fixes #270) 2026-01-12 14:36:51 +01:00
David P.andGitHub 1d774b5fbb Merge pull request #274 from alakhpc/alakhpc/fix-out-of-bounds
fix(matroska): handle insufficient bytes when reading EBML element headers
2026-01-12 14:09:06 +01:00
Vanilagy d6b518b9fd Fix thing 2026-01-12 14:07:43 +01:00
David P.andGitHub 4f5f30bc03 Merge pull request #273 from alakhpc/alakhpc/defer-defaultduration
fix(matroska): defer DefaultDuration calculation until TimestampScale is known
2026-01-12 13:48:41 +01:00
alakhpcandGitHub f6ca3ece50 Merge branch 'main' into alakhpc/defer-defaultduration 2026-01-12 06:49:36 -05:00
Vanilagy 7b036fe8ac Fix some things 2026-01-12 11:59:37 +01:00
Vanilagy 2bb84fd35b Add new sponsors 2026-01-12 10:32:43 +01:00
Alakh Chandarana 91e7812fb2 fix 2026-01-11 16:51:28 -05:00
Alakh Chandarana d29f4f33b5 implement fix 2026-01-11 04:15:03 -05:00
Vanilagy 7580935639 Bump patch 2026-01-06 13:36:29 +01:00
Vanilagy 53824536fa Remove packet iteration upper bound for mediaSamplesInRange (fixes #269) 2026-01-06 13:35:52 +01:00
Vanilagy 8f43086e3d Make allocationSize and copyTo always throw if internal format is null (closes #267) 2026-01-05 11:07:32 +01:00
Vanilagy 9343607fe1 Add deil sponsor 2026-01-02 12:33:35 +01:00
Vanilagy f877fabc60 Bump patch 2025-12-30 16:20:52 +01:00
Vanilagy a2115b1de7 Add workaround for WebKit AudioData.copyTo bug (fixes #260) 2025-12-30 16:10:53 +01:00
Vanilagy 8d46d50c39 Document that ADTS is append-only 2025-12-30 14:11:32 +01:00
Vanilagy 19219fc1ce Add Polotno sponsor 2025-12-29 20:28:13 +01:00
Vanilagy 05070f7c88 Bump patch 2025-12-24 14:17:46 +01:00
Vanilagy 736cc50fa7 Fix aborted UrlSource not aborting request 2025-12-24 14:15:31 +01:00
Vanilagy 9ba63f5d95 Add Roman Rädle 2025-12-23 20:16:43 +01:00
Vanilagy 17c6bff1dc Bump patch 2025-12-19 16:05:46 +01:00
Vanilagy c86af052f5 Add test for Annex B to length-prefixed conversion 2025-12-19 16:04:27 +01:00
Vanilagy 831e838f74 Implement workaround for https://issues.chromium.org/issues/470109459 2025-12-19 13:06:11 +01:00
28 changed files with 595 additions and 185 deletions
+2 -1
View File
@@ -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);
+1 -1
View File
@@ -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
+7
View File
@@ -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.
@@ -272,6 +275,10 @@ const output = new Output({
});
```
::: info
This format ensures [append-only writing](#append-only-writing).
:::
The following options are available:
```ts
type AdtsOutputFormatOptions = {
+5
View File
@@ -106,6 +106,8 @@ const sponsors = {
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/82552321', name: 'Polotno', url: 'https://github.com/polotno-project' },
{ image: 'https://avatars.githubusercontent.com/u/489051', name: 'Roman Rädle', url: 'https://github.com/raedle' },
{ 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/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
@@ -114,6 +116,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/139718', name: 'Anton Kosiakin', url: 'https://github.com/deil' },
{ image: 'https://avatars.githubusercontent.com/u/56988069', name: 'SyhabouthAlex', url: 'https://github.com/SyhabouthAlex' },
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
@@ -125,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' },
],
};
</script>
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.27.0",
"version": "1.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.27.0",
"version": "1.28.0",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7739,9 +7739,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.26.0.tgz",
"integrity": "sha512-0duZPn/vVpx+mKjOn3PNb/tBrtKUoq4n5+uO1JwyQm3cdaKYy7tVTEvTpXJxbpQ/CdnHn5YMVRg8mwi7fIgSYA==",
"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.0",
"version": "1.28.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.27.0",
"version": "1.28.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.27.0",
"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",
+57 -25
View File
@@ -24,6 +24,7 @@ import {
toUint8Array,
getChromiumVersion,
isChromium,
setUint24,
} from './misc';
import { PacketType } from './packet';
import { MetadataTags } from './metadata';
@@ -161,38 +162,54 @@ const removeEmulationPreventionBytes = (data: Uint8Array) => {
return new Uint8Array(result);
};
/** Converts an AVC packet in Annex B format to length-prefixed format. */
export const transformAnnexBToLengthPrefixed = (packetData: Uint8Array) => {
const NAL_UNIT_LENGTH_SIZE = 4;
const ANNEX_B_START_CODE = new Uint8Array([0, 0, 0, 1]);
const nalUnits = findNalUnitsInAnnexB(packetData);
if (nalUnits.length === 0) {
// If no NAL units were found, it's not valid Annex B data
return null;
}
let totalSize = 0;
for (const nalUnit of nalUnits) {
totalSize += NAL_UNIT_LENGTH_SIZE + nalUnit.byteLength;
}
const avccData = new Uint8Array(totalSize);
const dataView = new DataView(avccData.buffer);
export const concatNalUnitsInAnnexB = (nalUnits: Uint8Array[]) => {
const totalLength = nalUnits.reduce((a, b) => a + ANNEX_B_START_CODE.byteLength + b.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
// Write each NAL unit with its length prefix
for (const nalUnit of nalUnits) {
const length = nalUnit.byteLength;
result.set(ANNEX_B_START_CODE, offset);
offset += ANNEX_B_START_CODE.byteLength;
dataView.setUint32(offset, length, false);
offset += 4;
avccData.set(nalUnit, offset);
result.set(nalUnit, offset);
offset += nalUnit.byteLength;
}
return avccData;
return result;
};
export const concatNalUnitsInLengthPrefixed = (nalUnits: Uint8Array[], lengthSize: 1 | 2 | 3 | 4) => {
const totalLength = nalUnits.reduce((a, b) => a + lengthSize + b.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const nalUnit of nalUnits) {
const dataView = new DataView(result.buffer, result.byteOffset, result.byteLength);
switch (lengthSize) {
case 1:
dataView.setUint8(offset, nalUnit.byteLength);
break;
case 2:
dataView.setUint16(offset, nalUnit.byteLength, false);
break;
case 3:
setUint24(dataView, offset, nalUnit.byteLength, false);
break;
case 4:
dataView.setUint32(offset, nalUnit.byteLength, false);
break;
}
offset += lengthSize;
result.set(nalUnit, offset);
offset += nalUnit.byteLength;
}
return result;
};
// Data specified in ISO 14496-15
@@ -227,7 +244,22 @@ export const extractAvcNalUnits = (packetData: Uint8Array, decoderConfig: VideoD
}
};
const extractNalUnitTypeForAvc = (data: Uint8Array) => {
export const concatAvcNalUnits = (nalUnits: Uint8Array[], decoderConfig: VideoDecoderConfig) => {
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[4]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
return concatNalUnitsInLengthPrefixed(nalUnits, lengthSize);
} else {
// Stream is in Annex B format
return concatNalUnitsInAnnexB(nalUnits);
}
};
export const extractNalUnitTypeForAvc = (data: Uint8Array) => {
return data[0]! & 0x1F;
};
+21 -2
View File
@@ -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;
/**
+1
View File
@@ -190,6 +190,7 @@ export {
ConversionOptions,
ConversionVideoOptions,
ConversionAudioOptions,
ConversionCanceledError,
DiscardedTrack,
} from './conversion';
export {
+2 -2
View File
@@ -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: {
+8 -4
View File
@@ -25,11 +25,12 @@ import {
import { BufferTarget } from '../target';
import { EncodedPacket, PacketType } from '../packet';
import {
concatNalUnitsInLengthPrefixed,
extractAvcDecoderConfigurationRecord,
extractHevcDecoderConfigurationRecord,
findNalUnitsInAnnexB,
serializeAvcDecoderConfigurationRecord,
serializeHevcDecoderConfigurationRecord,
transformAnnexBToLengthPrefixed,
} from '../codec-data';
import { buildIsobmffMimeType } from './isobmff-misc';
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
@@ -464,15 +465,18 @@ export class IsobmffMuxer extends Muxer {
let packetData = packet.data;
if (trackData.info.requiresAnnexBTransformation) {
const transformedData = transformAnnexBToLengthPrefixed(packetData);
if (!transformedData) {
const nalUnits = findNalUnitsInAnnexB(packetData);
if (nalUnits.length === 0) {
// It's not valid Annex B data
throw new Error(
'Failed to transform packet data. Make sure all packets are provided in Annex B format, as'
+ ' specified in ITU-T-REC-H.264 and ITU-T-REC-H.265.',
);
}
packetData = transformedData;
// We don't strip things like SPS or PPS NALUs here, mainly because they can also appear in the middle
// of a stream and potentially modify the parameters of it. So, let's just leave them in to be sure.
packetData = concatNalUnitsInLengthPrefixed(nalUnits, 4);
}
const timestamp = this.validateAndNormalizeTimestamp(
+55 -18
View File
@@ -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<Record<MediaCodec, string>> = {
'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.');
}
};
+23 -14
View File
@@ -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
@@ -1148,7 +1160,6 @@ export class MatroskaDemuxer extends Demuxer {
const enabled = readUnsignedInt(slice, size);
if (!enabled) {
this.currentSegment!.tracks.pop();
this.currentTrack = null;
}
}; break;
@@ -1203,9 +1214,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 +2231,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.
+25 -19
View File
@@ -8,9 +8,12 @@
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
import {
concatAvcNalUnits,
deserializeAvcDecoderConfigurationRecord,
determineVideoPacketType,
extractAvcNalUnits,
extractHevcNalUnits,
extractNalUnitTypeForAvc,
extractNalUnitTypeForHevc,
HevcNalUnitType,
parseAvcSps,
@@ -477,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
@@ -928,8 +921,6 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
this.raslSkipped = true;
}
this.currentPacketIndex++;
if (this.customDecoder) {
this.customDecoderQueueSize++;
void this.customDecoderCallSerializer
@@ -942,9 +933,24 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
}
// Workaround for https://issues.chromium.org/issues/470109459
if (isChromium() && this.currentPacketIndex === 0 && this.codec === 'avc') {
const nalUnits = extractAvcNalUnits(packet.data, this.decoderConfig);
const filteredNalUnits = nalUnits.filter((x) => {
const type = extractNalUnitTypeForAvc(x);
// These trip up Chromium's key frame detection, so let's strip them
return !(type >= 20 && type <= 31);
});
const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
}
this.decoder.decode(packet.toEncodedVideoChunk());
this.decodeAlphaData(packet);
}
this.currentPacketIndex++;
}
decodeAlphaData(packet: EncodedPacket) {
+4 -6
View File
@@ -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;
})();
}
}
+4
View File
@@ -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;
+34 -17
View File
@@ -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();
+12
View File
@@ -725,6 +725,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.
*
@@ -749,6 +755,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.');
}
+141 -61
View File
@@ -18,6 +18,7 @@ import {
isFirefox,
polyfillSymbolDispose,
assertNever,
isWebKit,
} from './misc';
polyfillSymbolDispose();
@@ -445,8 +446,7 @@ export class VideoSample implements Disposable {
}
/**
* Returns the number of bytes required to hold this video sample's pixel data. Throws if `format` is `null`;
* specify an explicit RGB format in the options in this case.
* Returns the number of bytes required to hold this video sample's pixel data. Throws if `format` is `null`.
*/
allocationSize(options: VideoFrameCopyToOptions = {}): number {
validateVideoFrameCopyToOptions(options);
@@ -454,11 +454,10 @@ export class VideoSample implements Disposable {
if (this._closed) {
throw new Error('VideoSample is closed.');
}
if ((options.format ?? this.format) === null) {
throw new Error(
'Cannot get allocation size when format is null. Please manually provide an RGB pixel format in the'
+ ' options instead.',
);
if (this.format === null) {
// https://github.com/Vanilagy/mediabunny/issues/267
// https://github.com/w3c/webcodecs/issues/920
throw new Error('Cannot get allocation size when format is null. Sorry!');
}
assert(this._data !== null);
@@ -471,6 +470,7 @@ export class VideoSample implements Disposable {
|| options.rect
) {
// Temporarily convert to VideoFrame to get it done
// TODO: Compute this directly without needing to go through VideoFrame
const videoFrame = this.toVideoFrame();
const size = videoFrame.allocationSize(options);
videoFrame.close();
@@ -489,8 +489,7 @@ export class VideoSample implements Disposable {
}
/**
* Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. Throws if `format` is `null`;
* specify an explicit RGB format in the options in this case.
* Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. Throws if `format` is `null`.
* @returns The byte layout of the planes of the copied data.
*/
async copyTo(destination: AllowSharedBufferSource, options: VideoFrameCopyToOptions = {}): Promise<PlaneLayout[]> {
@@ -502,11 +501,8 @@ export class VideoSample implements Disposable {
if (this._closed) {
throw new Error('VideoSample is closed.');
}
if ((options.format ?? this.format) === null) {
throw new Error(
'Cannot copy video sample data when format is null. Please manually provide an RGB pixel format in the'
+ ' options instead.',
);
if (this.format === null) {
throw new Error('Cannot copy video sample data when format is null. Sorry!');
}
assert(this._data !== null);
@@ -519,6 +515,7 @@ export class VideoSample implements Disposable {
|| options.rect
) {
// Temporarily convert to VideoFrame to get it done
// TODO: Do this directly without needing to go through VideoFrame
const videoFrame = this.toVideoFrame();
const layout = await videoFrame.copyTo(destination, options);
videoFrame.close();
@@ -1416,6 +1413,7 @@ export class AudioSample implements Disposable {
const { planeIndex, format, frameCount: optFrameCount, frameOffset: optFrameOffset } = options;
const srcFormat = this.format;
const destFormat = format ?? this.format;
if (!destFormat) throw new Error('Destination format not determined');
@@ -1450,58 +1448,31 @@ export class AudioSample implements Disposable {
const writeFn = getWriteFunction(destFormat);
if (isAudioData(this._data)) {
if (destIsPlanar) {
if (destFormat === 'f32-planar') {
// Simple, since the browser must support f32-planar, we can just delegate here
this._data.copyTo(destination, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: 'f32-planar',
});
} else {
// Allocate temporary buffer for f32-planar data
const tempBuffer = new ArrayBuffer(copyFrameCount * 4);
const tempArray = new Float32Array(tempBuffer);
this._data.copyTo(tempArray, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: 'f32-planar',
});
// Convert each f32 sample to destination format
const tempView = new DataView(tempBuffer);
for (let i = 0; i < copyFrameCount; i++) {
const destOffset = i * destBytesPerSample;
const sample = tempView.getFloat32(i * 4, true);
writeFn(destView, destOffset, sample);
}
}
if (isWebKit() && numChannels > 2 && destFormat !== srcFormat) {
// WebKit bug workaround
doAudioDataCopyToWebKitWorkaround(
this._data,
destView,
srcFormat,
destFormat,
numChannels,
planeIndex,
frameOffset,
copyFrameCount,
);
} else {
// Destination is interleaved.
// Allocate a temporary Float32Array to hold one channel's worth of data.
const numCh = numChannels;
const temp = new Float32Array(copyFrameCount);
for (let ch = 0; ch < numCh; ch++) {
this._data.copyTo(temp, {
planeIndex: ch,
frameOffset,
frameCount: copyFrameCount,
format: 'f32-planar',
});
for (let i = 0; i < copyFrameCount; i++) {
const destIndex = i * numCh + ch;
const destOffset = destIndex * destBytesPerSample;
writeFn(destView, destOffset, temp[i]!);
}
}
// Per spec, only f32-planar conversion must be supported, but in practice, all browsers support all
// destination formats, so let's just delegate here:
this._data.copyTo(destination, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: destFormat,
});
}
} else {
const uint8Data = this._data;
const srcView = toDataView(uint8Data);
const srcFormat = this.format;
const readFn = getReadFunction(srcFormat);
const srcBytesPerSample = getBytesPerSample(srcFormat);
const srcIsPlanar = formatIsPlanar(srcFormat);
@@ -1844,3 +1815,112 @@ const getWriteFunction = (format: AudioSampleFormat): (view: DataView, offset: n
const isAudioData = (x: unknown): x is AudioData => {
return typeof AudioData !== 'undefined' && x instanceof AudioData;
};
/**
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format
* crashes the tab when there are more than 2 channels. This function works around that by always
* copying with the source format and then manually converting to the destination format.
*
* See https://bugs.webkit.org/show_bug.cgi?id=302521.
*/
const doAudioDataCopyToWebKitWorkaround = (
audioData: AudioData,
destView: DataView,
srcFormat: AudioSampleFormat,
destFormat: AudioSampleFormat,
numChannels: number,
planeIndex: number,
frameOffset: number,
copyFrameCount: number,
) => {
const readFn = getReadFunction(srcFormat);
const writeFn = getWriteFunction(destFormat);
const srcBytesPerSample = getBytesPerSample(srcFormat);
const destBytesPerSample = getBytesPerSample(destFormat);
const srcIsPlanar = formatIsPlanar(srcFormat);
const destIsPlanar = formatIsPlanar(destFormat);
if (destIsPlanar) {
if (srcIsPlanar) {
// src planar -> dest planar: copy single plane and convert
const data = new ArrayBuffer(copyFrameCount * srcBytesPerSample);
const dataView = toDataView(data);
audioData.copyTo(data, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
const srcOffset = i * srcBytesPerSample;
const destOffset = i * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
} else {
// src interleaved -> dest planar: copy all interleaved data, extract one channel
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
const dataView = toDataView(data);
audioData.copyTo(data, {
planeIndex: 0,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
const srcOffset = (i * numChannels + planeIndex) * srcBytesPerSample;
const destOffset = i * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
}
} else {
if (srcIsPlanar) {
// src planar -> dest interleaved: copy each plane and interleave
const planeSize = copyFrameCount * srcBytesPerSample;
const data = new ArrayBuffer(planeSize);
const dataView = toDataView(data);
for (let ch = 0; ch < numChannels; ch++) {
audioData.copyTo(data, {
planeIndex: ch,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
const srcOffset = i * srcBytesPerSample;
const destOffset = (i * numChannels + ch) * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
}
} else {
// src interleaved -> dest interleaved: copy all and convert
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
const dataView = toDataView(data);
audioData.copyTo(data, {
planeIndex: 0,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const idx = i * numChannels + ch;
const srcOffset = idx * srcBytesPerSample;
const destOffset = idx * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
}
}
}
};
+6 -7
View File
@@ -263,6 +263,11 @@ export class BlobSource extends Source {
}
worker.running = false;
if (worker.aborted) {
// MDN: "Calling this method signals a loss of interest in the stream by a consumer."
await reader?.cancel();
}
}
/** @internal */
@@ -556,7 +561,7 @@ export class UrlSource extends Source {
}
if (worker.aborted) {
break;
continue; // Cleanup happens in next iteration
}
const { done, value } = readResult;
@@ -578,14 +583,8 @@ export class UrlSource extends Source {
this.onread?.(worker.currentPos, worker.currentPos + value.length);
this._orchestrator.supplyWorkerData(worker, value);
}
if (worker.aborted) {
break;
}
}
worker.running = false;
// The previous UrlSource had logic for circumventing https://issues.chromium.org/issues/436025873; I haven't
// been able to observe this bug with the new UrlSource (maybe because we're using response streaming), so the
// logic for that has vanished for now. Leaving a comment here if this becomes relevant again.
+35
View File
@@ -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
});
+26
View File
@@ -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);
});
+57
View File
@@ -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
});
+7
View File
@@ -201,6 +201,12 @@ test('null format', async () => {
expect(() => sample.allocationSize()).toThrow('when format is null');
await expect(async () => sample.copyTo(new ArrayBuffer())).rejects.toThrow('when format is null');
// Even this throws :(
// See https://github.com/Vanilagy/mediabunny/issues/267
expect(() => sample.allocationSize({ format: 'RGBA' })).toThrow('when format is null');
// Uncomment this if the RGBA conversion works again:
/*
const size = sample.allocationSize({ format: 'RGBA' });
expect(size).toBe(1280 * 720 * 4);
const buffer = new ArrayBuffer(size);
@@ -217,4 +223,5 @@ test('null format', async () => {
await sample.copyTo(buffer, { format: 'RGBX' });
await sample.copyTo(buffer, { format: 'BGRA' });
await sample.copyTo(buffer, { format: 'BGRX' });
*/
});
+54
View File
@@ -0,0 +1,54 @@
import { expect, test } from 'vitest';
import { Input } from '../../src/input.js';
import { BufferSource, FilePathSource } from '../../src/source.js';
import path from 'node:path';
import { ALL_FORMATS } from '../../src/input-format.js';
import { Output } from '../../src/output.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { Conversion } from '../../src/conversion.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
import { extractAvcNalUnits } from '../../src/codec-data.js';
const __dirname = new URL('.', import.meta.url).pathname;
test('Annex B to length-prefixed conversion, MP4', async () => {
using originalInput = new Input({
source: new FilePathSource(path.join(__dirname, '..', 'public/annex-b-avc.mkv')),
formats: ALL_FORMATS,
});
const originalVideoTrack = (await originalInput.getPrimaryVideoTrack())!;
const originalDecoderConfig = (await originalVideoTrack.getDecoderConfig())!;
expect(originalDecoderConfig.description).toBeUndefined();
expect(originalVideoTrack.codec).toBe('avc');
const originalSink = new EncodedPacketSink(originalVideoTrack);
const originalFirstPacket = await originalSink.getFirstPacket();
expect([...originalFirstPacket!.data.slice(0, 4)]).toEqual([0, 0, 0, 1]);
const originalNalUnits = extractAvcNalUnits(originalFirstPacket!.data, originalDecoderConfig);
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({ input: originalInput, output });
await conversion.execute();
using newInput = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const newVideoTrack = (await newInput.getPrimaryVideoTrack())!;
const newDecoderConfig = (await newVideoTrack.getDecoderConfig())!;
expect(newDecoderConfig.description).toBeDefined();
expect(newVideoTrack.codec).toBe('avc');
const newSink = new EncodedPacketSink(newVideoTrack);
const newFirstPacket = await newSink.getFirstPacket();
expect([...newFirstPacket!.data.slice(0, 4)]).not.toEqual([0, 0, 0, 1]); // Successfully converted
const newNalUnits = extractAvcNalUnits(newFirstPacket!.data, newDecoderConfig);
expect(newNalUnits).toEqual(originalNalUnits); // Content is the same though
});
Binary file not shown.
Binary file not shown.