Compare commits

..
21 Commits
Author SHA1 Message Date
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
17 changed files with 315 additions and 129 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
+4
View File
@@ -272,6 +272,10 @@ const output = new Output({
});
```
::: info
This format ensures [append-only writing](#append-only-writing).
:::
The following options are available:
```ts
type AdtsOutputFormatOptions = {
+4
View File
@@ -106,6 +106,7 @@ 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' },
@@ -115,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' },
@@ -126,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.2",
"version": "1.27.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.27.2",
"version": "1.27.5",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7739,9 +7739,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.27.1.tgz",
"integrity": "sha512-XdgXNox1hx2ynQy3jcCbzX1YsJt9en9VnZVidNXTNnQUdl6q2twSoV1GjgTb7Wlw9evCYXcKkgjE8KNU7K5trg==",
"version": "1.27.4",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.27.4.tgz",
"integrity": "sha512-j69K0yiXYMHyb5g4XyhYjbz1VpllRVyyDGF5lqYTsrdF0XoDRoGReoRvon9lnENv5yY5A5u+09dnqISwXvS86Q==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12065,7 +12065,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.27.2",
"version": "1.27.5",
"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.2",
"version": "1.27.5",
"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.2",
"version": "1.27.5",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+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: {
+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 -13
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
@@ -1203,9 +1215,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.DefaultDuration: {
if (!this.currentTrack) break;
this.currentTrack.defaultDuration
= this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9;
this.currentTrack.defaultDurationNs = readUnsignedInt(slice, size);
}; break;
case EBMLId.Name: {
@@ -2222,7 +2232,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
}
if (size === null) {
if (size === undefined) {
// Undefined element size (can happen in livestreamed files). In this case, we need to do some
// searching to determine the actual size of the element.
+7 -17
View File
@@ -480,23 +480,13 @@ export abstract class BaseMediaSampleSink<
let currentPacket: EncodedPacket | null = keyPacket;
let endPacket: EncodedPacket | undefined = undefined;
if (endTimestamp < Infinity) {
// When an end timestamp is set, we cannot simply use that for the packet iterator due to out-of-order
// frames (B-frames). Instead, we'll need to keep decoding packets until we get a frame that exceeds
// this end time. However, we can still put a bound on it: Since key frames are by definition never
// out of order, we can stop at the first key frame after the end timestamp.
const packet = await packetSink.getPacket(endTimestamp);
const keyPacket = !packet
? null
: packet.type === 'key' && packet.timestamp === endTimestamp
? packet
: await packetSink.getNextKeyPacket(packet, { verifyKeyPackets: true });
if (keyPacket) {
endPacket = keyPacket;
}
}
// B-frames make it exceedingly difficult to properly define an upper bound for packet iteration if an end
// timestamp is set, so we just don't do it. The case that makes it especially tricky is when the frames
// following a key frame have a lower timestamp than the keyframe; something that quite frequently happens
// in HEVC streams. The price to pay for not upper-bounding the packet iterator is a slight increase in
// decoder work at the end of the range, but the added correctness and reliability makes this tradeoff worth
// it.
const endPacket = undefined;
const packets = packetSink.packets(keyPacket ?? undefined, endPacket);
await packets.next(); // Skip the start packet as we already have it
+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;
})();
}
}
+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);
}
}
}
}
};
+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
});
+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' });
*/
});