mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 14:23:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
868578685d | ||
|
|
e1478f8ebd | ||
|
|
8aac07eb61 | ||
|
|
267c09abf8 | ||
|
|
a89101ff40 | ||
|
|
2ab1be3834 | ||
|
|
571fbb3198 | ||
|
|
32d8b1544d | ||
|
|
b8f104d8b4 | ||
|
|
7db0583058 | ||
|
|
24af125415 | ||
|
|
78e78e5b4f | ||
|
|
1d774b5fbb | ||
|
|
d6b518b9fd | ||
|
|
4f5f30bc03 | ||
|
|
f6ca3ece50 | ||
|
|
7b036fe8ac | ||
|
|
2bb84fd35b | ||
|
|
91e7812fb2 | ||
|
|
d29f4f33b5 | ||
|
|
7580935639 | ||
|
|
53824536fa | ||
|
|
8f43086e3d | ||
|
|
9343607fe1 | ||
|
|
f877fabc60 | ||
|
|
a2115b1de7 | ||
|
|
8d46d50c39 | ||
|
|
19219fc1ce | ||
|
|
05070f7c88 | ||
|
|
736cc50fa7 | ||
|
|
9ba63f5d95 | ||
|
|
17c6bff1dc | ||
|
|
c86af052f5 | ||
|
|
831e838f74 |
+2
-1
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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,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
@@ -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
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -190,6 +190,7 @@ export {
|
||||
ConversionOptions,
|
||||
ConversionVideoOptions,
|
||||
ConversionAudioOptions,
|
||||
ConversionCanceledError,
|
||||
DiscardedTrack,
|
||||
} from './conversion';
|
||||
export {
|
||||
|
||||
+2
-2
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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' });
|
||||
*/
|
||||
});
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user