mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
2062 lines
66 KiB
TypeScript
2062 lines
66 KiB
TypeScript
/*!
|
|
* Copyright (c) 2026-present, Vanilagy and contributors
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
import {
|
|
assert,
|
|
clamp,
|
|
COLOR_PRIMARIES_MAP,
|
|
isAllowSharedBufferSource,
|
|
MATRIX_COEFFICIENTS_MAP,
|
|
Rotation,
|
|
SECOND_TO_MICROSECOND_FACTOR,
|
|
toDataView,
|
|
toUint8Array,
|
|
SetRequired,
|
|
TRANSFER_CHARACTERISTICS_MAP,
|
|
isFirefox,
|
|
polyfillSymbolDispose,
|
|
assertNever,
|
|
isWebKit,
|
|
Rational,
|
|
simplifyRational,
|
|
Rectangle,
|
|
validateRectangle,
|
|
} from './misc';
|
|
|
|
polyfillSymbolDispose();
|
|
|
|
type FinalizationRegistryValue = {
|
|
type: 'video';
|
|
data: VideoFrame | OffscreenCanvas | Uint8Array;
|
|
} | {
|
|
type: 'audio';
|
|
data: AudioData | Uint8Array;
|
|
};
|
|
|
|
// Let's manually handle logging the garbage collection errors that are typically logged by the browser. This way, they
|
|
// also kick for audio samples (which is normally not the case), making sure any incorrect code is quickly caught.
|
|
let lastVideoGcErrorLog = -Infinity;
|
|
let lastAudioGcErrorLog = -Infinity;
|
|
let finalizationRegistry: FinalizationRegistry<FinalizationRegistryValue> | null = null;
|
|
if (typeof FinalizationRegistry !== 'undefined') {
|
|
finalizationRegistry = new FinalizationRegistry<FinalizationRegistryValue>((value) => {
|
|
const now = Date.now();
|
|
|
|
if (value.type === 'video') {
|
|
if (now - lastVideoGcErrorLog >= 1000) {
|
|
// This error is annoying but oh so important
|
|
console.error(
|
|
`A VideoSample was garbage collected without first being closed. For proper resource management,`
|
|
+ ` make sure to call close() on all your VideoSamples as soon as you're done using them.`,
|
|
);
|
|
|
|
lastVideoGcErrorLog = now;
|
|
}
|
|
|
|
if (typeof VideoFrame !== 'undefined' && value.data instanceof VideoFrame) {
|
|
value.data.close(); // Prevent the browser error since we're logging our own
|
|
}
|
|
} else {
|
|
if (now - lastAudioGcErrorLog >= 1000) {
|
|
console.error(
|
|
`An AudioSample was garbage collected without first being closed. For proper resource management,`
|
|
+ ` make sure to call close() on all your AudioSamples as soon as you're done using them.`,
|
|
);
|
|
|
|
lastAudioGcErrorLog = now;
|
|
}
|
|
|
|
if (typeof AudioData !== 'undefined' && value.data instanceof AudioData) {
|
|
value.data.close();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The list of {@link VideoSample} pixel formats.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export const VIDEO_SAMPLE_PIXEL_FORMATS = [
|
|
// 4:2:0 Y, U, V
|
|
'I420',
|
|
'I420P10',
|
|
'I420P12',
|
|
// 4:2:0 Y, U, V, A
|
|
'I420A',
|
|
'I420AP10',
|
|
'I420AP12',
|
|
// 4:2:2 Y, U, V
|
|
'I422',
|
|
'I422P10',
|
|
'I422P12',
|
|
// 4:2:2 Y, U, V, A
|
|
'I422A',
|
|
'I422AP10',
|
|
'I422AP12',
|
|
// 4:4:4 Y, U, V
|
|
'I444',
|
|
'I444P10',
|
|
'I444P12',
|
|
// 4:4:4 Y, U, V, A
|
|
'I444A',
|
|
'I444AP10',
|
|
'I444AP12',
|
|
// 4:2:0 Y, UV
|
|
'NV12',
|
|
// 4:4:4 RGBA
|
|
'RGBA',
|
|
// 4:4:4 RGBX (opaque)
|
|
'RGBX',
|
|
// 4:4:4 BGRA
|
|
'BGRA',
|
|
// 4:4:4 BGRX (opaque)
|
|
'BGRX',
|
|
] as const;
|
|
const VIDEO_SAMPLE_PIXEL_FORMATS_SET = new Set(VIDEO_SAMPLE_PIXEL_FORMATS);
|
|
|
|
/**
|
|
* The internal pixel format with which a {@link VideoSample} is stored.
|
|
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format) for more.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export type VideoSamplePixelFormat = typeof VIDEO_SAMPLE_PIXEL_FORMATS[number];
|
|
|
|
/**
|
|
* Metadata used for VideoSample initialization.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export type VideoSampleInit = {
|
|
/**
|
|
* The internal pixel format in which the frame is stored.
|
|
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format)
|
|
*/
|
|
format?: VideoSamplePixelFormat;
|
|
/** The width of the frame in pixels. */
|
|
codedWidth?: number;
|
|
/** The height of the frame in pixels. */
|
|
codedHeight?: number;
|
|
/** The rotation of the frame in degrees, clockwise. */
|
|
rotation?: Rotation;
|
|
/** The presentation timestamp of the frame in seconds. */
|
|
timestamp?: number;
|
|
/** The duration of the frame in seconds. */
|
|
duration?: number;
|
|
/** The color space of the frame. */
|
|
colorSpace?: VideoColorSpaceInit;
|
|
/** The byte layout of the planes of the frame. */
|
|
layout?: PlaneLayout[];
|
|
/** Visible region in the coded frame. When omitted, the rect defaults to `(0, 0, codedWidth, codedHeight)`. */
|
|
visibleRect?: Rectangle | undefined;
|
|
/** Width of the frame in pixels after applying aspect ratio adjustments and rotation. */
|
|
displayWidth?: number | undefined;
|
|
/** Height of the frame in pixels after applying aspect ratio adjustments and rotation. */
|
|
displayHeight?: number | undefined;
|
|
};
|
|
|
|
/**
|
|
* Represents a raw, unencoded video sample (frame). Mainly used as an expressive wrapper around WebCodecs API's
|
|
* [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame), but can also be used standalone.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export class VideoSample implements Disposable {
|
|
/** @internal */
|
|
_data!: VideoFrame | OffscreenCanvas | Uint8Array | null;
|
|
/**
|
|
* Used for the ArrayBuffer-backed case.
|
|
* @internal
|
|
*/
|
|
_layout!: PlaneLayout[] | null;
|
|
/** @internal */
|
|
_closed: boolean = false;
|
|
|
|
/**
|
|
* The internal pixel format in which the frame is stored. Will be `null` if it's using an arbitrary internal
|
|
* format not representable by `VideoPixelFormat`.
|
|
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format)
|
|
*/
|
|
readonly format!: VideoSamplePixelFormat | null;
|
|
/** The visible region of the frame in the coded pixel grid. */
|
|
readonly visibleRect!: Rectangle;
|
|
/** The width of the frame in square pixels, before rotation is applied. */
|
|
readonly squarePixelWidth!: number;
|
|
/** The height of the frame in square pixels, before rotation is applied. */
|
|
readonly squarePixelHeight!: number;
|
|
/** The rotation of the frame in degrees, clockwise. */
|
|
readonly rotation!: Rotation;
|
|
/**
|
|
* The pixel aspect ratio of the frame, as a rational number in its reduced form. Most videos use
|
|
* square pixels (1:1).
|
|
*/
|
|
readonly pixelAspectRatio!: Rational;
|
|
/**
|
|
* The presentation timestamp of the frame in seconds. May be negative. Frames with negative end timestamps should
|
|
* not be presented.
|
|
*/
|
|
readonly timestamp!: number;
|
|
/** The duration of the frame in seconds. */
|
|
readonly duration!: number;
|
|
/** The color space of the frame. */
|
|
readonly colorSpace!: VideoSampleColorSpace;
|
|
|
|
/** The width of the frame in pixels. */
|
|
get codedWidth() {
|
|
// This is wrong, but the fix is a v2 thing
|
|
return this.visibleRect.width;
|
|
}
|
|
|
|
/** The height of the frame in pixels. */
|
|
get codedHeight() {
|
|
// Same here
|
|
return this.visibleRect.height;
|
|
}
|
|
|
|
/** The display width of the frame in pixels, after aspect ratio adjustment and rotation. */
|
|
get displayWidth() {
|
|
return this.rotation % 180 === 0 ? this.squarePixelWidth : this.squarePixelHeight;
|
|
}
|
|
|
|
/** The display height of the frame in pixels, after aspect ratio adjustment and rotation. */
|
|
get displayHeight() {
|
|
return this.rotation % 180 === 0 ? this.squarePixelHeight : this.squarePixelWidth;
|
|
}
|
|
|
|
/** The presentation timestamp of the frame in microseconds. */
|
|
get microsecondTimestamp() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);
|
|
}
|
|
|
|
/** The duration of the frame in microseconds. */
|
|
get microsecondDuration() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);
|
|
}
|
|
|
|
/**
|
|
* Whether this sample uses a pixel format that can hold transparency data. Note that this doesn't necessarily mean
|
|
* that the sample is transparent.
|
|
*/
|
|
get hasAlpha() {
|
|
return this.format && this.format.includes('A');
|
|
}
|
|
|
|
/**
|
|
* Creates a new {@link VideoSample} from a
|
|
* [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame). This is essentially a near zero-cost
|
|
* wrapper around `VideoFrame`. The sample's metadata is optionally refined using the data specified in `init`.
|
|
*/
|
|
constructor(data: VideoFrame, init?: VideoSampleInit);
|
|
/**
|
|
* Creates a new {@link VideoSample} from a
|
|
* [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource), similar to the
|
|
* [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame) constructor. When `VideoFrame` is
|
|
* available, this is simply a wrapper around its constructor. If not, it will copy the source's image data to an
|
|
* internal canvas for later use.
|
|
*/
|
|
constructor(data: CanvasImageSource, init: SetRequired<VideoSampleInit, 'timestamp'>);
|
|
/**
|
|
* Creates a new {@link VideoSample} from raw pixel data specified in `data`. Additional metadata must be provided
|
|
* in `init`.
|
|
*/
|
|
constructor(
|
|
data: AllowSharedBufferSource,
|
|
init: SetRequired<VideoSampleInit, 'format' | 'codedWidth' | 'codedHeight' | 'timestamp'>
|
|
);
|
|
constructor(
|
|
data: VideoFrame | CanvasImageSource | AllowSharedBufferSource,
|
|
init?: VideoSampleInit,
|
|
) {
|
|
if (
|
|
data instanceof ArrayBuffer
|
|
|| (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer)
|
|
|| ArrayBuffer.isView(data)
|
|
) {
|
|
if (!init || typeof init !== 'object') {
|
|
throw new TypeError('init must be an object.');
|
|
}
|
|
if (init.format === undefined || !VIDEO_SAMPLE_PIXEL_FORMATS_SET.has(init.format)) {
|
|
throw new TypeError('init.format must be one of: ' + VIDEO_SAMPLE_PIXEL_FORMATS.join(', '));
|
|
}
|
|
if (!Number.isInteger(init.codedWidth) || init.codedWidth! <= 0) {
|
|
throw new TypeError('init.codedWidth must be a positive integer.');
|
|
}
|
|
if (!Number.isInteger(init.codedHeight) || init.codedHeight! <= 0) {
|
|
throw new TypeError('init.codedHeight must be a positive integer.');
|
|
}
|
|
if (init.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {
|
|
throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');
|
|
}
|
|
if (!Number.isFinite(init.timestamp)) {
|
|
throw new TypeError('init.timestamp must be a number.');
|
|
}
|
|
if (init.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {
|
|
throw new TypeError('init.duration, when provided, must be a non-negative number.');
|
|
}
|
|
if (init.layout !== undefined) {
|
|
if (!Array.isArray(init.layout)) {
|
|
throw new TypeError('init.layout, when provided, must be an array.');
|
|
}
|
|
|
|
for (const plane of init.layout) {
|
|
if (!plane || typeof plane !== 'object' || Array.isArray(plane)) {
|
|
throw new TypeError('Each entry in init.layout must be an object.');
|
|
}
|
|
if (!Number.isInteger(plane.offset) || plane.offset < 0) {
|
|
throw new TypeError('plane.offset must be a non-negative integer.');
|
|
}
|
|
if (!Number.isInteger(plane.stride) || plane.stride < 0) {
|
|
throw new TypeError('plane.stride must be a non-negative integer.');
|
|
}
|
|
}
|
|
}
|
|
if (init.visibleRect !== undefined) {
|
|
validateRectangle(init.visibleRect, 'init.visibleRect');
|
|
}
|
|
if (
|
|
init.displayWidth !== undefined
|
|
&& (!Number.isInteger(init.displayWidth) || init.displayWidth <= 0)
|
|
) {
|
|
throw new TypeError('init.displayWidth, when provided, must be a positive integer.');
|
|
}
|
|
if (
|
|
init.displayHeight !== undefined
|
|
&& (!Number.isInteger(init.displayHeight) || init.displayHeight <= 0)
|
|
) {
|
|
throw new TypeError('init.displayHeight, when provided, must be a positive integer.');
|
|
}
|
|
if ((init.displayWidth !== undefined) !== (init.displayHeight !== undefined)) {
|
|
throw new TypeError(
|
|
'init.displayWidth and init.displayHeight must be either both provided or both omitted.',
|
|
);
|
|
}
|
|
|
|
this._data = toUint8Array(data).slice(); // Copy it
|
|
this._layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth!, init.codedHeight!);
|
|
|
|
this.format = init.format;
|
|
this.rotation = init.rotation ?? 0;
|
|
this.timestamp = init.timestamp!;
|
|
this.duration = init.duration ?? 0;
|
|
this.colorSpace = new VideoSampleColorSpace(init.colorSpace);
|
|
this.visibleRect = {
|
|
left: init.visibleRect?.left ?? 0,
|
|
top: init.visibleRect?.top ?? 0,
|
|
width: init.visibleRect?.width ?? init.codedWidth!,
|
|
height: init.visibleRect?.height ?? init.codedHeight!,
|
|
};
|
|
|
|
if (init.displayWidth !== undefined) {
|
|
this.squarePixelWidth = this.rotation % 180 === 0 ? init.displayWidth : init.displayHeight!;
|
|
this.squarePixelHeight = this.rotation % 180 === 0 ? init.displayHeight! : init.displayWidth;
|
|
} else {
|
|
this.squarePixelWidth = this.codedWidth;
|
|
this.squarePixelHeight = this.codedHeight;
|
|
}
|
|
} else if (typeof VideoFrame !== 'undefined' && data instanceof VideoFrame) {
|
|
if (init?.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {
|
|
throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');
|
|
}
|
|
if (init?.timestamp !== undefined && !Number.isFinite(init?.timestamp)) {
|
|
throw new TypeError('init.timestamp, when provided, must be a number.');
|
|
}
|
|
if (init?.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {
|
|
throw new TypeError('init.duration, when provided, must be a non-negative number.');
|
|
}
|
|
if (init?.visibleRect !== undefined) {
|
|
validateRectangle(init.visibleRect, 'init.visibleRect');
|
|
}
|
|
|
|
this._data = data;
|
|
this._layout = null;
|
|
|
|
this.format = data.format;
|
|
this.visibleRect = {
|
|
left: data.visibleRect?.x ?? 0,
|
|
top: data.visibleRect?.y ?? 0,
|
|
width: data.visibleRect?.width ?? data.codedWidth,
|
|
height: data.visibleRect?.height ?? data.codedHeight,
|
|
};
|
|
// The VideoFrame's rotation is ignored here. It's still a new field, and I'm not sure of any application
|
|
// where the browser makes use of it. If a case gets found, I'll add it.
|
|
this.rotation = init?.rotation ?? 0;
|
|
|
|
// Assuming no innate VideoFrame rotation here
|
|
this.squarePixelWidth = data.displayWidth;
|
|
this.squarePixelHeight = data.displayHeight;
|
|
|
|
this.timestamp = init?.timestamp ?? data.timestamp / 1e6;
|
|
this.duration = init?.duration ?? (data.duration ?? 0) / 1e6;
|
|
this.colorSpace = new VideoSampleColorSpace(data.colorSpace);
|
|
} else if (
|
|
(typeof HTMLImageElement !== 'undefined' && data instanceof HTMLImageElement)
|
|
|| (typeof SVGImageElement !== 'undefined' && data instanceof SVGImageElement)
|
|
|| (typeof ImageBitmap !== 'undefined' && data instanceof ImageBitmap)
|
|
|| (typeof HTMLVideoElement !== 'undefined' && data instanceof HTMLVideoElement)
|
|
|| (typeof HTMLCanvasElement !== 'undefined' && data instanceof HTMLCanvasElement)
|
|
|| (typeof OffscreenCanvas !== 'undefined' && data instanceof OffscreenCanvas)
|
|
) {
|
|
if (!init || typeof init !== 'object') {
|
|
throw new TypeError('init must be an object.');
|
|
}
|
|
if (init.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {
|
|
throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');
|
|
}
|
|
if (!Number.isFinite(init.timestamp)) {
|
|
throw new TypeError('init.timestamp must be a number.');
|
|
}
|
|
if (init.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {
|
|
throw new TypeError('init.duration, when provided, must be a non-negative number.');
|
|
}
|
|
|
|
if (typeof VideoFrame !== 'undefined') {
|
|
return new VideoSample(
|
|
new VideoFrame(data, {
|
|
timestamp: Math.trunc(init.timestamp! * SECOND_TO_MICROSECOND_FACTOR),
|
|
// Drag 0 to undefined
|
|
duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR) || undefined,
|
|
}),
|
|
init,
|
|
);
|
|
}
|
|
|
|
let width = 0;
|
|
let height = 0;
|
|
|
|
// Determine the dimensions of the thing
|
|
if ('naturalWidth' in data) {
|
|
width = data.naturalWidth;
|
|
height = data.naturalHeight;
|
|
} else if ('videoWidth' in data) {
|
|
width = data.videoWidth;
|
|
height = data.videoHeight;
|
|
} else if ('width' in data) {
|
|
width = Number(data.width);
|
|
height = Number(data.height);
|
|
}
|
|
|
|
if (!width || !height) {
|
|
throw new TypeError('Could not determine dimensions.');
|
|
}
|
|
|
|
const canvas = new OffscreenCanvas(width, height);
|
|
const context = canvas.getContext('2d', {
|
|
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
|
|
willReadFrequently: true,
|
|
});
|
|
assert(context);
|
|
|
|
// Draw it to a canvas
|
|
context.drawImage(data, 0, 0);
|
|
this._data = canvas;
|
|
this._layout = null;
|
|
|
|
this.format = 'RGBX';
|
|
this.visibleRect = { left: 0, top: 0, width, height };
|
|
this.squarePixelWidth = width;
|
|
this.squarePixelHeight = height;
|
|
this.rotation = init.rotation ?? 0;
|
|
this.timestamp = init.timestamp!;
|
|
this.duration = init.duration ?? 0;
|
|
this.colorSpace = new VideoSampleColorSpace({
|
|
matrix: 'rgb',
|
|
primaries: 'bt709',
|
|
transfer: 'iec61966-2-1',
|
|
fullRange: true,
|
|
});
|
|
} else {
|
|
throw new TypeError('Invalid data type: Must be a BufferSource or CanvasImageSource.');
|
|
}
|
|
|
|
this.pixelAspectRatio = simplifyRational({
|
|
num: this.squarePixelWidth * this.codedHeight,
|
|
den: this.squarePixelHeight * this.codedWidth,
|
|
});
|
|
finalizationRegistry?.register(this, { type: 'video', data: this._data }, this);
|
|
}
|
|
|
|
/** Clones this video sample. */
|
|
clone() {
|
|
if (this._closed) {
|
|
throw new Error('VideoSample is closed.');
|
|
}
|
|
|
|
assert(this._data !== null);
|
|
|
|
if (isVideoFrame(this._data)) {
|
|
return new VideoSample(this._data.clone(), {
|
|
timestamp: this.timestamp,
|
|
duration: this.duration,
|
|
rotation: this.rotation,
|
|
});
|
|
} else if (this._data instanceof Uint8Array) {
|
|
assert(this._layout);
|
|
|
|
return new VideoSample(this._data, {
|
|
format: this.format!,
|
|
layout: this._layout,
|
|
codedWidth: this.codedWidth,
|
|
codedHeight: this.codedHeight,
|
|
timestamp: this.timestamp,
|
|
duration: this.duration,
|
|
colorSpace: this.colorSpace,
|
|
rotation: this.rotation,
|
|
visibleRect: this.visibleRect,
|
|
displayWidth: this.displayWidth,
|
|
displayHeight: this.displayHeight,
|
|
});
|
|
} else {
|
|
return new VideoSample(this._data, {
|
|
format: this.format!,
|
|
codedWidth: this.codedWidth,
|
|
codedHeight: this.codedHeight,
|
|
timestamp: this.timestamp,
|
|
duration: this.duration,
|
|
colorSpace: this.colorSpace,
|
|
rotation: this.rotation,
|
|
visibleRect: this.visibleRect,
|
|
displayWidth: this.displayWidth,
|
|
displayHeight: this.displayHeight,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes this video sample, releasing held resources. Video samples should be closed as soon as they are not
|
|
* needed anymore.
|
|
*/
|
|
close() {
|
|
if (this._closed) {
|
|
return;
|
|
}
|
|
|
|
finalizationRegistry?.unregister(this);
|
|
|
|
if (isVideoFrame(this._data)) {
|
|
this._data.close();
|
|
} else {
|
|
this._data = null; // GC that shit
|
|
}
|
|
|
|
this._closed = true;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
if (this._closed) {
|
|
throw new Error('VideoSample is closed.');
|
|
}
|
|
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);
|
|
|
|
if (!isVideoFrame(this._data)) {
|
|
if (
|
|
options.colorSpace
|
|
|| (options.format && options.format !== this.format)
|
|
|| options.layout
|
|
|| 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();
|
|
|
|
return size;
|
|
}
|
|
}
|
|
|
|
if (isVideoFrame(this._data)) {
|
|
return this._data.allocationSize(options);
|
|
} else if (this._data instanceof Uint8Array) {
|
|
return this._data.byteLength;
|
|
} else {
|
|
return this.codedWidth * this.codedHeight * 4; // RGBX
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[]> {
|
|
if (!isAllowSharedBufferSource(destination)) {
|
|
throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.');
|
|
}
|
|
validateVideoFrameCopyToOptions(options);
|
|
|
|
if (this._closed) {
|
|
throw new Error('VideoSample is closed.');
|
|
}
|
|
if (this.format === null) {
|
|
throw new Error('Cannot copy video sample data when format is null. Sorry!');
|
|
}
|
|
|
|
assert(this._data !== null);
|
|
|
|
if (!isVideoFrame(this._data)) {
|
|
if (
|
|
options.colorSpace
|
|
|| (options.format && options.format !== this.format)
|
|
|| options.layout
|
|
|| 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();
|
|
|
|
return layout;
|
|
}
|
|
}
|
|
|
|
if (isVideoFrame(this._data)) {
|
|
return this._data.copyTo(destination, options);
|
|
} else if (this._data instanceof Uint8Array) {
|
|
assert(this._layout);
|
|
|
|
const dest = toUint8Array(destination);
|
|
dest.set(this._data);
|
|
|
|
return this._layout;
|
|
} else {
|
|
const canvas = this._data;
|
|
const context = canvas.getContext('2d');
|
|
assert(context);
|
|
|
|
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
|
|
const dest = toUint8Array(destination);
|
|
dest.set(imageData.data);
|
|
|
|
return [{
|
|
offset: 0,
|
|
stride: 4 * this.codedWidth,
|
|
}];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts this video sample to a VideoFrame for use with the WebCodecs API. The VideoFrame returned by this
|
|
* method *must* be closed separately from this video sample.
|
|
*/
|
|
toVideoFrame() {
|
|
if (this._closed) {
|
|
throw new Error('VideoSample is closed.');
|
|
}
|
|
|
|
assert(this._data !== null);
|
|
|
|
if (isVideoFrame(this._data)) {
|
|
return new VideoFrame(this._data, {
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration || undefined, // Drag 0 duration to undefined, glitches some codecs
|
|
});
|
|
} else if (this._data instanceof Uint8Array) {
|
|
return new VideoFrame(this._data, {
|
|
format: this.format! as VideoPixelFormat,
|
|
codedWidth: this.codedWidth,
|
|
codedHeight: this.codedHeight,
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration || undefined,
|
|
colorSpace: this.colorSpace,
|
|
});
|
|
} else {
|
|
return new VideoFrame(this._data, {
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration || undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draws the video sample to a 2D canvas context. Rotation metadata will be taken into account.
|
|
*
|
|
* @param dx - The x-coordinate in the destination canvas at which to place the top-left corner of the source image.
|
|
* @param dy - The y-coordinate in the destination canvas at which to place the top-left corner of the source image.
|
|
* @param dWidth - The width in pixels with which to draw the image in the destination canvas.
|
|
* @param dHeight - The height in pixels with which to draw the image in the destination canvas.
|
|
*/
|
|
draw(
|
|
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
|
dx: number,
|
|
dy: number,
|
|
dWidth?: number,
|
|
dHeight?: number,
|
|
): void;
|
|
/**
|
|
* Draws the video sample to a 2D canvas context. Rotation metadata will be taken into account.
|
|
*
|
|
* @param sx - The x-coordinate of the top left corner of the sub-rectangle of the source image to draw into the
|
|
* destination context.
|
|
* @param sy - The y-coordinate of the top left corner of the sub-rectangle of the source image to draw into the
|
|
* destination context.
|
|
* @param sWidth - The width of the sub-rectangle of the source image to draw into the destination context.
|
|
* @param sHeight - The height of the sub-rectangle of the source image to draw into the destination context.
|
|
* @param dx - The x-coordinate in the destination canvas at which to place the top-left corner of the source image.
|
|
* @param dy - The y-coordinate in the destination canvas at which to place the top-left corner of the source image.
|
|
* @param dWidth - The width in pixels with which to draw the image in the destination canvas.
|
|
* @param dHeight - The height in pixels with which to draw the image in the destination canvas.
|
|
*/
|
|
draw(
|
|
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
|
sx: number,
|
|
sy: number,
|
|
sWidth: number,
|
|
sHeight: number,
|
|
dx: number,
|
|
dy: number,
|
|
dWidth?: number,
|
|
dHeight?: number,
|
|
): void;
|
|
draw(
|
|
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
|
arg1: number,
|
|
arg2: number,
|
|
arg3?: number,
|
|
arg4?: number,
|
|
arg5?: number,
|
|
arg6?: number,
|
|
arg7?: number,
|
|
arg8?: number,
|
|
) {
|
|
let sx = 0;
|
|
let sy = 0;
|
|
let sWidth = this.displayWidth;
|
|
let sHeight = this.displayHeight;
|
|
let dx = 0;
|
|
let dy = 0;
|
|
let dWidth = this.displayWidth;
|
|
let dHeight = this.displayHeight;
|
|
|
|
if (arg5 !== undefined) {
|
|
sx = arg1!;
|
|
sy = arg2!;
|
|
sWidth = arg3!;
|
|
sHeight = arg4!;
|
|
dx = arg5;
|
|
dy = arg6!;
|
|
|
|
if (arg7 !== undefined) {
|
|
dWidth = arg7;
|
|
dHeight = arg8!;
|
|
} else {
|
|
dWidth = sWidth;
|
|
dHeight = sHeight;
|
|
}
|
|
} else {
|
|
dx = arg1;
|
|
dy = arg2;
|
|
|
|
if (arg3 !== undefined) {
|
|
dWidth = arg3;
|
|
dHeight = arg4!;
|
|
}
|
|
}
|
|
|
|
if (!(
|
|
(typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)
|
|
|| (
|
|
typeof OffscreenCanvasRenderingContext2D !== 'undefined'
|
|
&& context instanceof OffscreenCanvasRenderingContext2D
|
|
)
|
|
)) {
|
|
throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');
|
|
}
|
|
if (!Number.isFinite(sx)) {
|
|
throw new TypeError('sx must be a number.');
|
|
}
|
|
if (!Number.isFinite(sy)) {
|
|
throw new TypeError('sy must be a number.');
|
|
}
|
|
if (!Number.isFinite(sWidth) || sWidth < 0) {
|
|
throw new TypeError('sWidth must be a non-negative number.');
|
|
}
|
|
if (!Number.isFinite(sHeight) || sHeight < 0) {
|
|
throw new TypeError('sHeight must be a non-negative number.');
|
|
}
|
|
if (!Number.isFinite(dx)) {
|
|
throw new TypeError('dx must be a number.');
|
|
}
|
|
if (!Number.isFinite(dy)) {
|
|
throw new TypeError('dy must be a number.');
|
|
}
|
|
if (!Number.isFinite(dWidth) || dWidth < 0) {
|
|
throw new TypeError('dWidth must be a non-negative number.');
|
|
}
|
|
if (!Number.isFinite(dHeight) || dHeight < 0) {
|
|
throw new TypeError('dHeight must be a non-negative number.');
|
|
}
|
|
|
|
if (this._closed) {
|
|
throw new Error('VideoSample is closed.');
|
|
}
|
|
|
|
({ sx, sy, sWidth, sHeight } = this._rotateSourceRegion(sx, sy, sWidth, sHeight, this.rotation));
|
|
|
|
const source = this.toCanvasImageSource();
|
|
|
|
context.save();
|
|
|
|
const centerX = dx + dWidth / 2;
|
|
const centerY = dy + dHeight / 2;
|
|
|
|
context.translate(centerX, centerY);
|
|
context.rotate(this.rotation * Math.PI / 180);
|
|
|
|
const aspectRatioChange = this.rotation % 180 === 0 ? 1 : dWidth / dHeight;
|
|
|
|
// Scale to compensate for aspect ratio changes when rotated
|
|
context.scale(1 / aspectRatioChange, aspectRatioChange);
|
|
|
|
context.drawImage(
|
|
source,
|
|
sx,
|
|
sy,
|
|
sWidth,
|
|
sHeight,
|
|
-dWidth / 2,
|
|
-dHeight / 2,
|
|
dWidth,
|
|
dHeight,
|
|
);
|
|
|
|
context.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws the sample in the middle of the canvas corresponding to the context with the specified fit behavior.
|
|
*/
|
|
drawWithFit(context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, options: {
|
|
/**
|
|
* Controls the fitting algorithm.
|
|
*
|
|
* - `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
|
|
* - `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to
|
|
* letterboxing.
|
|
* - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
|
|
*/
|
|
fit: 'fill' | 'contain' | 'cover';
|
|
/** A way to override rotation. Defaults to the rotation of the sample. */
|
|
rotation?: Rotation;
|
|
/**
|
|
* Specifies the rectangular region of the video sample to crop to. The crop region will automatically be
|
|
* clamped to the dimensions of the video sample. Cropping is performed after rotation but before resizing.
|
|
* The crop region is in the _display pixel space_ of the underlying video data.
|
|
*/
|
|
crop?: CropRectangle;
|
|
}) {
|
|
if (!(
|
|
(typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)
|
|
|| (
|
|
typeof OffscreenCanvasRenderingContext2D !== 'undefined'
|
|
&& context instanceof OffscreenCanvasRenderingContext2D
|
|
)
|
|
)) {
|
|
throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');
|
|
}
|
|
if (!options || typeof options !== 'object') {
|
|
throw new TypeError('options must be an object.');
|
|
}
|
|
if (!['fill', 'contain', 'cover'].includes(options.fit)) {
|
|
throw new TypeError('options.fit must be \'fill\', \'contain\', or \'cover\'.');
|
|
}
|
|
if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
|
|
throw new TypeError('options.rotation, when provided, must be 0, 90, 180, or 270.');
|
|
}
|
|
if (options.crop !== undefined) {
|
|
validateCropRectangle(options.crop, 'options.');
|
|
}
|
|
|
|
const canvasWidth = context.canvas.width;
|
|
const canvasHeight = context.canvas.height;
|
|
const rotation = options.rotation ?? this.rotation;
|
|
|
|
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
|
|
? [this.squarePixelWidth, this.squarePixelHeight]
|
|
: [this.squarePixelHeight, this.squarePixelWidth];
|
|
|
|
if (options.crop) {
|
|
clampCropRectangle(options.crop, rotatedWidth, rotatedHeight);
|
|
}
|
|
|
|
// These variables specify where the final sample will be drawn on the canvas
|
|
let dx: number;
|
|
let dy: number;
|
|
let newWidth: number;
|
|
let newHeight: number;
|
|
|
|
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
|
|
options.crop?.left ?? 0,
|
|
options.crop?.top ?? 0,
|
|
options.crop?.width ?? rotatedWidth,
|
|
options.crop?.height ?? rotatedHeight,
|
|
rotation,
|
|
);
|
|
|
|
if (options.fit === 'fill') {
|
|
dx = 0;
|
|
dy = 0;
|
|
newWidth = canvasWidth;
|
|
newHeight = canvasHeight;
|
|
} else {
|
|
const [sampleWidth, sampleHeight] = options.crop
|
|
? [options.crop.width, options.crop.height]
|
|
: [rotatedWidth, rotatedHeight];
|
|
|
|
const scale = options.fit === 'contain'
|
|
? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight)
|
|
: Math.max(canvasWidth / sampleWidth, canvasHeight / sampleHeight);
|
|
newWidth = sampleWidth * scale;
|
|
newHeight = sampleHeight * scale;
|
|
dx = (canvasWidth - newWidth) / 2;
|
|
dy = (canvasHeight - newHeight) / 2;
|
|
}
|
|
|
|
context.save();
|
|
|
|
const aspectRatioChange = rotation % 180 === 0 ? 1 : newWidth / newHeight;
|
|
context.translate(canvasWidth / 2, canvasHeight / 2);
|
|
context.rotate(rotation * Math.PI / 180);
|
|
// This aspect ratio compensation is done so that we can draw the sample with the intended dimensions and
|
|
// don't need to think about how those dimensions change after the rotation
|
|
context.scale(1 / aspectRatioChange, aspectRatioChange);
|
|
context.translate(-canvasWidth / 2, -canvasHeight / 2);
|
|
|
|
// Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
|
|
// ourselves here
|
|
context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);
|
|
|
|
context.restore();
|
|
}
|
|
|
|
/** @internal */
|
|
_rotateSourceRegion(sx: number, sy: number, sWidth: number, sHeight: number, rotation: number) {
|
|
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
|
|
// stored. Therefore, we must map these back onto the original, pre-rotation image.
|
|
if (rotation === 90) {
|
|
[sx, sy, sWidth, sHeight] = [
|
|
sy,
|
|
this.squarePixelHeight - sx - sWidth,
|
|
sHeight,
|
|
sWidth,
|
|
];
|
|
} else if (rotation === 180) {
|
|
[sx, sy] = [
|
|
this.squarePixelWidth - sx - sWidth,
|
|
this.squarePixelHeight - sy - sHeight,
|
|
];
|
|
} else if (rotation === 270) {
|
|
[sx, sy, sWidth, sHeight] = [
|
|
this.squarePixelWidth - sy - sHeight,
|
|
sx,
|
|
sHeight,
|
|
sWidth,
|
|
];
|
|
}
|
|
|
|
return { sx, sy, sWidth, sHeight };
|
|
}
|
|
|
|
/**
|
|
* Converts this video sample to a
|
|
* [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource) for drawing to a canvas.
|
|
*
|
|
* You must use the value returned by this method immediately, as any VideoFrame created internally will
|
|
* automatically be closed in the next microtask.
|
|
*/
|
|
toCanvasImageSource() {
|
|
if (this._closed) {
|
|
throw new Error('VideoSample is closed.');
|
|
}
|
|
|
|
assert(this._data !== null);
|
|
|
|
if (this._data instanceof Uint8Array) {
|
|
// Requires VideoFrame to be defined
|
|
const videoFrame = this.toVideoFrame();
|
|
queueMicrotask(() => videoFrame.close()); // Let's automatically close the frame in the next microtask
|
|
|
|
return videoFrame;
|
|
} else {
|
|
return this._data;
|
|
}
|
|
}
|
|
|
|
/** Sets the rotation metadata of this video sample. */
|
|
setRotation(newRotation: Rotation) {
|
|
if (![0, 90, 180, 270].includes(newRotation)) {
|
|
throw new TypeError('newRotation must be 0, 90, 180, or 270.');
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
(this.rotation as Rotation) = newRotation;
|
|
}
|
|
|
|
/** Sets the presentation timestamp of this video sample, in seconds. */
|
|
setTimestamp(newTimestamp: number) {
|
|
if (!Number.isFinite(newTimestamp)) {
|
|
throw new TypeError('newTimestamp must be a number.');
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
(this.timestamp as number) = newTimestamp;
|
|
}
|
|
|
|
/** Sets the duration of this video sample, in seconds. */
|
|
setDuration(newDuration: number) {
|
|
if (!Number.isFinite(newDuration) || newDuration < 0) {
|
|
throw new TypeError('newDuration must be a non-negative number.');
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
(this.duration as number) = newDuration;
|
|
}
|
|
|
|
/** Calls `.close()`. */
|
|
[Symbol.dispose]() {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Describes the color space of a {@link VideoSample}. Corresponds to the WebCodecs API's VideoColorSpace.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export class VideoSampleColorSpace {
|
|
/** The color primaries standard used. */
|
|
readonly primaries: VideoColorPrimaries | null;
|
|
/** The transfer characteristics used. */
|
|
readonly transfer: VideoTransferCharacteristics | null;
|
|
/** The color matrix coefficients used. */
|
|
readonly matrix: VideoMatrixCoefficients | null;
|
|
/** Whether the color values use the full range or limited range. */
|
|
readonly fullRange: boolean | null;
|
|
|
|
/** Creates a new VideoSampleColorSpace. */
|
|
constructor(init?: VideoColorSpaceInit) {
|
|
if (init !== undefined) {
|
|
if (!init || typeof init !== 'object') {
|
|
throw new TypeError('init.colorSpace, when provided, must be an object.');
|
|
}
|
|
|
|
const primariesValues = Object.keys(COLOR_PRIMARIES_MAP);
|
|
if (init.primaries != null && !primariesValues.includes(init.primaries)) {
|
|
throw new TypeError(
|
|
`init.colorSpace.primaries, when provided, must be one of ${primariesValues.join(', ')}.`,
|
|
);
|
|
}
|
|
|
|
const transferValues = Object.keys(TRANSFER_CHARACTERISTICS_MAP);
|
|
if (init.transfer != null && !transferValues.includes(init.transfer)) {
|
|
throw new TypeError(
|
|
`init.colorSpace.transfer, when provided, must be one of ${transferValues.join(', ')}.`,
|
|
);
|
|
}
|
|
|
|
const matrixValues = Object.keys(MATRIX_COEFFICIENTS_MAP);
|
|
if (init.matrix != null && !matrixValues.includes(init.matrix)) {
|
|
throw new TypeError(
|
|
`init.colorSpace.matrix, when provided, must be one of ${matrixValues.join(', ')}.`,
|
|
);
|
|
}
|
|
|
|
if (init.fullRange != null && typeof init.fullRange !== 'boolean') {
|
|
throw new TypeError('init.colorSpace.fullRange, when provided, must be a boolean.');
|
|
}
|
|
}
|
|
|
|
this.primaries = init?.primaries ?? null;
|
|
this.transfer = init?.transfer ?? null;
|
|
this.matrix = init?.matrix ?? null;
|
|
this.fullRange = init?.fullRange ?? null;
|
|
}
|
|
|
|
/** Serializes the color space to a JSON object. */
|
|
toJSON(): VideoColorSpaceInit {
|
|
return {
|
|
primaries: this.primaries,
|
|
transfer: this.transfer,
|
|
matrix: this.matrix,
|
|
fullRange: this.fullRange,
|
|
};
|
|
}
|
|
}
|
|
|
|
const isVideoFrame = (x: unknown): x is VideoFrame => {
|
|
return typeof VideoFrame !== 'undefined' && x instanceof VideoFrame;
|
|
};
|
|
|
|
/**
|
|
* Specifies the rectangular cropping region.
|
|
* @group Miscellaneous
|
|
* @public
|
|
*/
|
|
export type CropRectangle = {
|
|
/** The distance in pixels from the left edge of the source frame to the left edge of the crop rectangle. */
|
|
left: number;
|
|
/** The distance in pixels from the top edge of the source frame to the top edge of the crop rectangle. */
|
|
top: number;
|
|
/** The width in pixels of the crop rectangle. */
|
|
width: number;
|
|
/** The height in pixels of the crop rectangle. */
|
|
height: number;
|
|
};
|
|
|
|
export const clampCropRectangle = (crop: CropRectangle, outerWidth: number, outerHeight: number) => {
|
|
crop.left = Math.min(crop.left, outerWidth);
|
|
crop.top = Math.min(crop.top, outerHeight);
|
|
crop.width = Math.min(crop.width, outerWidth - crop.left);
|
|
crop.height = Math.min(crop.height, outerHeight - crop.top);
|
|
|
|
assert(crop.width >= 0);
|
|
assert(crop.height >= 0);
|
|
};
|
|
|
|
export const validateCropRectangle = (crop: CropRectangle, prefix: string) => {
|
|
if (!crop || typeof crop !== 'object') {
|
|
throw new TypeError(prefix + 'crop, when provided, must be an object.');
|
|
}
|
|
if (!Number.isInteger(crop.left) || crop.left < 0) {
|
|
throw new TypeError(prefix + 'crop.left must be a non-negative integer.');
|
|
}
|
|
if (!Number.isInteger(crop.top) || crop.top < 0) {
|
|
throw new TypeError(prefix + 'crop.top must be a non-negative integer.');
|
|
}
|
|
if (!Number.isInteger(crop.width) || crop.width < 0) {
|
|
throw new TypeError(prefix + 'crop.width must be a non-negative integer.');
|
|
}
|
|
if (!Number.isInteger(crop.height) || crop.height < 0) {
|
|
throw new TypeError(prefix + 'crop.height must be a non-negative integer.');
|
|
}
|
|
};
|
|
|
|
const validateVideoFrameCopyToOptions = (options: VideoFrameCopyToOptions) => {
|
|
if (!options || typeof options !== 'object') {
|
|
throw new TypeError('options must be an object.');
|
|
}
|
|
if (options.colorSpace !== undefined && !['display-p3', 'srgb'].includes(options.colorSpace)) {
|
|
throw new TypeError('options.colorSpace, when provided, must be \'display-p3\' or \'srgb\'.');
|
|
}
|
|
if (options.format !== undefined && typeof options.format !== 'string') {
|
|
throw new TypeError('options.format, when provided, must be a string.');
|
|
}
|
|
if (options.layout !== undefined) {
|
|
if (!Array.isArray(options.layout)) {
|
|
throw new TypeError('options.layout, when provided, must be an array.');
|
|
}
|
|
|
|
for (const plane of options.layout) {
|
|
if (!plane || typeof plane !== 'object') {
|
|
throw new TypeError('Each entry in options.layout must be an object.');
|
|
}
|
|
if (!Number.isInteger(plane.offset) || plane.offset < 0) {
|
|
throw new TypeError('plane.offset must be a non-negative integer.');
|
|
}
|
|
if (!Number.isInteger(plane.stride) || plane.stride < 0) {
|
|
throw new TypeError('plane.stride must be a non-negative integer.');
|
|
}
|
|
}
|
|
}
|
|
if (options.rect !== undefined) {
|
|
if (!options.rect || typeof options.rect !== 'object') {
|
|
throw new TypeError('options.rect, when provided, must be an object.');
|
|
}
|
|
if (options.rect.x !== undefined && (!Number.isInteger(options.rect.x) || options.rect.x < 0)) {
|
|
throw new TypeError('options.rect.x, when provided, must be a non-negative integer.');
|
|
}
|
|
if (options.rect.y !== undefined && (!Number.isInteger(options.rect.y) || options.rect.y < 0)) {
|
|
throw new TypeError('options.rect.y, when provided, must be a non-negative integer.');
|
|
}
|
|
if (options.rect.width !== undefined && (!Number.isInteger(options.rect.width) || options.rect.width < 0)) {
|
|
throw new TypeError('options.rect.width, when provided, must be a non-negative integer.');
|
|
}
|
|
if (options.rect.height !== undefined && (!Number.isInteger(options.rect.height) || options.rect.height < 0)) {
|
|
throw new TypeError('options.rect.height, when provided, must be a non-negative integer.');
|
|
}
|
|
}
|
|
};
|
|
|
|
/** Implements logic from WebCodecs § 9.4.6 "Compute Layout and Allocation Size" */
|
|
const createDefaultPlaneLayout = (
|
|
format: VideoSamplePixelFormat,
|
|
codedWidth: number,
|
|
codedHeight: number,
|
|
): PlaneLayout[] => {
|
|
const planes = getPlaneConfigs(format);
|
|
const layouts: PlaneLayout[] = [];
|
|
let currentOffset = 0;
|
|
|
|
for (const plane of planes) {
|
|
// Per § 9.8, dimensions are usually "rounded up to the nearest integer".
|
|
const planeWidth = Math.ceil(codedWidth / plane.widthDivisor);
|
|
const planeHeight = Math.ceil(codedHeight / plane.heightDivisor);
|
|
|
|
const stride = planeWidth * plane.sampleBytes;
|
|
|
|
// Tight packing
|
|
const planeSize = stride * planeHeight;
|
|
|
|
layouts.push({
|
|
offset: currentOffset,
|
|
stride: stride,
|
|
});
|
|
|
|
currentOffset += planeSize;
|
|
}
|
|
|
|
return layouts;
|
|
};
|
|
|
|
type PlaneConfig = {
|
|
sampleBytes: number;
|
|
widthDivisor: number; // Horizontal sub-sampling factor
|
|
heightDivisor: number; // Vertical sub-sampling factor
|
|
};
|
|
|
|
/** Helper to retrieve plane configurations based on WebCodecs § 9.8 Pixel Format definitions. */
|
|
const getPlaneConfigs = (format: VideoSamplePixelFormat): PlaneConfig[] => {
|
|
// Helper for standard YUV planes
|
|
const yuv = (
|
|
yBytes: number,
|
|
uvBytes: number,
|
|
subX: number,
|
|
subY: number,
|
|
hasAlpha: boolean,
|
|
): PlaneConfig[] => {
|
|
const configs: PlaneConfig[] = [
|
|
{ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 },
|
|
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },
|
|
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },
|
|
];
|
|
|
|
if (hasAlpha) {
|
|
// Match luma dimensions
|
|
configs.push({ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 });
|
|
}
|
|
|
|
return configs;
|
|
};
|
|
|
|
switch (format) {
|
|
case 'I420':
|
|
return yuv(1, 1, 2, 2, false);
|
|
case 'I420P10':
|
|
case 'I420P12':
|
|
return yuv(2, 2, 2, 2, false);
|
|
case 'I420A':
|
|
return yuv(1, 1, 2, 2, true);
|
|
case 'I420AP10':
|
|
case 'I420AP12':
|
|
return yuv(2, 2, 2, 2, true);
|
|
|
|
case 'I422':
|
|
return yuv(1, 1, 2, 1, false);
|
|
case 'I422P10':
|
|
case 'I422P12':
|
|
return yuv(2, 2, 2, 1, false);
|
|
case 'I422A':
|
|
return yuv(1, 1, 2, 1, true);
|
|
case 'I422AP10':
|
|
case 'I422AP12':
|
|
return yuv(2, 2, 2, 1, true);
|
|
|
|
case 'I444':
|
|
return yuv(1, 1, 1, 1, false);
|
|
case 'I444P10':
|
|
case 'I444P12':
|
|
return yuv(2, 2, 1, 1, false);
|
|
case 'I444A':
|
|
return yuv(1, 1, 1, 1, true);
|
|
case 'I444AP10':
|
|
case 'I444AP12':
|
|
return yuv(2, 2, 1, 1, true);
|
|
|
|
case 'NV12':
|
|
return [
|
|
{ sampleBytes: 1, widthDivisor: 1, heightDivisor: 1 },
|
|
{ sampleBytes: 2, widthDivisor: 2, heightDivisor: 2 }, // Interleaved U and V
|
|
];
|
|
|
|
case 'RGBA':
|
|
case 'RGBX':
|
|
case 'BGRA':
|
|
case 'BGRX':
|
|
return [
|
|
{ sampleBytes: 4, widthDivisor: 1, heightDivisor: 1 },
|
|
];
|
|
|
|
default:
|
|
assertNever(format);
|
|
assert(false);
|
|
}
|
|
};
|
|
|
|
const AUDIO_SAMPLE_FORMATS = new Set<AudioSampleFormat>(
|
|
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
|
|
);
|
|
|
|
/**
|
|
* Metadata used for AudioSample initialization.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export type AudioSampleInit = {
|
|
/** The audio data for this sample. */
|
|
data: AllowSharedBufferSource;
|
|
/**
|
|
* The audio sample format. [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format)
|
|
*/
|
|
format: AudioSampleFormat;
|
|
/** The number of audio channels. */
|
|
numberOfChannels: number;
|
|
/** The audio sample rate in hertz. */
|
|
sampleRate: number;
|
|
/** The presentation timestamp of the sample in seconds. */
|
|
timestamp: number;
|
|
};
|
|
|
|
/**
|
|
* Options used for copying audio sample data.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export type AudioSampleCopyToOptions = {
|
|
/**
|
|
* The index identifying the plane to copy from. This must be 0 if using a non-planar (interleaved) output format.
|
|
*/
|
|
planeIndex: number;
|
|
/**
|
|
* The output format for the destination data. Defaults to the AudioSample's format.
|
|
* [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format)
|
|
*/
|
|
format?: AudioSampleFormat;
|
|
/** An offset into the source plane data indicating which frame to begin copying from. Defaults to 0. */
|
|
frameOffset?: number;
|
|
/**
|
|
* The number of frames to copy. If not provided, the copy will include all frames in the plane beginning
|
|
* with frameOffset.
|
|
*/
|
|
frameCount?: number;
|
|
};
|
|
|
|
/**
|
|
* Represents a raw, unencoded audio sample. Mainly used as an expressive wrapper around WebCodecs API's
|
|
* [`AudioData`](https://developer.mozilla.org/en-US/docs/Web/API/AudioData), but can also be used standalone.
|
|
* @group Samples
|
|
* @public
|
|
*/
|
|
export class AudioSample implements Disposable {
|
|
/** @internal */
|
|
_data: AudioData | Uint8Array;
|
|
/** @internal */
|
|
_closed: boolean = false;
|
|
|
|
/**
|
|
* The audio sample format.
|
|
* [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format)
|
|
*/
|
|
readonly format: AudioSampleFormat;
|
|
/** The audio sample rate in hertz. */
|
|
readonly sampleRate: number;
|
|
/**
|
|
* The number of audio frames in the sample, per channel. In other words, the length of this audio sample in frames.
|
|
*/
|
|
readonly numberOfFrames: number;
|
|
/** The number of audio channels. */
|
|
readonly numberOfChannels: number;
|
|
/** The duration of the sample in seconds. */
|
|
readonly duration: number;
|
|
/**
|
|
* The presentation timestamp of the sample in seconds. May be negative. Samples with negative end timestamps should
|
|
* not be presented.
|
|
*/
|
|
readonly timestamp: number;
|
|
|
|
/** The presentation timestamp of the sample in microseconds. */
|
|
get microsecondTimestamp() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);
|
|
}
|
|
|
|
/** The duration of the sample in microseconds. */
|
|
get microsecondDuration() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);
|
|
}
|
|
|
|
/**
|
|
* Creates a new {@link AudioSample}, either from an existing
|
|
* [`AudioData`](https://developer.mozilla.org/en-US/docs/Web/API/AudioData) or from raw bytes specified in
|
|
* {@link AudioSampleInit}.
|
|
*/
|
|
constructor(init: AudioData | AudioSampleInit) {
|
|
if (isAudioData(init)) {
|
|
if (init.format === null) {
|
|
throw new TypeError('AudioData with null format is not supported.');
|
|
}
|
|
|
|
this._data = init;
|
|
|
|
this.format = init.format;
|
|
this.sampleRate = init.sampleRate;
|
|
this.numberOfFrames = init.numberOfFrames;
|
|
this.numberOfChannels = init.numberOfChannels;
|
|
this.timestamp = init.timestamp / 1e6;
|
|
this.duration = init.numberOfFrames / init.sampleRate;
|
|
} else {
|
|
if (!init || typeof init !== 'object') {
|
|
throw new TypeError('Invalid AudioDataInit: must be an object.');
|
|
}
|
|
|
|
if (!AUDIO_SAMPLE_FORMATS.has(init.format)) {
|
|
throw new TypeError('Invalid AudioDataInit: invalid format.');
|
|
}
|
|
if (!Number.isFinite(init.sampleRate) || init.sampleRate <= 0) {
|
|
throw new TypeError('Invalid AudioDataInit: sampleRate must be > 0.');
|
|
}
|
|
if (!Number.isInteger(init.numberOfChannels) || init.numberOfChannels === 0) {
|
|
throw new TypeError('Invalid AudioDataInit: numberOfChannels must be an integer > 0.');
|
|
}
|
|
if (!Number.isFinite(init?.timestamp)) {
|
|
throw new TypeError('init.timestamp must be a number.');
|
|
}
|
|
|
|
const numberOfFrames
|
|
= init.data.byteLength / (getBytesPerSample(init.format) * init.numberOfChannels);
|
|
if (!Number.isInteger(numberOfFrames)) {
|
|
throw new TypeError('Invalid AudioDataInit: data size is not a multiple of frame size.');
|
|
}
|
|
|
|
this.format = init.format;
|
|
this.sampleRate = init.sampleRate;
|
|
this.numberOfFrames = numberOfFrames;
|
|
this.numberOfChannels = init.numberOfChannels;
|
|
this.timestamp = init.timestamp;
|
|
this.duration = numberOfFrames / init.sampleRate;
|
|
|
|
let dataBuffer: Uint8Array;
|
|
if (init.data instanceof ArrayBuffer) {
|
|
dataBuffer = new Uint8Array(init.data);
|
|
} else if (ArrayBuffer.isView(init.data)) {
|
|
dataBuffer = new Uint8Array(init.data.buffer, init.data.byteOffset, init.data.byteLength);
|
|
} else {
|
|
throw new TypeError('Invalid AudioDataInit: data is not a BufferSource.');
|
|
}
|
|
|
|
const expectedSize
|
|
= this.numberOfFrames * this.numberOfChannels * getBytesPerSample(this.format);
|
|
if (dataBuffer.byteLength < expectedSize) {
|
|
throw new TypeError('Invalid AudioDataInit: insufficient data size.');
|
|
}
|
|
|
|
this._data = dataBuffer;
|
|
}
|
|
|
|
finalizationRegistry?.register(this, { type: 'audio', data: this._data }, this);
|
|
}
|
|
|
|
/** Returns the number of bytes required to hold the audio sample's data as specified by the given options. */
|
|
allocationSize(options: AudioSampleCopyToOptions) {
|
|
if (!options || typeof options !== 'object') {
|
|
throw new TypeError('options must be an object.');
|
|
}
|
|
if (!Number.isInteger(options.planeIndex) || options.planeIndex < 0) {
|
|
throw new TypeError('planeIndex must be a non-negative integer.');
|
|
}
|
|
|
|
if (options.format !== undefined && !AUDIO_SAMPLE_FORMATS.has(options.format)) {
|
|
throw new TypeError('Invalid format.');
|
|
}
|
|
if (options.frameOffset !== undefined && (!Number.isInteger(options.frameOffset) || options.frameOffset < 0)) {
|
|
throw new TypeError('frameOffset must be a non-negative integer.');
|
|
}
|
|
if (options.frameCount !== undefined && (!Number.isInteger(options.frameCount) || options.frameCount < 0)) {
|
|
throw new TypeError('frameCount must be a non-negative integer.');
|
|
}
|
|
|
|
if (this._closed) {
|
|
throw new Error('AudioSample is closed.');
|
|
}
|
|
|
|
const destFormat = options.format ?? this.format;
|
|
|
|
const frameOffset = options.frameOffset ?? 0;
|
|
if (frameOffset >= this.numberOfFrames) {
|
|
throw new RangeError('frameOffset out of range');
|
|
}
|
|
|
|
const copyFrameCount
|
|
= options.frameCount !== undefined ? options.frameCount : (this.numberOfFrames - frameOffset);
|
|
if (copyFrameCount > (this.numberOfFrames - frameOffset)) {
|
|
throw new RangeError('frameCount out of range');
|
|
}
|
|
|
|
const bytesPerSample = getBytesPerSample(destFormat);
|
|
const isPlanar = formatIsPlanar(destFormat);
|
|
if (isPlanar && options.planeIndex >= this.numberOfChannels) {
|
|
throw new RangeError('planeIndex out of range');
|
|
}
|
|
if (!isPlanar && options.planeIndex !== 0) {
|
|
throw new RangeError('planeIndex out of range');
|
|
}
|
|
|
|
const elementCount = isPlanar ? copyFrameCount : copyFrameCount * this.numberOfChannels;
|
|
return elementCount * bytesPerSample;
|
|
}
|
|
|
|
/** Copies the audio sample's data to an ArrayBuffer or ArrayBufferView as specified by the given options. */
|
|
copyTo(destination: AllowSharedBufferSource, options: AudioSampleCopyToOptions) {
|
|
if (!isAllowSharedBufferSource(destination)) {
|
|
throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.');
|
|
}
|
|
if (!options || typeof options !== 'object') {
|
|
throw new TypeError('options must be an object.');
|
|
}
|
|
if (!Number.isInteger(options.planeIndex) || options.planeIndex < 0) {
|
|
throw new TypeError('planeIndex must be a non-negative integer.');
|
|
}
|
|
|
|
if (options.format !== undefined && !AUDIO_SAMPLE_FORMATS.has(options.format)) {
|
|
throw new TypeError('Invalid format.');
|
|
}
|
|
if (options.frameOffset !== undefined && (!Number.isInteger(options.frameOffset) || options.frameOffset < 0)) {
|
|
throw new TypeError('frameOffset must be a non-negative integer.');
|
|
}
|
|
if (options.frameCount !== undefined && (!Number.isInteger(options.frameCount) || options.frameCount < 0)) {
|
|
throw new TypeError('frameCount must be a non-negative integer.');
|
|
}
|
|
|
|
if (this._closed) {
|
|
throw new Error('AudioSample is closed.');
|
|
}
|
|
|
|
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');
|
|
|
|
const numFrames = this.numberOfFrames;
|
|
const numChannels = this.numberOfChannels;
|
|
const frameOffset = optFrameOffset ?? 0;
|
|
if (frameOffset >= numFrames) {
|
|
throw new RangeError('frameOffset out of range');
|
|
}
|
|
|
|
const copyFrameCount = optFrameCount !== undefined ? optFrameCount : (numFrames - frameOffset);
|
|
if (copyFrameCount > (numFrames - frameOffset)) {
|
|
throw new RangeError('frameCount out of range');
|
|
}
|
|
|
|
const destBytesPerSample = getBytesPerSample(destFormat);
|
|
const destIsPlanar = formatIsPlanar(destFormat);
|
|
if (destIsPlanar && planeIndex >= numChannels) {
|
|
throw new RangeError('planeIndex out of range');
|
|
}
|
|
if (!destIsPlanar && planeIndex !== 0) {
|
|
throw new RangeError('planeIndex out of range');
|
|
}
|
|
|
|
const destElementCount = destIsPlanar ? copyFrameCount : copyFrameCount * numChannels;
|
|
const requiredSize = destElementCount * destBytesPerSample;
|
|
if (destination.byteLength < requiredSize) {
|
|
throw new RangeError('Destination buffer is too small');
|
|
}
|
|
|
|
const destView = toDataView(destination);
|
|
const writeFn = getWriteFunction(destFormat);
|
|
|
|
if (isAudioData(this._data)) {
|
|
if (isWebKit() && numChannels > 2 && destFormat !== srcFormat) {
|
|
// WebKit bug workaround
|
|
doAudioDataCopyToWebKitWorkaround(
|
|
this._data,
|
|
destView,
|
|
srcFormat,
|
|
destFormat,
|
|
numChannels,
|
|
planeIndex,
|
|
frameOffset,
|
|
copyFrameCount,
|
|
);
|
|
} else {
|
|
// 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 readFn = getReadFunction(srcFormat);
|
|
const srcBytesPerSample = getBytesPerSample(srcFormat);
|
|
const srcIsPlanar = formatIsPlanar(srcFormat);
|
|
|
|
for (let i = 0; i < copyFrameCount; i++) {
|
|
if (destIsPlanar) {
|
|
const destOffset = i * destBytesPerSample;
|
|
let srcOffset: number;
|
|
if (srcIsPlanar) {
|
|
srcOffset = (planeIndex * numFrames + (i + frameOffset)) * srcBytesPerSample;
|
|
} else {
|
|
srcOffset = (((i + frameOffset) * numChannels) + planeIndex) * srcBytesPerSample;
|
|
}
|
|
|
|
const normalized = readFn(srcView, srcOffset);
|
|
writeFn(destView, destOffset, normalized);
|
|
} else {
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
const destIndex = i * numChannels + ch;
|
|
const destOffset = destIndex * destBytesPerSample;
|
|
let srcOffset: number;
|
|
if (srcIsPlanar) {
|
|
srcOffset = (ch * numFrames + (i + frameOffset)) * srcBytesPerSample;
|
|
} else {
|
|
srcOffset = (((i + frameOffset) * numChannels) + ch) * srcBytesPerSample;
|
|
}
|
|
|
|
const normalized = readFn(srcView, srcOffset);
|
|
writeFn(destView, destOffset, normalized);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Clones this audio sample. */
|
|
clone(): AudioSample {
|
|
if (this._closed) {
|
|
throw new Error('AudioSample is closed.');
|
|
}
|
|
|
|
if (isAudioData(this._data)) {
|
|
const sample = new AudioSample(this._data.clone());
|
|
sample.setTimestamp(this.timestamp); // Make sure the timestamp is precise (beyond microsecond accuracy)
|
|
|
|
return sample;
|
|
} else {
|
|
return new AudioSample({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.timestamp,
|
|
data: this._data,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes this audio sample, releasing held resources. Audio samples should be closed as soon as they are not
|
|
* needed anymore.
|
|
*/
|
|
close(): void {
|
|
if (this._closed) {
|
|
return;
|
|
}
|
|
|
|
finalizationRegistry?.unregister(this);
|
|
|
|
if (isAudioData(this._data)) {
|
|
this._data.close();
|
|
} else {
|
|
this._data = new Uint8Array(0);
|
|
}
|
|
|
|
this._closed = true;
|
|
}
|
|
|
|
/**
|
|
* Converts this audio sample to an AudioData for use with the WebCodecs API. The AudioData returned by this
|
|
* method *must* be closed separately from this audio sample.
|
|
*/
|
|
toAudioData() {
|
|
if (this._closed) {
|
|
throw new Error('AudioSample is closed.');
|
|
}
|
|
|
|
if (isAudioData(this._data)) {
|
|
if (this._data.timestamp === this.microsecondTimestamp) {
|
|
// Timestamp matches, let's just return the data (but cloned)
|
|
return this._data.clone();
|
|
} else {
|
|
// It's impossible to simply change an AudioData's timestamp, so we'll need to create a new one
|
|
if (formatIsPlanar(this.format)) {
|
|
const size = this.allocationSize({ planeIndex: 0, format: this.format });
|
|
const data = new ArrayBuffer(size * this.numberOfChannels);
|
|
|
|
// We gotta read out each plane individually
|
|
for (let i = 0; i < this.numberOfChannels; i++) {
|
|
this.copyTo(new Uint8Array(data, i * size, size), { planeIndex: i, format: this.format });
|
|
}
|
|
|
|
return new AudioData({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.microsecondTimestamp,
|
|
data,
|
|
});
|
|
} else {
|
|
const data = new ArrayBuffer(this.allocationSize({ planeIndex: 0, format: this.format }));
|
|
this.copyTo(data, { planeIndex: 0, format: this.format });
|
|
|
|
return new AudioData({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.microsecondTimestamp,
|
|
data,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
return new AudioData({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.microsecondTimestamp,
|
|
data: this._data.buffer instanceof ArrayBuffer
|
|
? this._data.buffer
|
|
: this._data.slice(), // In the case of SharedArrayBuffer, convert to ArrayBuffer
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Convert this audio sample to an AudioBuffer for use with the Web Audio API. */
|
|
toAudioBuffer() {
|
|
if (this._closed) {
|
|
throw new Error('AudioSample is closed.');
|
|
}
|
|
|
|
const audioBuffer = new AudioBuffer({
|
|
numberOfChannels: this.numberOfChannels,
|
|
length: this.numberOfFrames,
|
|
sampleRate: this.sampleRate,
|
|
});
|
|
|
|
const dataBytes = new Float32Array(this.allocationSize({ planeIndex: 0, format: 'f32-planar' }) / 4);
|
|
|
|
for (let i = 0; i < this.numberOfChannels; i++) {
|
|
this.copyTo(dataBytes, { planeIndex: i, format: 'f32-planar' });
|
|
audioBuffer.copyToChannel(dataBytes, i);
|
|
}
|
|
|
|
return audioBuffer;
|
|
}
|
|
|
|
/** Sets the presentation timestamp of this audio sample, in seconds. */
|
|
setTimestamp(newTimestamp: number) {
|
|
if (!Number.isFinite(newTimestamp)) {
|
|
throw new TypeError('newTimestamp must be a number.');
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
(this.timestamp as number) = newTimestamp;
|
|
}
|
|
|
|
/** Calls `.close()`. */
|
|
[Symbol.dispose]() {
|
|
this.close();
|
|
}
|
|
|
|
/** @internal */
|
|
static* _fromAudioBuffer(audioBuffer: AudioBuffer, timestamp: number) {
|
|
if (!(audioBuffer instanceof AudioBuffer)) {
|
|
throw new TypeError('audioBuffer must be an AudioBuffer.');
|
|
}
|
|
|
|
const MAX_FLOAT_COUNT = 48000 * 5; // 5 seconds of mono 48 kHz audio per sample
|
|
|
|
const numberOfChannels = audioBuffer.numberOfChannels;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const totalFrames = audioBuffer.length;
|
|
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
|
|
|
|
let currentRelativeFrame = 0;
|
|
let remainingFrames = totalFrames;
|
|
|
|
// Create AudioSamples in a chunked fashion so we don't create huge Float32Arrays
|
|
while (remainingFrames > 0) {
|
|
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
|
|
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
|
|
|
|
for (let channel = 0; channel < numberOfChannels; channel++) {
|
|
audioBuffer.copyFromChannel(
|
|
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
|
|
channel,
|
|
currentRelativeFrame,
|
|
);
|
|
}
|
|
|
|
yield new AudioSample({
|
|
format: 'f32-planar',
|
|
sampleRate,
|
|
numberOfFrames: framesToCopy,
|
|
numberOfChannels,
|
|
timestamp: timestamp + currentRelativeFrame / sampleRate,
|
|
data: chunkData,
|
|
});
|
|
|
|
currentRelativeFrame += framesToCopy;
|
|
remainingFrames -= framesToCopy;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates AudioSamples from an AudioBuffer, starting at the given timestamp in seconds. Typically creates exactly
|
|
* one sample, but may create multiple if the AudioBuffer is exceedingly large.
|
|
*/
|
|
static fromAudioBuffer(audioBuffer: AudioBuffer, timestamp: number) {
|
|
if (!(audioBuffer instanceof AudioBuffer)) {
|
|
throw new TypeError('audioBuffer must be an AudioBuffer.');
|
|
}
|
|
|
|
const MAX_FLOAT_COUNT = 48000 * 5; // 5 seconds of mono 48 kHz audio per sample
|
|
|
|
const numberOfChannels = audioBuffer.numberOfChannels;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const totalFrames = audioBuffer.length;
|
|
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
|
|
|
|
let currentRelativeFrame = 0;
|
|
let remainingFrames = totalFrames;
|
|
|
|
const result: AudioSample[] = [];
|
|
|
|
// Create AudioSamples in a chunked fashion so we don't create huge Float32Arrays
|
|
while (remainingFrames > 0) {
|
|
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
|
|
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
|
|
|
|
for (let channel = 0; channel < numberOfChannels; channel++) {
|
|
audioBuffer.copyFromChannel(
|
|
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
|
|
channel,
|
|
currentRelativeFrame,
|
|
);
|
|
}
|
|
|
|
const audioSample = new AudioSample({
|
|
format: 'f32-planar',
|
|
sampleRate,
|
|
numberOfFrames: framesToCopy,
|
|
numberOfChannels,
|
|
timestamp: timestamp + currentRelativeFrame / sampleRate,
|
|
data: chunkData,
|
|
});
|
|
|
|
result.push(audioSample);
|
|
|
|
currentRelativeFrame += framesToCopy;
|
|
remainingFrames -= framesToCopy;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const getBytesPerSample = (format: AudioSampleFormat): number => {
|
|
switch (format) {
|
|
case 'u8':
|
|
case 'u8-planar':
|
|
return 1;
|
|
case 's16':
|
|
case 's16-planar':
|
|
return 2;
|
|
case 's32':
|
|
case 's32-planar':
|
|
return 4;
|
|
case 'f32':
|
|
case 'f32-planar':
|
|
return 4;
|
|
default:
|
|
throw new Error('Unknown AudioSampleFormat');
|
|
}
|
|
};
|
|
|
|
const formatIsPlanar = (format: AudioSampleFormat): boolean => {
|
|
switch (format) {
|
|
case 'u8-planar':
|
|
case 's16-planar':
|
|
case 's32-planar':
|
|
case 'f32-planar':
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const getReadFunction = (format: AudioSampleFormat): (view: DataView, offset: number) => number => {
|
|
switch (format) {
|
|
case 'u8':
|
|
case 'u8-planar':
|
|
return (view, offset) => (view.getUint8(offset) - 128) / 128;
|
|
case 's16':
|
|
case 's16-planar':
|
|
return (view, offset) => view.getInt16(offset, true) / 32768;
|
|
case 's32':
|
|
case 's32-planar':
|
|
return (view, offset) => view.getInt32(offset, true) / 2147483648;
|
|
case 'f32':
|
|
case 'f32-planar':
|
|
return (view, offset) => view.getFloat32(offset, true);
|
|
}
|
|
};
|
|
|
|
const getWriteFunction = (format: AudioSampleFormat): (view: DataView, offset: number, value: number) => void => {
|
|
switch (format) {
|
|
case 'u8':
|
|
case 'u8-planar':
|
|
return (view, offset, value) =>
|
|
view.setUint8(offset, clamp((value + 1) * 127.5, 0, 255));
|
|
case 's16':
|
|
case 's16-planar':
|
|
return (view, offset, value) =>
|
|
view.setInt16(offset, clamp(Math.round(value * 32767), -32768, 32767), true);
|
|
case 's32':
|
|
case 's32-planar':
|
|
return (view, offset, value) =>
|
|
view.setInt32(offset, clamp(Math.round(value * 2147483647), -2147483648, 2147483647), true);
|
|
case 'f32':
|
|
case 'f32-planar':
|
|
return (view, offset, value) => view.setFloat32(offset, value, true);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|