Add options to VideoSample allocationSize and copyTo methods, add explicit error message when format is null (closes #256)

This commit is contained in:
Vanilagy
2025-12-17 15:10:25 +01:00
parent 4dd747a0a6
commit 15bdd072e1
4 changed files with 530 additions and 20 deletions
+2 -4
View File
@@ -378,13 +378,11 @@ const bytesNeeded = videoSample.allocationSize(); // => number
Then, use `copyTo` to copy the pixel data into the destination buffer: Then, use `copyTo` to copy the pixel data into the destination buffer:
```ts ```ts
const bytes = new Uint8Array(bytesNeeded); const bytes = new Uint8Array(bytesNeeded);
videoSample.copyTo(bytes); const planeLayout = await videoSample.copyTo(bytes);
``` ```
::: info ::: info
The data will always be in the pixel format specified in the `format` field. You can pass additional options to `allocationSize` and `copyTo` to extract data in a different pixel format.
To convert the data into a different pixel format, or to extract only a section of the frame, please use the [`allocationSize`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/allocationSize) and [`copyTo`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/copyTo) methods on `VideoFrame` instead. Get a `VideoFrame` by running `videoSample.toVideoFrame()`.
::: :::
--- ---
+2
View File
@@ -168,8 +168,10 @@ export {
AudioSampleCopyToOptions, AudioSampleCopyToOptions,
VideoSample, VideoSample,
VideoSampleInit, VideoSampleInit,
VideoSamplePixelFormat,
VideoSampleColorSpace, VideoSampleColorSpace,
CropRectangle, CropRectangle,
VIDEO_SAMPLE_PIXEL_FORMATS,
} from './sample'; } from './sample';
export { export {
AudioBufferSink, AudioBufferSink,
+306 -16
View File
@@ -17,6 +17,7 @@ import {
SetRequired, SetRequired,
isFirefox, isFirefox,
polyfillSymbolDispose, polyfillSymbolDispose,
assertNever,
} from './misc'; } from './misc';
polyfillSymbolDispose(); polyfillSymbolDispose();
@@ -69,6 +70,57 @@ if (typeof FinalizationRegistry !== 'undefined') {
}); });
} }
/**
* 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. * Metadata used for VideoSample initialization.
* @group Samples * @group Samples
@@ -77,9 +129,9 @@ if (typeof FinalizationRegistry !== 'undefined') {
export type VideoSampleInit = { export type VideoSampleInit = {
/** /**
* The internal pixel format in which the frame is stored. * The internal pixel format in which the frame is stored.
* [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format) * [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format)
*/ */
format?: VideoPixelFormat; format?: VideoSamplePixelFormat;
/** The width of the frame in pixels. */ /** The width of the frame in pixels. */
codedWidth?: number; codedWidth?: number;
/** The height of the frame in pixels. */ /** The height of the frame in pixels. */
@@ -92,6 +144,8 @@ export type VideoSampleInit = {
duration?: number; duration?: number;
/** The color space of the frame. */ /** The color space of the frame. */
colorSpace?: VideoColorSpaceInit; colorSpace?: VideoColorSpaceInit;
/** The byte layout of the planes of the frame. */
layout?: PlaneLayout[];
}; };
/** /**
@@ -103,14 +157,20 @@ export type VideoSampleInit = {
export class VideoSample implements Disposable { export class VideoSample implements Disposable {
/** @internal */ /** @internal */
_data!: VideoFrame | OffscreenCanvas | Uint8Array | null; _data!: VideoFrame | OffscreenCanvas | Uint8Array | null;
/**
* Used for the ArrayBuffer-backed case.
* @internal
*/
_layout!: PlaneLayout[] | null;
/** @internal */ /** @internal */
_closed: boolean = false; _closed: boolean = false;
/** /**
* The internal pixel format in which the frame is stored. * The internal pixel format in which the frame is stored. Will be `null` if it's using an arbitrary internal
* [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format) * format not representable by `VideoPixelFormat`.
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format)
*/ */
readonly format!: VideoPixelFormat | null; readonly format!: VideoSamplePixelFormat | null;
/** The width of the frame in pixels. */ /** The width of the frame in pixels. */
readonly codedWidth!: number; readonly codedWidth!: number;
/** The height of the frame in pixels. */ /** The height of the frame in pixels. */
@@ -189,8 +249,8 @@ export class VideoSample implements Disposable {
if (!init || typeof init !== 'object') { if (!init || typeof init !== 'object') {
throw new TypeError('init must be an object.'); throw new TypeError('init must be an object.');
} }
if (!('format' in init) || typeof init.format !== 'string') { if (init.format === undefined || !VIDEO_SAMPLE_PIXEL_FORMATS_SET.has(init.format)) {
throw new TypeError('init.format must be a string.'); throw new TypeError('init.format must be one of: ' + VIDEO_SAMPLE_PIXEL_FORMATS.join(', '));
} }
if (!Number.isInteger(init.codedWidth) || init.codedWidth! <= 0) { if (!Number.isInteger(init.codedWidth) || init.codedWidth! <= 0) {
throw new TypeError('init.codedWidth must be a positive integer.'); throw new TypeError('init.codedWidth must be a positive integer.');
@@ -209,6 +269,7 @@ export class VideoSample implements Disposable {
} }
this._data = toUint8Array(data).slice(); // Copy it this._data = toUint8Array(data).slice(); // Copy it
this._layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth!, init.codedHeight!);
this.format = init.format; this.format = init.format;
this.codedWidth = init.codedWidth!; this.codedWidth = init.codedWidth!;
@@ -229,6 +290,7 @@ export class VideoSample implements Disposable {
} }
this._data = data; this._data = data;
this._layout = null;
this.format = data.format; this.format = data.format;
// Copying the display dimensions here, assuming no innate VideoFrame rotation // Copying the display dimensions here, assuming no innate VideoFrame rotation
@@ -301,6 +363,7 @@ export class VideoSample implements Disposable {
// Draw it to a canvas // Draw it to a canvas
context.drawImage(data, 0, 0); context.drawImage(data, 0, 0);
this._data = canvas; this._data = canvas;
this._layout = null;
this.format = 'RGBX'; this.format = 'RGBX';
this.codedWidth = width; this.codedWidth = width;
@@ -336,8 +399,11 @@ export class VideoSample implements Disposable {
rotation: this.rotation, rotation: this.rotation,
}); });
} else if (this._data instanceof Uint8Array) { } else if (this._data instanceof Uint8Array) {
return new VideoSample(this._data.slice(), { assert(this._layout);
return new VideoSample(this._data, {
format: this.format!, format: this.format!,
layout: this._layout,
codedWidth: this.codedWidth, codedWidth: this.codedWidth,
codedHeight: this.codedHeight, codedHeight: this.codedHeight,
timestamp: this.timestamp, timestamp: this.timestamp,
@@ -378,16 +444,43 @@ export class VideoSample implements Disposable {
this._closed = true; this._closed = true;
} }
/** Returns the number of bytes required to hold this video sample's pixel data. */ /**
allocationSize() { * Returns the number of bytes required to hold this video sample's pixel data. Throws if `format` is `null`;
* specify an explicit RGB format in the options in this case.
*/
allocationSize(options: VideoFrameCopyToOptions = {}): number {
validateVideoFrameCopyToOptions(options);
if (this._closed) { if (this._closed) {
throw new Error('VideoSample is closed.'); throw new Error('VideoSample is closed.');
} }
if ((options.format ?? this.format) === null) {
throw new Error(
'Cannot get allocation size when format is null. Please manually provide an RGB pixel format in the'
+ ' options instead.',
);
}
assert(this._data !== null); assert(this._data !== null);
if (!isVideoFrame(this._data)) {
if (
options.colorSpace
|| (options.format && options.format !== this.format)
|| options.layout
|| options.rect
) {
// Temporarily convert to VideoFrame to get it done
const videoFrame = this.toVideoFrame();
const size = videoFrame.allocationSize(options);
videoFrame.close();
return size;
}
}
if (isVideoFrame(this._data)) { if (isVideoFrame(this._data)) {
return this._data.allocationSize(); return this._data.allocationSize(options);
} else if (this._data instanceof Uint8Array) { } else if (this._data instanceof Uint8Array) {
return this._data.byteLength; return this._data.byteLength;
} else { } else {
@@ -395,23 +488,54 @@ export class VideoSample implements Disposable {
} }
} }
/** Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. */ /**
async copyTo(destination: AllowSharedBufferSource) { * Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. Throws if `format` is `null`;
* specify an explicit RGB format in the options in this case.
* @returns The byte layout of the planes of the copied data.
*/
async copyTo(destination: AllowSharedBufferSource, options: VideoFrameCopyToOptions = {}): Promise<PlaneLayout[]> {
if (!isAllowSharedBufferSource(destination)) { if (!isAllowSharedBufferSource(destination)) {
throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.'); throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.');
} }
validateVideoFrameCopyToOptions(options);
if (this._closed) { if (this._closed) {
throw new Error('VideoSample is closed.'); throw new Error('VideoSample is closed.');
} }
if ((options.format ?? this.format) === null) {
throw new Error(
'Cannot copy video sample data when format is null. Please manually provide an RGB pixel format in the'
+ ' options instead.',
);
}
assert(this._data !== null); assert(this._data !== null);
if (!isVideoFrame(this._data)) {
if (
options.colorSpace
|| (options.format && options.format !== this.format)
|| options.layout
|| options.rect
) {
// Temporarily convert to VideoFrame to get it done
const videoFrame = this.toVideoFrame();
const layout = await videoFrame.copyTo(destination, options);
videoFrame.close();
return layout;
}
}
if (isVideoFrame(this._data)) { if (isVideoFrame(this._data)) {
await this._data.copyTo(destination); return this._data.copyTo(destination, options);
} else if (this._data instanceof Uint8Array) { } else if (this._data instanceof Uint8Array) {
assert(this._layout);
const dest = toUint8Array(destination); const dest = toUint8Array(destination);
dest.set(this._data); dest.set(this._data);
return this._layout;
} else { } else {
const canvas = this._data; const canvas = this._data;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
@@ -420,6 +544,11 @@ export class VideoSample implements Disposable {
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight); const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
const dest = toUint8Array(destination); const dest = toUint8Array(destination);
dest.set(imageData.data); dest.set(imageData.data);
return [{
offset: 0,
stride: 4 * this.codedWidth,
}];
} }
} }
@@ -441,7 +570,7 @@ export class VideoSample implements Disposable {
}); });
} else if (this._data instanceof Uint8Array) { } else if (this._data instanceof Uint8Array) {
return new VideoFrame(this._data, { return new VideoFrame(this._data, {
format: this.format!, format: this.format! as VideoPixelFormat,
codedWidth: this.codedWidth, codedWidth: this.codedWidth,
codedHeight: this.codedHeight, codedHeight: this.codedHeight,
timestamp: this.microsecondTimestamp, timestamp: this.microsecondTimestamp,
@@ -887,7 +1016,168 @@ export const validateCropRectangle = (crop: CropRectangle, prefix: string) => {
} }
}; };
const AUDIO_SAMPLE_FORMATS = new Set( const validateVideoFrameCopyToOptions = (options: VideoFrameCopyToOptions) => {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.colorSpace !== undefined && !['display-p3', 'srgb'].includes(options.colorSpace)) {
throw new TypeError('options.colorSpace, when provided, must be \'display-p3\' or \'srgb\'.');
}
if (options.format !== undefined && typeof options.format !== 'string') {
throw new TypeError('options.format, when provided, must be a string.');
}
if (options.layout !== undefined) {
if (!Array.isArray(options.layout)) {
throw new TypeError('options.layout, when provided, must be an array.');
}
for (const plane of options.layout) {
if (!plane || typeof plane !== 'object') {
throw new TypeError('Each entry in options.layout must be an object.');
}
if (!Number.isInteger(plane.offset) || plane.offset < 0) {
throw new TypeError('plane.offset must be a non-negative integer.');
}
if (!Number.isInteger(plane.stride) || plane.stride < 0) {
throw new TypeError('plane.stride must be a non-negative integer.');
}
}
}
if (options.rect !== undefined) {
if (!options.rect || typeof options.rect !== 'object') {
throw new TypeError('options.rect, when provided, must be an object.');
}
if (options.rect.x !== undefined && (!Number.isInteger(options.rect.x) || options.rect.x < 0)) {
throw new TypeError('options.rect.x, when provided, must be a non-negative integer.');
}
if (options.rect.y !== undefined && (!Number.isInteger(options.rect.y) || options.rect.y < 0)) {
throw new TypeError('options.rect.y, when provided, must be a non-negative integer.');
}
if (options.rect.width !== undefined && (!Number.isInteger(options.rect.width) || options.rect.width < 0)) {
throw new TypeError('options.rect.width, when provided, must be a non-negative integer.');
}
if (options.rect.height !== undefined && (!Number.isInteger(options.rect.height) || options.rect.height < 0)) {
throw new TypeError('options.rect.height, when provided, must be a non-negative integer.');
}
}
};
/** Implements logic from WebCodecs § 9.4.6 "Compute Layout and Allocation Size" */
const createDefaultPlaneLayout = (
format: VideoSamplePixelFormat,
codedWidth: number,
codedHeight: number,
): PlaneLayout[] => {
const planes = getPlaneConfigs(format);
const layouts: PlaneLayout[] = [];
let currentOffset = 0;
for (const plane of planes) {
// Per § 9.8, dimensions are usually "rounded up to the nearest integer".
const planeWidth = Math.ceil(codedWidth / plane.widthDivisor);
const planeHeight = Math.ceil(codedHeight / plane.heightDivisor);
const stride = planeWidth * plane.sampleBytes;
// Tight packing
const planeSize = stride * planeHeight;
layouts.push({
offset: currentOffset,
stride: stride,
});
currentOffset += planeSize;
}
return layouts;
};
type PlaneConfig = {
sampleBytes: number;
widthDivisor: number; // Horizontal sub-sampling factor
heightDivisor: number; // Vertical sub-sampling factor
};
/** Helper to retrieve plane configurations based on WebCodecs § 9.8 Pixel Format definitions. */
const getPlaneConfigs = (format: VideoSamplePixelFormat): PlaneConfig[] => {
// Helper for standard YUV planes
const yuv = (
yBytes: number,
uvBytes: number,
subX: number,
subY: number,
hasAlpha: boolean,
): PlaneConfig[] => {
const configs: PlaneConfig[] = [
{ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 },
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },
];
if (hasAlpha) {
// Match luma dimensions
configs.push({ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 });
}
return configs;
};
switch (format) {
case 'I420':
return yuv(1, 1, 2, 2, false);
case 'I420P10':
case 'I420P12':
return yuv(2, 2, 2, 2, false);
case 'I420A':
return yuv(1, 1, 2, 2, true);
case 'I420AP10':
case 'I420AP12':
return yuv(2, 2, 2, 2, true);
case 'I422':
return yuv(1, 1, 2, 1, false);
case 'I422P10':
case 'I422P12':
return yuv(2, 2, 2, 1, false);
case 'I422A':
return yuv(1, 1, 2, 1, true);
case 'I422AP10':
case 'I422AP12':
return yuv(2, 2, 2, 1, true);
case 'I444':
return yuv(1, 1, 1, 1, false);
case 'I444P10':
case 'I444P12':
return yuv(2, 2, 1, 1, false);
case 'I444A':
return yuv(1, 1, 1, 1, true);
case 'I444AP10':
case 'I444AP12':
return yuv(2, 2, 1, 1, true);
case 'NV12':
return [
{ sampleBytes: 1, widthDivisor: 1, heightDivisor: 1 },
{ sampleBytes: 2, widthDivisor: 2, heightDivisor: 2 }, // Interleaved U and V
];
case 'RGBA':
case 'RGBX':
case 'BGRA':
case 'BGRX':
return [
{ sampleBytes: 4, widthDivisor: 1, heightDivisor: 1 },
];
default:
assertNever(format);
assert(false);
}
};
const AUDIO_SAMPLE_FORMATS = new Set<AudioSampleFormat>(
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'], ['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
); );
+220
View File
@@ -0,0 +1,220 @@
import { expect, test } from 'vitest';
import { VideoSample } from '../../src/sample.js';
test('allocationSize', async () => {
{
const canvas = new OffscreenCanvas(1280, 720);
canvas.getContext('2d');
using sample = new VideoSample(canvas, { timestamp: 0 });
const size1 = sample.allocationSize();
expect(size1).toBe(1280 * 720 * 4);
const size2 = sample.allocationSize({
layout: [{
offset: 0,
stride: 1300 * 4,
}],
});
expect(size2).toBe(1300 * 720 * 4);
}
{
const data = new Uint8Array(1280 * 720 * 4);
using sample = new VideoSample(data, {
timestamp: 0,
codedWidth: 1280,
codedHeight: 720,
format: 'RGBA',
});
const size1 = sample.allocationSize();
expect(size1).toBe(1280 * 720 * 4);
const size2 = sample.allocationSize({
layout: [{
offset: 0,
stride: 1300 * 4,
}],
});
expect(size2).toBe(1300 * 720 * 4);
}
{
const data = new Uint8Array(1280 * 720 * 1.5);
using sample = new VideoSample(data, {
timestamp: 0,
codedWidth: 1280,
codedHeight: 720,
format: 'I420',
});
const size1 = sample.allocationSize();
expect(size1).toBe(1280 * 720 * 1.5);
const size2 = sample.allocationSize({ format: 'RGBA' });
expect(size2).toBe(1280 * 720 * 4);
const size3 = sample.allocationSize({
format: 'RGBA',
layout: [{
offset: 0,
stride: 1300 * 4,
}],
});
expect(size3).toBe(1300 * 720 * 4);
}
});
test('copyTo and plane layouts', async () => {
const buffer = new ArrayBuffer(1e7);
{
const canvas = new OffscreenCanvas(1280, 720);
canvas.getContext('2d');
using sample = new VideoSample(canvas, { timestamp: 0 });
const layout = await sample.copyTo(buffer);
expect(layout).toEqual([{
offset: 0,
stride: 1280 * 4,
}]);
}
{
const canvas = new OffscreenCanvas(1280, 720);
canvas.getContext('2d');
using sample = new VideoSample(canvas, { timestamp: 0 });
const layout = await sample.copyTo(buffer, {
layout: [{
offset: 0,
stride: 1300 * 4,
}],
});
expect(layout).toEqual([{
offset: 0,
stride: 1300 * 4,
}]);
}
{
const data = new Uint8Array(1280 * 720 * 4);
using sample = new VideoSample(data, {
timestamp: 0,
codedWidth: 1280,
codedHeight: 720,
format: 'RGBA',
});
const layout = await sample.copyTo(buffer);
expect(layout).toEqual([{
offset: 0,
stride: 1280 * 4,
}]);
}
{
const data = new Uint8Array(1280 * 720 * 4);
using sample = new VideoSample(data, {
timestamp: 0,
codedWidth: 1280,
codedHeight: 720,
format: 'RGBA',
});
const layout = await sample.copyTo(buffer, {
layout: [{
offset: 0,
stride: 1300 * 4,
}],
});
expect(layout).toEqual([{
offset: 0,
stride: 1300 * 4,
}]);
}
{
const data = new Uint8Array(1300 * 720 * 4);
using sample = new VideoSample(data, {
timestamp: 0,
codedWidth: 1280,
codedHeight: 720,
format: 'RGBA',
layout: [{
offset: 0,
stride: 1300 * 4,
}],
});
const layout = await sample.copyTo(buffer);
expect(layout).toEqual([{
offset: 0,
stride: 1300 * 4,
}]);
using clone = sample.clone();
const clonedLayout = await clone.copyTo(buffer);
expect(clonedLayout).toEqual([{
offset: 0,
stride: 1300 * 4,
}]);
}
{
const data = new Uint8Array(1280 * 720 * 1.5);
using sample = new VideoSample(data, {
timestamp: 0,
codedWidth: 1280,
codedHeight: 720,
format: 'I420',
});
const layout = await sample.copyTo(buffer);
expect(layout).toEqual([{
offset: 0,
stride: 1280,
}, {
offset: 1280 * 720,
stride: 1280 / 2,
}, {
offset: 1280 * 720 + (1280 / 2) * (720 / 2),
stride: 1280 / 2,
}]);
}
});
test('null format', async () => {
const canvas = new OffscreenCanvas(1280, 720);
canvas.getContext('2d');
const frame = new VideoFrame(canvas, { timestamp: 0 });
using sample = new VideoSample(frame);
// @ts-expect-error Sybau
sample.format = null;
expect(() => sample.allocationSize()).toThrow('when format is null');
await expect(async () => sample.copyTo(new ArrayBuffer())).rejects.toThrow('when format is null');
const size = sample.allocationSize({ format: 'RGBA' });
expect(size).toBe(1280 * 720 * 4);
const buffer = new ArrayBuffer(size);
const layout = await sample.copyTo(buffer, { format: 'RGBA' });
expect(layout).toEqual([{
offset: 0,
stride: 1280 * 4,
}]);
sample.allocationSize({ format: 'RGBX' });
sample.allocationSize({ format: 'BGRA' });
sample.allocationSize({ format: 'BGRX' });
await sample.copyTo(buffer, { format: 'RGBX' });
await sample.copyTo(buffer, { format: 'BGRA' });
await sample.copyTo(buffer, { format: 'BGRX' });
});