Files
mediabunny/src/sample.ts
T

3325 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 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,
normalizeRotation,
roundToMultiple,
arrayArgmin,
MaybePromise,
DeepReadonly,
} from './misc';
import { Logging } from './logging';
polyfillSymbolDispose();
type FinalizationRegistryValue = {
type: 'video';
data: VideoFrame | OffscreenCanvas | Uint8Array | VideoSampleResource;
} | {
type: 'audio';
data: AudioData | Uint8Array | AudioSampleResource;
};
// 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 = performance.now();
if (value.type === 'video') {
if (now - lastVideoGcErrorLog >= 1000) {
// This error is annoying but oh so important
Logging._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) {
Logging._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();
}
}
});
}
/**
* Abstract base class for custom video sample resources. Implement this class to provide custom backing
* for VideoSample instances.
* @group Samples
* @public
*/
export abstract class VideoSampleResource {
/** @internal */
_referenceCount: number = 0;
/** @internal */
_lastAllocationBuffer: ArrayBuffer | null = null;
/**
* Returns the internal pixel format in which the frame is stored.
* [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format)
*/
abstract getFormat(): VideoSamplePixelFormat | null;
/** Returns the width of the frame in pixels. */
abstract getCodedWidth(): number;
/** Returns the height of the frame in pixels. */
abstract getCodedHeight(): number;
/** Returns the width of the frame in square pixels, respecting pixel aspect ratio. */
abstract getSquarePixelWidth(): number;
/** Returns the height of the frame in square pixels, respecting pixel aspect ratio. */
abstract getSquarePixelHeight(): number;
/** Returns the color space of the frame. */
abstract getColorSpace(): VideoSampleColorSpace;
/**
* Closes this resource, releasing held resources. Called automatically when the last {@link VideoSample} using this
* resource is closed.
*/
abstract close(): void;
/**
* Returns the data planes that hold the video data for this sample. The returned planes and data must be in the
* format returned by `getFormat()`.
*/
abstract getDataPlanes(): MaybePromise<VideoDataPlane[]>;
/**
* Returns a new RGB {@link VideoSample} that contains the same content as this sample. The provided `init` object
* must be used to set the metadata of this new video sample. When converting from a non-RGB format to RGB, the
* conversion must respect `colorSpace`.
*/
abstract toRgbSample(
init: SetRequired<VideoSampleInit, 'timestamp'>,
colorSpace: PredefinedColorSpace,
): MaybePromise<VideoSample>;
}
/**
* Describes a single data plane of a video frame.
* @group Samples
* @public
*/
export type VideoDataPlane = {
/** The data of the plane. */
data: Uint8Array;
/** The stride of the plane, in bytes. This is the distance in bytes between the start of each row of pixels. */
stride: number;
};
/**
* 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;
/** The encode options to use when this sample is passed to an encoder. */
encodeOptions?: DeepReadonly<VideoEncoderEncodeOptions>;
/** @internal */
_doNotCopy?: boolean;
};
/**
* 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 | VideoSampleResource | 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 `VideoSamplePixelFormat`.
* [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 (respecting pixel aspect ratio), before rotation is applied. */
readonly squarePixelWidth!: number;
/** The height of the frame in square pixels (respecting pixel aspect ratio), 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 encode options to use when this sample is passed to an encoder. */
readonly encodeOptions!: DeepReadonly<VideoEncoderEncodeOptions>;
/** 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'>
);
/**
* Creates a new {@link VideoSample} backed by a custom {@link VideoSampleResource}.
*/
constructor(resource: VideoSampleResource, init: SetRequired<VideoSampleInit, 'timestamp'>);
constructor(
data: VideoFrame | CanvasImageSource | AllowSharedBufferSource | VideoSampleResource,
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.format = init.format;
this.rotation = init.rotation ?? 0;
this.timestamp = init.timestamp!;
this.duration = init.duration ?? 0;
const layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth!, init.codedHeight!);
let colorSpaceInit = init.colorSpace ?? null;
if (colorSpaceInit === null) {
if (
this.format === 'RGBA' || this.format === 'RGBX'
|| this.format === 'BGRA' || this.format === 'BGRX'
) {
// sRGB Color Space
colorSpaceInit = {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
};
} else {
// REC709 Color Space
colorSpaceInit = {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
fullRange: false,
};
}
}
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.visibleRect.width;
this.squarePixelHeight = this.visibleRect.height;
}
// As an optimization, one could check if VideoFrame is defined and if it is, create a VideoFrame here from
// the data. Since VideoFrames are typically needed anyway, doing it this way would avoid an additional
// copy of the frame data. But due to https://issues.chromium.org/issues/529413114, this is currently
// not done.
this._data = init._doNotCopy
? toUint8Array(data)
: toUint8Array(data).slice(); // Copy it
this._layout = layout;
this.colorSpace = new VideoSampleColorSpace(colorSpaceInit);
} 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 (init.visibleRect !== undefined) {
validateRectangle(init.visibleRect, 'init.visibleRect');
}
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,
// WebCodecs wants DOMRectInit
visibleRect: init.visibleRect && {
x: init.visibleRect.left,
y: init.visibleRect.top,
width: init.visibleRect.width,
height: init.visibleRect.height,
},
}),
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 visibleRect = init.visibleRect ?? { left: 0, top: 0, width, height };
const canvas = new OffscreenCanvas(visibleRect.width, visibleRect.height);
const context = canvas.getContext('2d', {
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
willReadFrequently: true,
});
if (!context) {
throw new Error(
'OffscreenCanvas must have support for the \'2d\' context in order to create a VideoSample from'
+ ' this data.',
);
}
// Draw it to a canvas, cropped to the visible rect
context.drawImage(data, -visibleRect.left, -visibleRect.top);
this._data = canvas;
this._layout = null;
this.format = 'RGBX';
this.visibleRect = { left: 0, top: 0, width: visibleRect.width, height: visibleRect.height };
this.squarePixelWidth = visibleRect.width;
this.squarePixelHeight = visibleRect.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 if (data instanceof VideoSampleResource) {
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.');
}
this._data = data;
data._referenceCount++;
this.format = data.getFormat();
if (this.format !== null && !VIDEO_SAMPLE_PIXEL_FORMATS.includes(this.format)) {
throw new TypeError('getFormat() must return a VideoSamplePixelFormat or null.');
}
this.visibleRect = {
left: 0,
top: 0,
width: data.getCodedWidth(),
height: data.getCodedHeight(),
};
if (!Number.isInteger(this.visibleRect.width) || this.visibleRect.width <= 0) {
throw new TypeError('getCodedWidth() must return a positive integer.');
}
if (!Number.isInteger(this.visibleRect.height) || this.visibleRect.height <= 0) {
throw new TypeError('getCodedHeight() must return a positive integer.');
}
this.squarePixelWidth = data.getSquarePixelWidth();
if (!Number.isInteger(this.squarePixelWidth) || this.squarePixelWidth <= 0) {
throw new TypeError('getSquarePixelWidth() must return a positive integer.');
}
this.squarePixelHeight = data.getSquarePixelHeight();
if (!Number.isInteger(this.squarePixelHeight) || this.squarePixelHeight <= 0) {
throw new TypeError('getSquarePixelHeight() must return a positive integer.');
}
this.rotation = init.rotation ?? 0;
this.timestamp = init.timestamp!;
this.duration = init.duration ?? 0;
this.colorSpace = data.getColorSpace();
} else {
throw new TypeError(
'Invalid data type: Must be a BufferSource, CanvasImageSource, or VideoSampleResource.',
);
}
this.encodeOptions = init?.encodeOptions ?? {};
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 (this._data instanceof VideoSampleResource) {
return new VideoSample(this._data, {
timestamp: this.timestamp,
duration: this.duration,
rotation: this.rotation,
encodeOptions: this.encodeOptions,
});
} else if (isVideoFrame(this._data)) {
return new VideoSample(this._data.clone(), {
timestamp: this.timestamp,
duration: this.duration,
rotation: this.rotation,
encodeOptions: this.encodeOptions,
});
} 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,
encodeOptions: this.encodeOptions,
// It's already been copied, if we copy it again we make the clone unnecessarily expensive
_doNotCopy: true,
});
} 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,
encodeOptions: this.encodeOptions,
});
}
}
/**
* 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 (this._data instanceof VideoSampleResource) {
this._data._referenceCount--;
if (this._data._referenceCount === 0) {
this._data.close();
}
} else 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.
*/
allocationSize(options: VideoFrameCopyToOptions = {}): number {
validateVideoFrameCopyToOptions(options);
if (this._closed) {
throw new Error('VideoSample is closed.');
}
if ((options.format ?? 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.');
}
if (isVideoFrame(this._data)) {
// Call the native method purely for performance
return this._data.allocationSize(options);
}
const combinedLayout = ParseVideoFrameCopyToOptions(this, options);
return combinedLayout.allocationSize;
}
/**
* Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView.
* @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 ((options.format ?? this.format) == null) {
throw new Error('Cannot copy video sample data when format is null.');
}
assert(this._data !== null);
if (isVideoFrame(this._data)) {
return this._data.copyTo(destination, options);
}
// Detect non-RGB to RGB conversion
if (
options.format
&& !['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(this.format!)
&& ['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format)
) {
// RGB conversion for custom VideoSampleResource
if (this._data instanceof VideoSampleResource) {
using rgbSample = await this._data.toRgbSample(
{
timestamp: this.timestamp,
duration: this.duration,
rotation: this.rotation,
},
options.colorSpace ?? 'srgb',
);
if (!(rgbSample instanceof VideoSample)) {
throw new TypeError('toRgbSample() must return a VideoSample.');
}
if (!['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(rgbSample.format!)) {
throw new Error(
`Sample returned by toRgbSample was expected to have an RGB format, got`
+ ` '${rgbSample.format}' instead.`,
);
}
// Note that we DON'T force the RGB format to be exactly what was requested; any RGB format will do
return await rgbSample.copyTo(destination, options); // 'await' is intentional here cuz of using
} else {
if (typeof VideoFrame === 'undefined') {
throw new Error(
'For this sample, converting from a non-RGB to an RGB format requires VideoFrame to'
+ ' be defined.',
);
}
const tempFrame = this.toVideoFrame();
const result = await tempFrame.copyTo(destination, options);
tempFrame.close();
return result;
}
}
const combinedLayout = ParseVideoFrameCopyToOptions(this, options);
assert(this.format);
// 4. If destination.byteLength is less than combinedLayout’s allocationSize, return a promise rejected with
const destBytes = toUint8Array(destination);
if (destBytes.byteLength < combinedLayout.allocationSize) {
throw new TypeError(
`Destination buffer too small. Required: ${combinedLayout.allocationSize},`
+ ` Available: ${destBytes.byteLength}`,
);
}
const planeConfigs = getPlaneConfigs(this.format);
let dataPlanes: VideoDataPlane[];
if (this._data instanceof VideoSampleResource) {
let result = this._data.getDataPlanes();
if (result instanceof Promise) result = await result;
if (
!Array.isArray(result)
|| result.some(x => !(x.data instanceof Uint8Array) || !Number.isInteger(x.stride) || x.stride < 0)
) {
throw new TypeError(
'getDataPlanes() must return an array of objects with a Uint8Array "data" property and a'
+ ' non-negative integer "stride" property.',
);
}
dataPlanes = result;
} else if (this._data instanceof Uint8Array) {
assert(this._layout);
assert(this._layout.length === planeConfigs.length);
dataPlanes = this._layout.map((planeLayout, i) => {
const height = Math.ceil(this.codedHeight / planeConfigs[i]!.heightDivisor);
return {
data: (this._data as Uint8Array).subarray(
planeLayout.offset,
planeLayout.offset + planeLayout.stride * height,
),
stride: planeLayout.stride,
};
});
} else {
const canvas = this._data;
const context = canvas.getContext('2d');
assert(context); // We already got it earlier so it's definitely available
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
dataPlanes = [{
data: toUint8Array(imageData.data),
stride: 4 * this.codedWidth,
}];
}
// Algo taken from WebCodecs spec:
// 6. Let p be a new Promise. (Implicit)
// 7. Let copyStepsQueue be the result of starting a new parallel queue. (Implicit)
// 8. Let planeLayouts be a new list.
const planeLayouts: PlaneLayout[] = [];
// Enqueue the following steps to copyStepsQueue: (fuck the queuing part)
// Let resource be the media resource referenced by [[resource reference]].
// (this.data)
// Let numPlanes be the number of planes as defined by [[format]].
const numPlanes = planeConfigs.length;
// Let planeIndex be 0.
// While planeIndex is less than combinedLayout’s numPlanes:
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
const computedLayout = combinedLayout.computedLayouts[planeIndex]!;
// Let sourceStride be the stride of the plane in resource as identified by planeIndex.
const sourceStride = dataPlanes[planeIndex]!.stride;
const sourceData = dataPlanes[planeIndex]!.data;
// Let sourceOffset be the product of multiplying computedLayout’s sourceTop by sourceStride
let sourceOffset = computedLayout.sourceTop * sourceStride;
// Add computedLayout’s sourceLeftBytes to sourceOffset.
sourceOffset += computedLayout.sourceLeftBytes;
// Let destinationOffset be computedLayout’s destinationOffset.
let destinationOffset = computedLayout.destinationOffset;
// Let rowBytes be computedLayout’s sourceWidthBytes.
const rowBytes = computedLayout.sourceWidthBytes;
// Let layout be a new PlaneLayout, with offset set to destinationOffset and stride set to rowBytes.
// This is a spec error actually (https://github.com/w3c/webcodecs/issues/918)
const layout: PlaneLayout = {
offset: destinationOffset,
stride: computedLayout.destinationStride,
};
// Let row be 0.
// While row is less than computedLayout’s sourceHeight:
for (let row = 0; row < computedLayout.sourceHeight; row++) {
// Copy rowBytes bytes from resource starting at sourceOffset to destination starting
// at destinationOffset.
if (sourceOffset + rowBytes > sourceData.byteLength) {
throw new Error(`Source buffer OOB read.`);
}
if (destinationOffset + rowBytes > destBytes.byteLength) {
throw new Error(`Destination buffer OOB write.`);
}
const srcSub = sourceData.subarray(sourceOffset, sourceOffset + rowBytes);
destBytes.set(srcSub, destinationOffset);
// Increment sourceOffset by sourceStride.
sourceOffset += sourceStride;
// Increment destinationOffset by computedLayout’s destinationStride.
destinationOffset += computedLayout.destinationStride;
}
// Append layout to planeLayouts.
planeLayouts.push(layout);
}
// Now, handle converting between different RGB formats
if (options.format !== undefined) {
const needsRgbConversion = this.format.startsWith('RGB') !== options.format.startsWith('RGB');
// Going X->A requires setting the alpha to 255, going the other way doesn't since the value of X is w/e
const needsAlphaConversion = this.format.includes('X') && options.format.includes('A');
if (needsRgbConversion || needsAlphaConversion) {
// Loop over the destination bytes
for (let i = 0; i < combinedLayout.allocationSize; i += 4) {
if (needsRgbConversion) {
// Swap R with B
const r = destBytes[i]!;
const b = destBytes[i + 2]!;
destBytes[i] = b;
destBytes[i + 2] = r;
}
if (needsAlphaConversion) {
destBytes[i + 3] = 255;
}
}
}
}
// Queue a task to resolve p with planeLayouts.
return planeLayouts;
}
/**
* 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(): VideoFrame {
if (this._closed) {
throw new Error('VideoSample is closed.');
}
assert(this._data !== null);
if (this._data instanceof VideoSampleResource) {
if (this.format === null) {
throw new Error(
'Cannot convert a VideoSampleResource-backed VideoSample to VideoFrame if format is null.',
);
}
const planes = this._data.getDataPlanes();
if (planes instanceof Promise) {
throw new Error(
'Cannot convert a VideoSampleResource-backed VideoSample to VideoFrame if getDataPlanes() returns'
+ ' a promise.',
);
}
// We can't use allocationSize since that method assumes a tight packing
const size = planes.reduce((a, b) => a + b.data.byteLength, 0);
const buffer = new Uint8Array(size);
let offset = 0;
const offsets: number[] = [];
for (const plane of planes) {
buffer.set(plane.data, offset);
offsets.push(offset);
offset += plane.data.byteLength;
}
return new VideoFrame(buffer, {
format: this.format as VideoPixelFormat,
layout: planes.map((x, i) => ({
offset: offsets[i]!,
stride: x.stride,
})),
codedWidth: this.codedWidth,
codedHeight: this.codedHeight,
timestamp: this.microsecondTimestamp,
duration: this.microsecondDuration,
colorSpace: this.colorSpace,
visibleRect: this.visibleRect,
displayWidth: this.squarePixelWidth, // Not display* since we're not passing rotation
displayHeight: this.squarePixelHeight,
});
} else 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) {
assert(this._layout);
return new VideoFrame(this._data, {
format: this.format! as VideoPixelFormat,
codedWidth: this.codedWidth, // This is technically wrong! codedWidth is a lie technically. But, since
codedHeight: this.codedHeight, // we pass the layout (which contains the true coded width), we're good.
layout: this._layout,
timestamp: this.microsecondTimestamp,
duration: this.microsecondDuration || undefined,
colorSpace: this.colorSpace,
visibleRect: this.visibleRect,
displayWidth: this.squarePixelWidth, // Not display* since we're not passing rotation
displayHeight: this.squarePixelHeight,
});
} 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];
let finalCrop = options.crop;
if (finalCrop) {
finalCrop = clampCropRectangle(finalCrop, 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 };
}
/**
* Draws the sample onto the target canvas with fit behavior, manually mipmapping on strong downscales for quality.
* @internal
*/
_drawWithFitAndMipmapping(
targetCanvas: HTMLCanvasElement | OffscreenCanvas,
targetContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
options: {
fit: 'fill' | 'contain' | 'cover';
rotation: Rotation;
crop: CropRectangle | undefined;
/** Freshly-created target canvases carry no stale pixels and don't need to be cleared. */
targetIsFresh: boolean;
/** Paints on a black background instead of transparency (for alpha discard or opaque emulation). */
fillBlack: boolean;
},
) {
const targetWidth = targetCanvas.width;
const targetHeight = targetCanvas.height;
const [rotatedWidth, rotatedHeight] = options.rotation % 180 === 0
? [this.squarePixelWidth, this.squarePixelHeight]
: [this.squarePixelHeight, this.squarePixelWidth];
const sourceWidth = options.crop ? options.crop.width : rotatedWidth;
const sourceHeight = options.crop ? options.crop.height : rotatedHeight;
// Once we downscale by more than 2x, bilinear filtering starts skipping pixels and the result aliases. So in
// that case, we do manual mipmapping: draw at a larger size first, then iteratively halve the image until we
// arrive at the target size. Sucks that we have to do this honestly, but imageSmoothingQuality is very flaky -
// some resources are just not mipmapped in the browser and thus always fall back to bilinear downsampling.
let mipLevels = 0;
if (2 * targetWidth < sourceWidth && 2 * targetHeight < sourceHeight) {
mipLevels = Math.floor(Math.log2(
Math.min(sourceWidth / targetWidth, sourceHeight / targetHeight),
));
}
const drawWidth = targetWidth * 2 ** mipLevels;
const drawHeight = targetHeight * 2 ** mipLevels;
const { canvas, context, isNew } = mipLevels > 0
? getTransformationCanvas(drawWidth, drawHeight)
: { canvas: targetCanvas, context: targetContext, isNew: options.targetIsFresh };
context.imageSmoothingQuality = 'high';
if (options.fillBlack) {
context.fillStyle = 'black';
context.fillRect(0, 0, drawWidth, drawHeight);
} else if (!isNew) {
// Reused canvases carry stale pixels from a prior draw
context.clearRect(0, 0, drawWidth, drawHeight);
}
this.drawWithFit(context, {
fit: options.fit,
rotation: options.rotation,
crop: options.crop,
});
// Walk down the mip chain by repeatedly drawing the canvas onto itself at half size. The 'copy' composite
// operation makes sure pixels get replaced instead of blended with what's already there.
context.globalCompositeOperation = 'copy';
for (let i = mipLevels; i > 1; i--) {
const levelWidth = targetWidth * 2 ** i;
const levelHeight = targetHeight * 2 ** i;
context.drawImage(canvas, 0, 0, levelWidth, levelHeight, 0, 0, levelWidth / 2, levelHeight / 2);
}
context.globalCompositeOperation = 'source-over';
if (mipLevels > 0) {
// We'd love to skip this step and hand over the oversized mip canvas cropped with visibleRect, but sadly,
// all browsers ignore a frame's visibleRect when encoding it and consume the full coded frame instead
// (violating the WebCodecs spec, I think? See https://issues.chromium.org/issues/543284189). So, the result
// must live on a canvas of exactly the target size, which is why the last halving step draws onto the
// target canvas directly. 'copy' also conveniently disposes of any stale pixels there.
targetContext.imageSmoothingQuality = 'high';
targetContext.globalCompositeOperation = 'copy';
targetContext.drawImage(
canvas,
0, 0, 2 * targetWidth, 2 * targetHeight,
0, 0, targetWidth, targetHeight,
);
targetContext.globalCompositeOperation = 'source-over';
}
}
/**
* 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 may
* 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 VideoSampleResource || 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;
}
}
/**
* Transform this video sample to a new video sample given the options. Can be used to resize, rotate, and crop
* the sample.
*
* In non-browser environments, this method will not work by default. To make it work, register a custom
* transformer function via {@link registerVideoSampleTransformer}.
*/
async transform(options: VideoSampleTransformOptions) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.width !== undefined && (!Number.isInteger(options.width) || options.width <= 0)) {
throw new TypeError('options.width, when provided, must be a positive integer.');
}
if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) {
throw new TypeError('options.height, when provided, must be a positive integer.');
}
if (
options.roundDimensionsTo !== undefined
&& (!Number.isInteger(options.roundDimensionsTo) || options.roundDimensionsTo <= 0)
) {
throw new TypeError('options.roundDimensionsTo, when provided, must be a positive integer.');
}
if (options.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.fit)) {
throw new TypeError('options.fit, when provided, must be one of "fill", "contain", or "cover".');
}
if (
options.width !== undefined
&& options.height !== undefined
&& options.fit === undefined
) {
throw new TypeError(
'When both options.width and options.height are provided, options.fit must also be provided.',
);
}
if (options.rotate !== undefined && ![0, 90, 180, 270].includes(options.rotate)) {
throw new TypeError('options.rotate, when provided, must be 0, 90, 180 or 270.');
}
if (options.crop !== undefined) {
validateCropRectangle(options.crop, 'options.');
}
if (options.alpha !== undefined && !['keep', 'discard'].includes(options.alpha)) {
throw new TypeError('options.alpha, when provided, must be \'keep\' or \'discard\'.');
}
const rotation = normalizeRotation(this.rotation + (options.rotate ?? 0));
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
? [this.squarePixelWidth, this.squarePixelHeight]
: [this.squarePixelHeight, this.squarePixelWidth];
// Clamp crop rectangle to the rotated video dimensions
let finalCrop = options.crop;
if (finalCrop) {
finalCrop = clampCropRectangle(finalCrop, rotatedWidth, rotatedHeight);
}
const cropWidth = finalCrop ? finalCrop.width : rotatedWidth;
const cropHeight = finalCrop ? finalCrop.height : rotatedHeight;
const originalAspectRatio = cropWidth / cropHeight;
let targetWidth: number;
let targetHeight: number;
if (options.width !== undefined && options.height === undefined) {
targetWidth = options.width;
targetHeight = targetWidth / originalAspectRatio;
} else if (options.width === undefined && options.height !== undefined) {
targetHeight = options.height;
targetWidth = targetHeight * originalAspectRatio;
} else if (options.width !== undefined && options.height !== undefined) {
targetWidth = options.width;
targetHeight = options.height;
} else {
targetWidth = cropWidth;
targetHeight = cropHeight;
}
targetWidth = roundToMultiple(targetWidth, options.roundDimensionsTo ?? 1);
targetHeight = roundToMultiple(targetHeight, options.roundDimensionsTo ?? 1);
const description: VideoSampleTransformationDescription = {
width: targetWidth,
height: targetHeight,
fit: options.fit ?? 'fill',
rotation,
crop: finalCrop ?? {
left: 0,
top: 0,
width: rotatedWidth,
height: rotatedHeight,
},
alpha: options.alpha ?? 'keep',
};
// Description's finalized; let's see if a registered transformer wants to handle it
for (const transformer of registeredVideoSampleTransformers) {
let result = transformer(this, description);
if (result instanceof Promise) result = await result;
if (result !== null) {
return result;
}
}
// We need to handle it ourselves, and we use canvases to do it
const { canvas, context, isNew } = getTransformationCanvas(description.width, description.height);
this._drawWithFitAndMipmapping(canvas, context, {
fit: description.fit,
rotation: description.rotation,
crop: description.crop,
targetIsFresh: isNew,
fillBlack: description.alpha === 'discard',
});
return new VideoSample(canvas, {
timestamp: this.timestamp,
duration: this.duration,
rotation: 0, // Any previous rotation is now baked in
});
}
/** 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;
}
/** Sets the encode options used when this sample is passed to an encoder. */
setEncodeOptions(newEncodeOptions: VideoEncoderEncodeOptions) {
if (!newEncodeOptions || typeof newEncodeOptions !== 'object') {
throw new TypeError('newEncodeOptions must be an object.');
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
(this.encodeOptions as DeepReadonly<VideoEncoderEncodeOptions>) = newEncodeOptions;
}
/** Calls `.close()`. */
[Symbol.dispose]() {
this.close();
}
}
/**
* Options for transforming a {@link VideoSample}. The order of operations are:
*
* 1. Pixel aspect ratio normalization (always applied)
* 2. Rotation
* 3. Crop
* 4. Resize using fit
* @group Samples
* @public
*/
export type VideoSampleTransformOptions = {
/**
* The width in pixels to resize the frames to. If height is not set, it will be deduced
* automatically based on aspect ratio.
*/
width?: number;
/**
* The height in pixels to resize the frames to. If width is not set, it will be deduced
* automatically based on aspect ratio.
*/
height?: number;
/**
* A positive integer. When provided, both the width and height will be rounded to the nearest multiple of
* this number.
*/
roundDimensionsTo?: number;
/**
* The fitting algorithm in case both width and height are set.
*
* - `'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';
/**
* The clockwise rotation by which to rotate the frames. Rotation is applied before resizing.
*/
rotate?: Rotation;
/**
* Specifies the rectangular region of the frames to crop to. The crop region will automatically be
* clamped to the dimensions of the frame. Cropping is performed after rotation but before resizing.
*/
crop?: CropRectangle;
/**
* Whether to discard or keep the transparency information of the video sample. The default is `'keep'`.
*/
alpha?: 'keep' | 'discard';
};
/**
* A fully-resolved description of a video sample transformation, with all defaults and constraints baked in.
*
* The order of operations must be:
* 1. Pixel aspect ratio normalization (always applied)
* 2. Rotation
* 3. Crop
* 4. Resize using fit
* @group Samples
* @public
*/
export type VideoSampleTransformationDescription = {
/** The width in pixels to resize the frames to. */
width: number;
/** The height in pixels to resize the frames to. */
height: number;
/**
* 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';
/** The clockwise rotation by which to rotate the frames. Rotation is applied before resizing. */
rotation: Rotation;
/**
* The rectangular region of the frames to crop to, clamped to the dimensions of the frame. Cropping is
* performed after rotation but before resizing.
*/
crop: CropRectangle;
/** Whether to discard or keep the transparency information of the video sample. */
alpha: 'keep' | 'discard';
};
const registeredVideoSampleTransformers: ((
sample: VideoSample,
description: VideoSampleTransformationDescription,
) => MaybePromise<VideoSample | null>)[] = [];
/**
* Registers a callback to handle the transformation of {@link VideoSample} instances. The callback can either return
* the transformed sample, or `null` to indicate that it doesn't want to handle the given transformation task.
* @group Samples
* @public
*/
export const registerVideoSampleTransformer = (
transformer: (
sample: VideoSample,
description: VideoSampleTransformationDescription,
) => MaybePromise<VideoSample | null>,
) => {
if (registeredVideoSampleTransformers.includes(transformer)) {
return; // Already in there
}
registeredVideoSampleTransformers.push(transformer);
};
const TRANSFORMATION_CANVAS_CACHE_MAX_SIZE = 3;
const transformationCanvasCache: {
canvas: HTMLCanvasElement | OffscreenCanvas;
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
age: number;
}[] = [];
let transformationCanvasCacheNextAge = 0;
const getTransformationCanvas = (width: number, height: number) => {
for (const entry of transformationCanvasCache) {
if (entry.canvas.width === width && entry.canvas.height === height) {
entry.age = transformationCanvasCacheNextAge++;
return { canvas: entry.canvas, context: entry.context, isNew: false };
}
}
let canvas: HTMLCanvasElement | OffscreenCanvas;
if (typeof OffscreenCanvas !== 'undefined') {
canvas = new OffscreenCanvas(width, height);
} else {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error(
'Cannot transform VideoSamples in this environment. Either run in an environment with'
+ ' OffscreenCanvas or HTMLCanvasElement, or supply a custom VideoSample transformer using'
+ ' registerVideoSampleTransformer().',
);
}
canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
}
const context = canvas.getContext('2d', {
alpha: true,
willReadFrequently: false,
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
if (!context) {
throw new Error(
'The \'2d\' canvas context is required to transform VideoSamples. Register a custom transformer using'
+ ' registerVideoSampleTransformer to work around this limitation.',
);
}
if (transformationCanvasCache.length >= TRANSFORMATION_CANVAS_CACHE_MAX_SIZE) {
transformationCanvasCache.splice(arrayArgmin(transformationCanvasCache, x => x.age), 1);
}
transformationCanvasCache.push({
canvas,
context,
age: transformationCanvasCacheNextAge++,
});
return { canvas, context, isNew: true };
};
/**
* 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): CropRectangle => {
const left = Math.min(crop.left, outerWidth);
const top = Math.min(crop.top, outerHeight);
const width = Math.min(crop.width, outerWidth - left);
const height = Math.min(crop.height, outerHeight - top);
assert(width >= 0);
assert(height >= 0);
return { left, top, width, height };
};
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. */
export 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);
}
};
type CombinedBufferLayout = {
allocationSize: number;
computedLayouts: ComputedPlaneLayout[];
};
type ComputedPlaneLayout = {
destinationOffset: number;
destinationStride: number;
sourceTop: number;
sourceHeight: number;
sourceLeftBytes: number;
sourceWidthBytes: number;
};
/** Taken from the WebCodecs spec. */
const ParseVideoFrameCopyToOptions = (
sample: VideoSample,
options: VideoFrameCopyToOptions,
): CombinedBufferLayout => {
// 1. Let defaultRect be the result of performing the getter steps for visibleRect.
const defaultRect: Rectangle = {
left: 0,
top: 0,
width: sample.codedWidth,
height: sample.codedHeight,
};
// 2. Let overrideRect be undefined.
// 3. If options.rect exists, assign the value of options.rect to overrideRect.
const overrideRect = options.rect;
// 4. Let parsedRect be the result of running the Parse Visible Rect algorithm...
const parsedRect = ParseVisibleRect(
defaultRect,
overrideRect,
sample.codedWidth,
sample.codedHeight,
sample.format,
);
// 5. If parsedRect is an exception, return parsedRect. (Handled by throw)
// 6. Let optLayout be undefined.
// 7. If options.layout exists, assign its value to optLayout.
const optLayout = options.layout;
// 8. Let format be undefined.
let format: VideoSamplePixelFormat | undefined;
// 9. If options.format does not exist, assign [[format]] to format.
if (!options.format || options.format === sample.format) {
format = sample.format!;
} else if (['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format)) {
// 10. Otherwise, if options.format is equal to one of RGBA, RGBX, BGRA, BGRX, then assign options.format
// to format...
format = options.format;
} else {
throw new Error('NotSupportedError: Invalid destination format.');
}
// 11. Let combinedLayout be the result of running the Compute Layout and Allocation Size algorithm...
return ComputeLayoutAndAllocationSize(parsedRect, format, optLayout);
};
/** Taken from the WebCodecs spec. */
const ParseVisibleRect = (
defaultRect: DOMRectInit,
overrideRect: DOMRectInit | undefined,
codedWidth: number,
codedHeight: number,
format: VideoSamplePixelFormat | null,
): DOMRectInit => {
// 1. Let sourceRect be defaultRect
const sourceRect = { ...defaultRect };
// 2. If overrideRect is not undefined:
if (overrideRect !== undefined) {
// If either of overrideRect.width or height is 0, return a TypeError.
if (overrideRect.width === 0 || overrideRect.height === 0) {
throw new TypeError('visibleRect dimensions cannot be zero.');
}
// If the sum of overrideRect.x and overrideRect.width is greater than codedWidth, return a TypeError.
if ((overrideRect.x || 0) + (overrideRect.width || 0) > codedWidth) {
throw new TypeError('visibleRect exceeds codedWidth.');
}
// If the sum of overrideRect.y and overrideRect.height is greater than codedHeight, return a TypeError.
if ((overrideRect.y || 0) + (overrideRect.height || 0) > codedHeight) {
throw new TypeError('visibleRect exceeds codedHeight.');
}
// Assign overrideRect to sourceRect.
sourceRect.x = overrideRect.x || 0;
sourceRect.y = overrideRect.y || 0;
sourceRect.width = overrideRect.width || 0;
sourceRect.height = overrideRect.height || 0;
}
// 3. Let validAlignment be the result of running the Verify Rect Offset Alignment algorithm.
const validAlignment = VerifyRectOffsetAlignment(format, sourceRect);
// 4. If validAlignment is false, throw a TypeError.
if (!validAlignment) {
throw new TypeError('visibleRect alignment is invalid for the format.');
}
// 5. Return sourceRect.
return sourceRect;
};
/** Taken from the WebCodecs spec. */
const VerifyRectOffsetAlignment = (format: VideoSamplePixelFormat | null, rect: DOMRectInit): boolean => {
// 1. If format is null, return true.
if (format === null) return true;
const planes = getPlaneConfigs(format);
// 2. Let planeIndex be 0.
// 3. Let numPlanes be the number of planes as defined by format.
// 4. While planeIndex is less than numPlanes:
for (let planeIndex = 0; planeIndex < planes.length; planeIndex++) {
const plane = planes[planeIndex]!;
const sampleWidth = plane.widthDivisor;
const sampleHeight = plane.heightDivisor;
// If rect.x is not a multiple of sampleWidth, return false.
if ((rect.x || 0) % sampleWidth !== 0) return false;
// If rect.y is not a multiple of sampleHeight, return false.
if ((rect.y || 0) % sampleHeight !== 0) return false;
}
return true;
};
/** Taken from the WebCodecs spec. */
const ComputeLayoutAndAllocationSize = (
parsedRect: DOMRectInit,
format: VideoSamplePixelFormat,
layout?: PlaneLayout[],
): CombinedBufferLayout => {
const planes = getPlaneConfigs(format);
// 1. Let numPlanes be the number of planes as defined by format.
const numPlanes = planes.length;
// 2. If layout is not undefined and its length does not equal numPlanes, throw a TypeError.
if (layout !== undefined && layout.length !== numPlanes) {
throw new TypeError(`Layout must have ${numPlanes} planes.`);
}
// 3. Let minAllocationSize be 0.
let minAllocationSize = 0;
// 4. Let computedLayouts be a new list.
const computedLayouts: ComputedPlaneLayout[] = [];
// 5. Let endOffsets be a new list.
const endOffsets: number[] = [];
// 6. Let planeIndex be 0.
// 7. While planeIndex < numPlanes:
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
const plane = planes[planeIndex]!;
const sampleBytes = plane.sampleBytes;
const sampleWidth = plane.widthDivisor;
const sampleHeight = plane.heightDivisor;
// Let computedLayout be a new computed plane layout.
const computedLayout: ComputedPlaneLayout = {
destinationOffset: 0,
destinationStride: 0,
sourceTop: 0,
sourceHeight: 0,
sourceLeftBytes: 0,
sourceWidthBytes: 0,
};
// Set computedLayout’s sourceTop...
computedLayout.sourceTop = Math.ceil(Math.trunc(parsedRect.y || 0) / sampleHeight);
// Set computedLayout’s sourceHeight...
computedLayout.sourceHeight = Math.ceil(Math.trunc(parsedRect.height || 0) / sampleHeight);
// Set computedLayout’s sourceLeftBytes...
computedLayout.sourceLeftBytes = Math.floor(Math.trunc(parsedRect.x || 0) / sampleWidth) * sampleBytes;
// Set computedLayout’s sourceWidthBytes...
computedLayout.sourceWidthBytes = Math.floor(Math.trunc(parsedRect.width || 0) / sampleWidth) * sampleBytes;
// If layout is not undefined:
if (layout !== undefined) {
const planeLayout = layout[planeIndex]!;
// If planeLayout.stride is less than computedLayout’s sourceWidthBytes, return a TypeError.
if (planeLayout.stride < computedLayout.sourceWidthBytes) {
throw new TypeError(`Stride for plane ${planeIndex} is too small.`);
}
// Assign planeLayout.offset to computedLayout’s destinationOffset.
computedLayout.destinationOffset = planeLayout.offset;
// Assign planeLayout.stride to computedLayout’s destinationStride.
computedLayout.destinationStride = planeLayout.stride;
} else {
// Otherwise:
// Assign minAllocationSize to computedLayout’s destinationOffset.
computedLayout.destinationOffset = minAllocationSize;
// Assign computedLayout’s sourceWidthBytes to computedLayout’s destinationStride.
computedLayout.destinationStride = computedLayout.sourceWidthBytes;
}
// Let planeSize be the product of multiplying computedLayout’s destinationStride and sourceHeight.
const planeSize = computedLayout.destinationStride * computedLayout.sourceHeight;
// Let planeEnd be the sum of planeSize and computedLayout’s destinationOffset.
const planeEnd = planeSize + computedLayout.destinationOffset;
// If planeSize or planeEnd is greater than maximum range of unsigned long, return a TypeError.
if (planeEnd > 4294967295) {
throw new TypeError('Allocation size exceeds limit.');
}
// Append planeEnd to endOffsets.
endOffsets.push(planeEnd);
// Assign the maximum of minAllocationSize and planeEnd to minAllocationSize.
minAllocationSize = Math.max(minAllocationSize, planeEnd);
// Check for overlap
for (let earlierPlaneIndex = 0; earlierPlaneIndex < planeIndex; earlierPlaneIndex++) {
const earlierLayout = computedLayouts[earlierPlaneIndex]!;
// If plane A ends before plane B starts, they do not overlap.
if (
endOffsets[planeIndex]! <= earlierLayout.destinationOffset
|| endOffsets[earlierPlaneIndex]! <= computedLayout.destinationOffset
) {
continue;
}
throw new TypeError('Planes overlap.');
}
computedLayouts.push(computedLayout);
}
// 12. Return combinedLayout.
return {
allocationSize: minAllocationSize,
computedLayouts: computedLayouts,
};
};
const AUDIO_SAMPLE_FORMATS = new Set<AudioSampleFormat>(
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
);
/**
* Abstract base class for custom audio sample resources. Implement this class to provide custom backing
* for AudioSample instances.
* @group Samples
* @public
*/
export abstract class AudioSampleResource {
/** @internal */
_referenceCount: number = 0;
/**
* Returns the audio sample format.
* [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format)
*/
abstract getFormat(): AudioSampleFormat;
/** Returns the audio sample rate in hertz. */
abstract getSampleRate(): number;
/** Returns the number of audio frames in the sample, per channel. */
abstract getNumberOfFrames(): number;
/** Returns the number of audio channels. */
abstract getNumberOfChannels(): number;
/** Returns the presentation timestamp of the sample in seconds. */
abstract getTimestamp(): number;
/**
* Closes this resource, releasing held resources. Called automatically when the last {@link AudioSample} using this
* resource is closed.
*/
abstract close(): void;
/**
* Returns the audio sample data for the plane given by `planeIndex`. The audio data must be in the format returned
* by `getFormat()`. For interleaved formats, there is only one plane.
*/
abstract getDataPlane(planeIndex: number): Uint8Array;
}
/**
* 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 | AudioSampleResource;
/** @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 | AudioSampleResource);
constructor(init: AudioData | AudioSampleInit | AudioSampleResource) {
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 instanceof AudioSampleResource) {
this._data = init;
init._referenceCount++;
this.format = init.getFormat();
if (!AUDIO_SAMPLE_FORMATS.has(this.format)) {
throw new TypeError('getFormat() must return an AudioSampleFormat.');
}
this.sampleRate = init.getSampleRate();
if (!Number.isInteger(this.sampleRate) || this.sampleRate <= 0) {
throw new TypeError('getSampleRate() must return a positive integer.');
}
this.numberOfFrames = init.getNumberOfFrames();
if (!Number.isInteger(this.numberOfFrames) || this.numberOfFrames < 0) {
throw new TypeError('getNumberOfFrames() must return a non-negative integer.');
}
this.numberOfChannels = init.getNumberOfChannels();
if (!Number.isInteger(this.numberOfChannels) || this.numberOfChannels <= 0) {
throw new TypeError('getNumberOfChannels() must return a positive integer.');
}
this.timestamp = init.getTimestamp();
if (!Number.isFinite(this.timestamp)) {
throw new TypeError('getTimestamp() must return a finite number.');
}
this.duration = this.numberOfFrames / this.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 { format, frameCount: optFrameCount, frameOffset: optFrameOffset } = options;
let { planeIndex } = 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 readFn = getReadFunction(srcFormat);
const srcBytesPerSample = getBytesPerSample(srcFormat);
const srcIsPlanar = formatIsPlanar(srcFormat);
let uint8Data: Uint8Array;
if (this._data instanceof AudioSampleResource) {
const getDataPlaneValidated = (index: number) => {
const result = (this._data as AudioSampleResource).getDataPlane(index);
if (!(result instanceof Uint8Array)) {
throw new TypeError('getDataPlane() must return a Uint8Array.');
}
const expectedSize = numFrames * srcBytesPerSample * (srcIsPlanar ? 1 : numChannels);
if (result.byteLength !== expectedSize) {
throw new TypeError(
`Data plane ${index} has invalid size. Expected exactly ${expectedSize} bytes, got`
+ ` ${result.byteLength} bytes.`,
);
}
return result;
};
if (srcIsPlanar) {
if (destIsPlanar) {
// Only one source plane will be extracted, so let's fetch only that one
uint8Data = getDataPlaneValidated(planeIndex);
planeIndex = 0; // To fix the subsequent access
} else {
// Pack all planes tightly together
uint8Data = new Uint8Array(numFrames * srcBytesPerSample * numChannels);
for (let ch = 0; ch < numChannels; ch++) {
const planeData = getDataPlaneValidated(ch);
uint8Data.set(planeData, ch * numFrames * srcBytesPerSample);
}
}
} else {
uint8Data = getDataPlaneValidated(0); // That's the only plane there is
}
} else {
uint8Data = this._data;
}
const srcView = toDataView(uint8Data);
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 (this._data instanceof AudioSampleResource) {
const sample = new AudioSample(this._data);
sample.setTimestamp(this.timestamp); // Make sure the timestamp is correct
return sample;
} else if (isAudioData(this._data)) {
const sample = new AudioSample(this._data.clone());
sample.setTimestamp(this.timestamp);
return sample;
} else {
return new AudioSample({
format: this.format,
sampleRate: this.sampleRate,
numberOfFrames: this.numberOfFrames,
numberOfChannels: this.numberOfChannels,
timestamp: this.timestamp,
data: this._data,
});
}
}
/**
* Returns a new {@link AudioSample} containing only the frames in the range [startSample, endSample). Both bounds
* must lie within this sample's range of frames. The returned sample's timestamp is shifted to match the start of
* the trimmed section.
*/
trim(startSample: number, endSample = this.numberOfFrames) {
if (!Number.isInteger(startSample) || startSample < 0) {
throw new TypeError('startSample must be a non-negative integer.');
}
if (!Number.isInteger(endSample) || endSample < 0) {
throw new TypeError('endSample must be a non-negative integer.');
}
if (startSample > this.numberOfFrames) {
throw new RangeError('startSample out of range.');
}
if (endSample > this.numberOfFrames) {
throw new RangeError('endSample out of range.');
}
if (endSample < startSample) {
throw new RangeError('endSample must not be less than startSample.');
}
if (this._closed) {
throw new Error('AudioSample is closed.');
}
const frameCount = endSample - startSample;
const bytesPerSample = getBytesPerSample(this.format);
let data: Uint8Array;
if (formatIsPlanar(this.format)) {
const planeSize = frameCount * bytesPerSample;
data = new Uint8Array(planeSize * this.numberOfChannels);
if (frameCount > 0) {
// Copy plane-by-plane
for (let i = 0; i < this.numberOfChannels; i++) {
this.copyTo(data.subarray(i * planeSize, (i + 1) * planeSize), {
planeIndex: i,
format: this.format,
frameOffset: startSample,
frameCount,
});
}
}
} else {
// Trivial
data = new Uint8Array(frameCount * this.numberOfChannels * bytesPerSample);
if (frameCount > 0) {
this.copyTo(data, {
planeIndex: 0,
format: this.format,
frameOffset: startSample,
frameCount,
});
}
}
return new AudioSample({
data,
format: this.format,
sampleRate: this.sampleRate,
numberOfChannels: this.numberOfChannels,
timestamp: this.timestamp + startSample / this.sampleRate,
});
}
/**
* 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 (this._data instanceof AudioSampleResource) {
this._data._referenceCount--;
if (this._data._referenceCount === 0) {
this._data.close();
}
} else 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 (this._data instanceof AudioSampleResource) {
return this._createAudioDataFromData();
} else 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
return this._createAudioDataFromData();
}
} 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
});
}
}
/** @internal */
_createAudioDataFromData() {
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,
});
}
}
/** 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;
}
}
export 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;
};
export const toInterleavedAudioFormat = (format: AudioSampleFormat): 'u8' | 's16' | 's32' | 'f32' => {
switch (format) {
case 'u8-planar':
return 'u8';
case 's16-planar':
return 's16';
case 's32-planar':
return 's32';
case 'f32-planar':
return 'f32';
default:
return format;
}
};
/**
* 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);
}
}
}
}
};
export const audioSampleToInterleavedFormat = (sample: AudioSample, format: 'u8' | 's16' | 's32' | 'f32') => {
const size = sample.allocationSize({ format, planeIndex: 0 });
const buffer = new ArrayBuffer(size);
sample.copyTo(buffer, { format, planeIndex: 0 });
return new AudioSample({
data: buffer,
format,
numberOfChannels: sample.numberOfChannels,
sampleRate: sample.sampleRate,
timestamp: sample.timestamp,
duration: sample.duration,
});
};