Change VideoSampleResource interface, validate return values, add more proper VideoSample.copyTo() logic, fix decoder/encoder color space issues

This commit is contained in:
Vanilagy
2026-05-08 19:37:41 +02:00
parent f5bd540863
commit ab9958234a
7 changed files with 831 additions and 654 deletions
+45 -2
View File
@@ -1,12 +1,14 @@
import { VideoSamplePixelFormat, VideoCodec } from 'mediabunny'; import { VideoSamplePixelFormat, MediaCodec } from 'mediabunny';
import * as NodeAv from 'node-av'; import * as NodeAv from 'node-av';
export const CODEC_TO_CODEC_ID: Record<VideoCodec, NodeAv.AVCodecID> = { export const CODEC_TO_CODEC_ID: Partial<Record<MediaCodec, NodeAv.AVCodecID>> = {
avc: NodeAv.AV_CODEC_ID_H264, avc: NodeAv.AV_CODEC_ID_H264,
hevc: NodeAv.AV_CODEC_ID_HEVC, hevc: NodeAv.AV_CODEC_ID_HEVC,
vp8: NodeAv.AV_CODEC_ID_VP8, vp8: NodeAv.AV_CODEC_ID_VP8,
vp9: NodeAv.AV_CODEC_ID_VP9, vp9: NodeAv.AV_CODEC_ID_VP9,
av1: NodeAv.AV_CODEC_ID_AV1, av1: NodeAv.AV_CODEC_ID_AV1,
aac: NodeAv.AV_CODEC_ID_AAC,
}; };
let cachedHardwareContext: NodeAv.HardwareContext | null | undefined = undefined; let cachedHardwareContext: NodeAv.HardwareContext | null | undefined = undefined;
@@ -180,3 +182,44 @@ export const fromPixelFormat = (pixelFormat: VideoSamplePixelFormat) => {
default: return NodeAv.AV_PIX_FMT_NONE; default: return NodeAv.AV_PIX_FMT_NONE;
} }
}; };
export const toAudioSampleFormat = (ffmpegSampleFormat: NodeAv.AVSampleFormat): AudioSampleFormat | null => {
switch (ffmpegSampleFormat) {
case NodeAv.AV_SAMPLE_FMT_U8: return 'u8';
case NodeAv.AV_SAMPLE_FMT_S16: return 's16';
case NodeAv.AV_SAMPLE_FMT_S32: return 's32';
case NodeAv.AV_SAMPLE_FMT_FLT: return 'f32';
case NodeAv.AV_SAMPLE_FMT_U8P: return 'u8-planar';
case NodeAv.AV_SAMPLE_FMT_S16P: return 's16-planar';
case NodeAv.AV_SAMPLE_FMT_S32P: return 's32-planar';
case NodeAv.AV_SAMPLE_FMT_FLTP: return 'f32-planar';
default: return null;
}
};
export const fromAudioSampleFormat = (sampleFormat: AudioSampleFormat): NodeAv.AVSampleFormat => {
switch (sampleFormat) {
case 'u8': return NodeAv.AV_SAMPLE_FMT_U8;
case 's16': return NodeAv.AV_SAMPLE_FMT_S16;
case 's32': return NodeAv.AV_SAMPLE_FMT_S32;
case 'f32': return NodeAv.AV_SAMPLE_FMT_FLT;
case 'u8-planar': return NodeAv.AV_SAMPLE_FMT_U8P;
case 's16-planar': return NodeAv.AV_SAMPLE_FMT_S16P;
case 's32-planar': return NodeAv.AV_SAMPLE_FMT_S32P;
case 'f32-planar': return NodeAv.AV_SAMPLE_FMT_FLTP;
default: return NodeAv.AV_SAMPLE_FMT_NONE;
}
};
export const getChannelLayout = (numChannels: number): NodeAv.ChannelLayout => {
switch (numChannels) {
case 1: return NodeAv.AV_CHANNEL_LAYOUT_MONO;
case 2: return NodeAv.AV_CHANNEL_LAYOUT_STEREO;
case 4: return NodeAv.AV_CHANNEL_LAYOUT_QUAD;
case 6: return NodeAv.AV_CHANNEL_LAYOUT_5POINT1;
case 8: return NodeAv.AV_CHANNEL_LAYOUT_7POINT1;
default: return { nbChannels: numChannels, order: NodeAv.AV_CHANNEL_ORDER_UNSPEC, mask: 0n };
}
};
+9 -33
View File
@@ -1,13 +1,7 @@
import { CustomVideoDecoder, VideoCodec, EncodedPacket, VideoSample, MaybePromise, Rational } from 'mediabunny'; import { CustomVideoDecoder, VideoCodec, EncodedPacket, VideoSample, MaybePromise, Rational } from 'mediabunny';
import * as NodeAv from 'node-av'; import * as NodeAv from 'node-av';
import { import { CODEC_TO_CODEC_ID, getHardwareDecoderCodec } from './misc';
CODEC_TO_CODEC_ID, import { assert, binarySearchLessOrEqual, simplifyRational, toUint8Array } from '../../../src/misc';
getHardwareDecoderCodec,
mapColorPrimaries,
mapMatrixCoefficients,
mapTransferCharacteristics,
} from './misc';
import { binarySearchLessOrEqual, simplifyRational, toUint8Array } from '../../../src/misc';
import { NodeAvFrameVideoSampleResource } from './video-sample'; import { NodeAvFrameVideoSampleResource } from './video-sample';
export class NodeAvVideoDecoder extends CustomVideoDecoder { export class NodeAvVideoDecoder extends CustomVideoDecoder {
@@ -37,6 +31,7 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
this.packet.alloc(); this.packet.alloc();
const codecId = CODEC_TO_CODEC_ID[this.codec]; const codecId = CODEC_TO_CODEC_ID[this.codec];
assert(codecId !== undefined);
let codec: NodeAv.Codec | null; let codec: NodeAv.Codec | null;
if (this.config.hardwareAcceleration !== 'prefer-hardware' || this.codec === 'av1') { if (this.config.hardwareAcceleration !== 'prefer-hardware' || this.codec === 'av1') {
@@ -67,30 +62,6 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
: null; : null;
codecContext.sampleAspectRatio = new NodeAv.Rational(this.pixelAspectRatio.num, this.pixelAspectRatio.den); codecContext.sampleAspectRatio = new NodeAv.Rational(this.pixelAspectRatio.num, this.pixelAspectRatio.den);
if (this.config.colorSpace?.primaries) {
const mapped = mapColorPrimaries(this.config.colorSpace.primaries);
if (mapped !== null) {
codecContext.colorPrimaries = mapped;
}
}
if (this.config.colorSpace?.transfer) {
const mapped = mapTransferCharacteristics(this.config.colorSpace.transfer);
if (mapped !== null) {
codecContext.colorTrc = mapped;
}
}
if (this.config.colorSpace?.matrix) {
const mapped = mapMatrixCoefficients(this.config.colorSpace.matrix);
if (mapped !== null) {
codecContext.colorSpace = mapped;
}
}
if (this.config.colorSpace?.fullRange != null) {
codecContext.colorRange = this.config.colorSpace.fullRange
? NodeAv.AVCOL_RANGE_JPEG
: NodeAv.AVCOL_RANGE_MPEG;
}
const ret = await codecContext.open2(); const ret = await codecContext.open2();
NodeAv.FFmpegError.throwIfError(ret, 'Open codec context'); NodeAv.FFmpegError.throwIfError(ret, 'Open codec context');
@@ -178,7 +149,12 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
} }
} }
this.onSample(new VideoSample(new NodeAvFrameVideoSampleResource(this.frame), { const clone = this.frame.clone();
if (!clone) {
throw new Error('Frame clone allocation failed.');
}
this.onSample(new VideoSample(new NodeAvFrameVideoSampleResource(clone), {
timestamp, timestamp,
duration, duration,
})); }));
+1 -11
View File
@@ -35,7 +35,6 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
avCodec!: NodeAv.Codec; avCodec!: NodeAv.Codec;
codecContext: NodeAv.CodecContext | null = null; codecContext: NodeAv.CodecContext | null = null;
lastBuffer: Buffer | null = null; lastBuffer: Buffer | null = null;
colorSpaceSet = false;
packetEmitted = false; packetEmitted = false;
scaler: NodeAv.SoftwareScaleContext | null = null; scaler: NodeAv.SoftwareScaleContext | null = null;
@@ -65,6 +64,7 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
this.packet.alloc(); this.packet.alloc();
const codecId = CODEC_TO_CODEC_ID[this.codec]; const codecId = CODEC_TO_CODEC_ID[this.codec];
assert(codecId !== undefined);
let codec: NodeAv.Codec | null = null; let codec: NodeAv.Codec | null = null;
if (this.config.hardwareAcceleration === 'prefer-software') { if (this.config.hardwareAcceleration === 'prefer-software') {
@@ -203,15 +203,6 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
this.frame.fromBuffer(this.lastBuffer); this.frame.fromBuffer(this.lastBuffer);
} }
if (!this.colorSpaceSet) {
this.codecContext.colorPrimaries = this.frame.colorPrimaries;
this.codecContext.colorTrc = this.frame.colorTrc;
this.codecContext.colorSpace = this.frame.colorSpace;
this.codecContext.colorRange = this.frame.colorRange;
this.colorSpaceSet = true;
}
let frameToEncode = this.frame; let frameToEncode = this.frame;
const requiresScaler const requiresScaler
@@ -550,7 +541,6 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
} }
this.packetEmitted = false; this.packetEmitted = false;
this.colorSpaceSet = false;
} }
close(): MaybePromise<void> { close(): MaybePromise<void> {
+60 -450
View File
@@ -1,20 +1,27 @@
import assert from 'assert'; import assert from 'assert';
import { Rectangle, VideoSamplePixelFormat } from 'mediabunny'; import { VideoSamplePixelFormat } from 'mediabunny';
import { VideoSampleColorSpace } from 'mediabunny'; import { VideoSampleColorSpace } from 'mediabunny';
import { VideoSampleResource } from 'mediabunny'; import { VideoSampleResource } from 'mediabunny';
import * as NodeAv from 'node-av'; import * as NodeAv from 'node-av';
import { toUint8Array } from '../../../src/misc'; import { MaybePromise, toUint8Array } from '../../../src/misc';
import { getPlaneConfigs } from '../../../src/sample'; import { VideoDataPlane, VideoSample } from '../../../src/sample';
import { import {
toPixelFormat, toPixelFormat,
unmapColorPrimaries, unmapColorPrimaries,
unmapTransferCharacteristics, unmapTransferCharacteristics,
unmapMatrixCoefficients, unmapMatrixCoefficients,
fromPixelFormat, fromPixelFormat,
mapColorPrimaries,
mapTransferCharacteristics,
mapMatrixCoefficients,
} from './misc'; } from './misc';
import { SetRequired } from 'mediabunny';
import { VideoSampleInit } from 'mediabunny';
const JPEG_RANGE_PIX_FORMATS = new Set([
NodeAv.AV_PIX_FMT_YUVJ411P,
NodeAv.AV_PIX_FMT_YUVJ420P,
NodeAv.AV_PIX_FMT_YUVJ422P,
NodeAv.AV_PIX_FMT_YUVJ440P,
NodeAv.AV_PIX_FMT_YUVJ444P,
]);
export class NodeAvFrameVideoSampleResource extends VideoSampleResource { export class NodeAvFrameVideoSampleResource extends VideoSampleResource {
frame: NodeAv.Frame; frame: NodeAv.Frame;
@@ -22,12 +29,7 @@ export class NodeAvFrameVideoSampleResource extends VideoSampleResource {
constructor(frame: NodeAv.Frame) { constructor(frame: NodeAv.Frame) {
super(); super();
const clone = frame.clone(); this.frame = frame;
if (!clone) {
throw new Error('Allocation failure during frame clone.');
}
this.frame = clone;
} }
getFormat(): VideoSamplePixelFormat | null { getFormat(): VideoSamplePixelFormat | null {
@@ -63,7 +65,12 @@ export class NodeAvFrameVideoSampleResource extends VideoSampleResource {
primaries: unmapColorPrimaries(this.frame.colorPrimaries) as VideoColorPrimaries | null, primaries: unmapColorPrimaries(this.frame.colorPrimaries) as VideoColorPrimaries | null,
transfer: unmapTransferCharacteristics(this.frame.colorTrc) as VideoTransferCharacteristics | null, transfer: unmapTransferCharacteristics(this.frame.colorTrc) as VideoTransferCharacteristics | null,
matrix: unmapMatrixCoefficients(this.frame.colorSpace) as VideoMatrixCoefficients | null, matrix: unmapMatrixCoefficients(this.frame.colorSpace) as VideoMatrixCoefficients | null,
fullRange: this.frame.colorRange === NodeAv.AVCOL_RANGE_JPEG, fullRange: this.frame.colorRange === NodeAv.AVCOL_RANGE_JPEG
|| JPEG_RANGE_PIX_FORMATS.has(this.frame.format as NodeAv.AVPixelFormat)
? true
: this.frame.colorRange === NodeAv.AVCOL_RANGE_MPEG
? false
: null,
}); });
} }
@@ -71,450 +78,53 @@ export class NodeAvFrameVideoSampleResource extends VideoSampleResource {
this.frame.free(); this.frame.free();
} }
allocationSize(options: VideoFrameCopyToOptions): number { getDataPlanes(): MaybePromise<VideoDataPlane[]> {
// 3. Let combinedLayout be the result of running the Parse VideoFrameCopyToOptions algorithm with options. assert(this.frame.data);
// 4. If combinedLayout is an exception, throw combinedLayout.
const combinedLayout = ParseVideoFrameCopyToOptions(this, options);
// 5. Return combinedLayout's allocationSize. return this.frame.data.map((data, i) => ({
return combinedLayout.allocationSize; data: toUint8Array(data),
stride: this.frame.linesize[i]!,
}));
} }
async copyTo( async toRgbSample(
destination: AllowSharedBufferSource, init: SetRequired<VideoSampleInit, 'timestamp'>,
options: VideoFrameCopyToOptions, format: 'RGBA' | 'RGBX' | 'BGRA' | 'BGRX',
): Promise<PlaneLayout[]> { // Will respect it when somebody complains
const format = this.getFormat(); // eslint-disable-next-line @typescript-eslint/no-unused-vars
assert(format !== null); colorSpace: PredefinedColorSpace,
): Promise<VideoSample> {
const width = this.frame.width;
const height = this.frame.height;
// 3. Let combinedLayout be the result of running the Parse VideoFrameCopyToOptions algorithm with options. const scaler = new NodeAv.SoftwareScaleContext();
const combinedLayout = ParseVideoFrameCopyToOptions(this, options); const srcFmt = this.frame.format as NodeAv.AVPixelFormat;
const dstFmt = fromPixelFormat(format);
// 4. If destination.byteLength is less than combinedLayout’s allocationSize, return a promise rejected with scaler.getContext(
// // eslint-disable-next-line @stylistic/max-len width, height, srcFmt,
const destBytes = toUint8Array(destination); width, height, dstFmt,
if (destBytes.byteLength < combinedLayout.allocationSize) { NodeAv.SWS_BILINEAR,
throw new TypeError( );
`Destination buffer too small. Required: ${combinedLayout.allocationSize},`
+ ` Available: ${destBytes.byteLength}`, const dstFrame = new NodeAv.Frame();
); dstFrame.width = width;
dstFrame.height = height;
dstFrame.format = dstFmt;
dstFrame.alloc();
dstFrame.allocBuffer();
const srcFrame = this.frame;
try {
await scaler.scaleFrame(dstFrame, srcFrame);
} finally {
scaler.freeContext();
} }
// 5. If options.format is equal to one of RGBA, RGBX, BGRA, BGRX then: dstFrame.format = dstFmt; // FFmpeg messes up RGBA and RGBX
if (options.format && ['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format) && options.format !== format) { dstFrame.sampleAspectRatio = srcFrame.sampleAspectRatio;
// Let newOptions be the result of running the Clone Configuration algorithm with options.
// Assign undefined to newOptions.format.
const newOptions = { ...options };
delete newOptions.format;
// Let rgbFrame be the result of running the Convert to RGB frame algorithm with this, options.format, return new VideoSample(new NodeAvFrameVideoSampleResource(dstFrame), init);
// and options.colorSpace.
const rgbFrame = await ConvertToRGBFrame(this, options.format, options.colorSpace);
// Return the result of calling copyTo() on rgbFrame with destination and newOptions.
try {
const result = await rgbFrame.copyTo(destination, newOptions);
rgbFrame.close();
return result;
} catch (e) {
rgbFrame.close();
throw e;
}
}
// 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 planes = getPlaneConfigs(format);
const numPlanes = planes.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.
let sourceStride = 0;
let sourceStartOffset = 0;
sourceStride = this.frame.linesize[planeIndex]!;
const planeBuffer = this.frame.data![planeIndex]!;
if (!planeBuffer) {
throw new Error(`Missing data for plane ${planeIndex}`);
}
const sourceData = new Uint8Array(planeBuffer.buffer, planeBuffer.byteOffset, planeBuffer.byteLength);
sourceStartOffset = 0;
// 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;
// Adjust for the base offset of the plane in the source buffer
sourceOffset += sourceStartOffset;
// 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);
}
// Queue a task to resolve p with planeLayouts.
return planeLayouts;
} }
} }
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 = (
frame: VideoSampleResource,
options: VideoFrameCopyToOptions,
): CombinedBufferLayout => {
// 1. Let defaultRect be the result of performing the getter steps for visibleRect.
const defaultRect: Rectangle = {
left: 0,
top: 0,
width: frame.getCodedWidth(),
height: frame.getCodedHeight(),
};
// 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,
frame.getCodedWidth(),
frame.getCodedHeight(),
frame.getFormat(),
);
// 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 === frame.getFormat()) {
format = frame.getFormat()!;
} 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,
};
};
// Taken from the WebCodecs spec
const ConvertToRGBFrame = async (
frame: NodeAvFrameVideoSampleResource,
format: VideoPixelFormat,
colorSpace?: PredefinedColorSpace,
): Promise<NodeAvFrameVideoSampleResource> => {
// 1. Let convertedFrame be a new VideoFrame...
// (We construct it at the end, but we prepare the resource here)
const width = frame.getCodedWidth();
const height = frame.getCodedHeight();
const scaler = new NodeAv.SoftwareScaleContext();
const srcFmt = fromPixelFormat(frame.getFormat()!);
const dstFmt = fromPixelFormat(format);
// Configure scaling: Source -> Destination (Visible Size)
scaler.getContext(
width, height, srcFmt,
width, height, dstFmt,
NodeAv.SWS_BILINEAR,
);
// Allocate destination frame
const dstFrame = new NodeAv.Frame();
dstFrame.width = width;
dstFrame.height = height;
dstFrame.format = dstFmt;
dstFrame.alloc();
dstFrame.allocBuffer();
// Apply destination color space settings to dstFrame
// If colorSpace is not provided, srgb is used
const targetColorSpace = colorSpace || 'srgb';
if (targetColorSpace === 'srgb') {
dstFrame.colorPrimaries = NodeAv.AVCOL_PRI_BT709;
dstFrame.colorTrc = NodeAv.AVCOL_TRC_IEC61966_2_1;
dstFrame.colorSpace = NodeAv.AVCOL_SPC_RGB;
dstFrame.colorRange = NodeAv.AVCOL_RANGE_JPEG;
} else if (targetColorSpace === 'display-p3') {
dstFrame.colorPrimaries = NodeAv.AVCOL_PRI_SMPTE432;
dstFrame.colorTrc = NodeAv.AVCOL_TRC_IEC61966_2_1;
dstFrame.colorSpace = NodeAv.AVCOL_SPC_RGB;
dstFrame.colorRange = NodeAv.AVCOL_RANGE_JPEG;
}
const srcFrame = frame.frame;
// Apply source color space settings to srcFrame (if known)
// This ensures the scaler knows the input color space for correct conversion
const frameColorSpace = frame.getColorSpace();
if (colorSpace) {
srcFrame.colorPrimaries = mapColorPrimaries(frameColorSpace.primaries ?? 'unknown')
?? NodeAv.AVCOL_PRI_UNSPECIFIED;
srcFrame.colorTrc = mapTransferCharacteristics(frameColorSpace.transfer ?? 'unknown')
?? NodeAv.AVCOL_TRC_UNSPECIFIED;
srcFrame.colorSpace = mapMatrixCoefficients(frameColorSpace.matrix ?? 'unknown')
?? NodeAv.AVCOL_SPC_UNSPECIFIED;
srcFrame.colorRange = frameColorSpace.fullRange
? NodeAv.AVCOL_RANGE_JPEG
: NodeAv.AVCOL_RANGE_MPEG;
}
try {
await scaler.scaleFrame(dstFrame, srcFrame);
} finally {
scaler.freeContext();
}
return new NodeAvFrameVideoSampleResource(dstFrame);
};
+459 -90
View File
@@ -116,16 +116,20 @@ export abstract class VideoSampleResource {
*/ */
abstract close(): void; abstract close(): void;
/** Returns the number of bytes required to hold this video sample's pixel data. */ abstract getDataPlanes(): MaybePromise<VideoDataPlane[]>;
abstract allocationSize(options: VideoFrameCopyToOptions): number;
/** Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. */ abstract toRgbSample(
abstract copyTo( init: SetRequired<VideoSampleInit, 'timestamp'>,
destination: AllowSharedBufferSource, format: 'RGBA' | 'RGBX' | 'BGRA' | 'BGRX',
options: VideoFrameCopyToOptions colorSpace: PredefinedColorSpace,
): MaybePromise<PlaneLayout[]>; ): MaybePromise<VideoSample>;
} }
export type VideoDataPlane = {
data: Uint8Array;
stride: number;
};
/** /**
* The list of {@link VideoSample} pixel formats. * The list of {@link VideoSample} pixel formats.
* @group Samples * @group Samples
@@ -567,9 +571,33 @@ export class VideoSample implements Disposable {
data._referenceCount++; data._referenceCount++;
this.format = data.getFormat(); this.format = data.getFormat();
this.visibleRect = { left: 0, top: 0, width: data.getCodedWidth(), height: data.getCodedHeight() }; 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(); 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(); 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.rotation = init.rotation ?? 0;
this.timestamp = init.timestamp!; this.timestamp = init.timestamp!;
this.duration = init.duration ?? 0; this.duration = init.duration ?? 0;
@@ -682,42 +710,20 @@ export class VideoSample implements Disposable {
if (this._closed) { if (this._closed) {
throw new Error('VideoSample is closed.'); throw new Error('VideoSample is closed.');
} }
if (this.format === null) {
if ((options.format ?? this.format) == null) {
// https://github.com/Vanilagy/mediabunny/issues/267 // https://github.com/Vanilagy/mediabunny/issues/267
// https://github.com/w3c/webcodecs/issues/920 // https://github.com/w3c/webcodecs/issues/920
throw new Error('Cannot get allocation size when format is null. Sorry!'); throw new Error('Cannot get allocation size when format is null.');
}
assert(this._data !== null);
if (this._data instanceof VideoSampleResource) {
return this._data.allocationSize(options);
}
if (!isVideoFrame(this._data)) {
if (
options.colorSpace
|| (options.format && options.format !== this.format)
|| options.layout
|| options.rect
) {
// Temporarily convert to VideoFrame to get it done
// TODO: Compute this directly without needing to go through VideoFrame
const videoFrame = this.toVideoFrame();
const size = videoFrame.allocationSize(options);
videoFrame.close();
return size;
}
} }
if (isVideoFrame(this._data)) { if (isVideoFrame(this._data)) {
// Call the native method purely for performance
return this._data.allocationSize(options); return this._data.allocationSize(options);
} else if (this._data instanceof Uint8Array) {
return this._data.byteLength;
} else {
return this.codedWidth * this.codedHeight * 4; // RGBX
} }
const combinedLayout = ParseVideoFrameCopyToOptions(this, options);
return combinedLayout.allocationSize;
} }
/** /**
@@ -733,56 +739,200 @@ export class VideoSample implements Disposable {
if (this._closed) { if (this._closed) {
throw new Error('VideoSample is closed.'); throw new Error('VideoSample is closed.');
} }
if (this.format === null) { if ((options.format ?? this.format) == null) {
throw new Error('Cannot copy video sample data when format is null. Sorry!'); throw new Error('Cannot copy video sample data when format is null. Sorry!');
} }
assert(this._data !== null); assert(this._data !== null);
if (this._data instanceof VideoSampleResource) { if (isVideoFrame(this._data)) {
return this._data.copyTo(destination, options); return this._data.copyTo(destination, options);
} }
if (!isVideoFrame(this._data)) { if (
if ( options.format
options.colorSpace && this.format !== options.format
|| (options.format && options.format !== this.format) && ['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format)
|| options.layout ) {
|| options.rect // RGB conversion for custom VideoSampleResource
) { if (this._data instanceof VideoSampleResource) {
// Temporarily convert to VideoFrame to get it done using rgbSample = await this._data.toRgbSample(
// TODO: Do this directly without needing to go through VideoFrame {
const videoFrame = this.toVideoFrame(); timestamp: this.timestamp,
const layout = await videoFrame.copyTo(destination, options); duration: this.duration,
videoFrame.close(); rotation: this.rotation,
},
options.format as 'RGBA' | 'RGBX' | 'BGRA' | 'BGRX',
options.colorSpace ?? 'srgb',
);
if (!(rgbSample instanceof VideoSample)) {
throw new TypeError('toRgbSample() must return a VideoSample.');
}
if (rgbSample.format !== options.format) {
throw new Error(
`Sample returned by toRgbSample was expected to have format '${options.format}', got`
+ ` '${rgbSample.format}' instead.`,
);
}
return layout; return await rgbSample.copyTo(destination, options); // 'await' is intentional here cuz of using
} else if (!['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(this.format!)) {
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;
} }
} }
if (isVideoFrame(this._data)) { const combinedLayout = ParseVideoFrameCopyToOptions(this, options);
return this._data.copyTo(destination, 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) { } else if (this._data instanceof Uint8Array) {
assert(this._layout); assert(this._layout);
assert(this._layout.length === planeConfigs.length);
const dest = toUint8Array(destination); dataPlanes = this._layout.map((planeLayout, i) => {
dest.set(this._data); const height = Math.ceil(this.codedHeight / planeConfigs[i]!.heightDivisor);
return this._layout; return {
data: (this._data as Uint8Array).subarray(
planeLayout.offset,
planeLayout.offset + planeLayout.stride * height,
),
stride: planeLayout.stride,
};
});
} else { } else {
const canvas = this._data; const canvas = this._data;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
assert(context); assert(context);
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight); const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
const dest = toUint8Array(destination);
dest.set(imageData.data);
return [{ dataPlanes = [{
offset: 0, data: toUint8Array(imageData.data),
stride: 4 * this.codedWidth, stride: 4 * this.codedWidth,
}]; }];
} }
// 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);
}
// RGB conversion for ArrayBuffer and canvas-backed samples
if (options.format !== undefined && !(this._data instanceof VideoSampleResource)) {
const needsRgbConversion = this.format.startsWith('RGB') !== options.format.startsWith('RGB');
if (needsRgbConversion) {
// Loop over the destination bytes, swapping R and B
for (let i = 0; i < combinedLayout.allocationSize; i += 4) {
const r = destBytes[i]!;
const b = destBytes[i + 2]!;
destBytes[i] = b;
destBytes[i + 2] = r;
}
}
}
// Queue a task to resolve p with planeLayouts.
return planeLayouts;
} }
/** /**
@@ -797,35 +947,10 @@ export class VideoSample implements Disposable {
assert(this._data !== null); assert(this._data !== null);
if (this._data instanceof VideoSampleResource) { if (this._data instanceof VideoSampleResource) {
const format = this._data.getFormat() ?? 'RGBA'; throw new Error(
const allocationSize = this._data.allocationSize({ format: format as VideoPixelFormat }); 'Creating a VideoFrame from a VideoSampleResource is currently not supported. To work around this,'
+ ' copy the data into a buffer and then create a VideoFrame from it.',
let data: ArrayBuffer; );
if (this._data._lastAllocationBuffer?.byteLength === allocationSize) {
data = this._data._lastAllocationBuffer;
} else {
data = this._data._lastAllocationBuffer = new ArrayBuffer(allocationSize);
}
const layoutResult = this._data.copyTo(data, { format: format as VideoPixelFormat });
if (layoutResult instanceof Promise) {
throw new Error(
'Cannot create a VideoFrame from a VideoSampleResource if copyTo returns a Promise. To work'
+ ' around this, copy the data into a buffer and then create a VideoFrame from it.',
);
}
const layout = layoutResult;
return new VideoFrame(data, {
format: format as VideoPixelFormat,
codedWidth: this._data.getCodedWidth(),
codedHeight: this._data.getCodedHeight(),
colorSpace: this._data.getColorSpace(),
layout,
timestamp: this.microsecondTimestamp,
duration: this.microsecondDuration,
});
} else if (isVideoFrame(this._data)) { } else if (isVideoFrame(this._data)) {
return new VideoFrame(this._data, { return new VideoFrame(this._data, {
timestamp: this.microsecondTimestamp, timestamp: this.microsecondTimestamp,
@@ -1474,6 +1599,250 @@ export const getPlaneConfigs = (format: VideoSamplePixelFormat): PlaneConfig[] =
} }
}; };
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>( 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'],
); );
+181 -9
View File
@@ -154,7 +154,7 @@ test('copyTo and plane layouts', async () => {
expect(layout).toEqual([{ expect(layout).toEqual([{
offset: 0, offset: 0,
stride: 1300 * 4, stride: 1280 * 4,
}]); }]);
using clone = sample.clone(); using clone = sample.clone();
@@ -162,7 +162,7 @@ test('copyTo and plane layouts', async () => {
expect(clonedLayout).toEqual([{ expect(clonedLayout).toEqual([{
offset: 0, offset: 0,
stride: 1300 * 4, stride: 1280 * 4,
}]); }]);
} }
@@ -186,6 +186,184 @@ test('copyTo and plane layouts', async () => {
offset: 1280 * 720 + (1280 / 2) * (720 / 2), offset: 1280 * 720 + (1280 / 2) * (720 / 2),
stride: 1280 / 2, stride: 1280 / 2,
}]); }]);
const rgbLayout = await sample.copyTo(buffer, { format: 'RGBA' });
expect(rgbLayout).toEqual([{
offset: 0,
stride: 4 * 1280,
}]);
}
});
test('RGB conversion for ArrayBuffer-backed data', async () => {
// 2x2 RGBA image with distinct, easily-verifiable pixels
const src = new Uint8Array([
10, 20, 30, 255, 40, 50, 60, 255,
70, 80, 90, 255, 100, 110, 120, 255,
]);
{
// RGBA -> BGRA: R and B should swap
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: 2,
codedHeight: 2,
format: 'RGBA',
});
const dest = new Uint8Array(2 * 2 * 4);
const layout = await sample.copyTo(dest, { format: 'BGRA' });
expect(layout).toEqual([{ offset: 0, stride: 2 * 4 }]);
expect(Array.from(dest)).toEqual([
30, 20, 10, 255, 60, 50, 40, 255,
90, 80, 70, 255, 120, 110, 100, 255,
]);
}
{
// RGBA -> RGBA: no swap, bytes copied through unchanged
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: 2,
codedHeight: 2,
format: 'RGBA',
});
const dest = new Uint8Array(2 * 2 * 4);
await sample.copyTo(dest, { format: 'RGBA' });
expect(Array.from(dest)).toEqual(Array.from(src));
}
{
// RGBA -> BGRX: R and B should swap
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: 2,
codedHeight: 2,
format: 'RGBA',
});
const dest = new Uint8Array(2 * 2 * 4);
await sample.copyTo(dest, { format: 'BGRX' });
expect(Array.from(dest)).toEqual([
30, 20, 10, 255, 60, 50, 40, 255,
90, 80, 70, 255, 120, 110, 100, 255,
]);
}
{
// BGRA -> RGBA: R and B should swap
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: 2,
codedHeight: 2,
format: 'BGRA',
});
const dest = new Uint8Array(2 * 2 * 4);
await sample.copyTo(dest, { format: 'RGBA' });
expect(Array.from(dest)).toEqual([
30, 20, 10, 255, 60, 50, 40, 255,
90, 80, 70, 255, 120, 110, 100, 255,
]);
}
{
// BGRA -> BGRA: no swap
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: 2,
codedHeight: 2,
format: 'BGRA',
});
const dest = new Uint8Array(2 * 2 * 4);
await sample.copyTo(dest, { format: 'BGRA' });
expect(Array.from(dest)).toEqual(Array.from(src));
}
});
test('crop (rect option) for ArrayBuffer-backed data', async () => {
// 4x4 RGBA image where each pixel's R/G channels encode (x, y)
const width = 4;
const height = 4;
const src = new Uint8Array(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
src[i] = x;
src[i + 1] = y;
src[i + 2] = 0;
src[i + 3] = 255;
}
}
{
// Crop a 2x2 region at (1, 1)
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: width,
codedHeight: height,
format: 'RGBA',
});
const size = sample.allocationSize({ rect: { x: 1, y: 1, width: 2, height: 2 } });
expect(size).toBe(2 * 2 * 4);
const dest = new Uint8Array(size);
const layout = await sample.copyTo(dest, { rect: { x: 1, y: 1, width: 2, height: 2 } });
expect(layout).toEqual([{ offset: 0, stride: 2 * 4 }]);
expect(Array.from(dest)).toEqual([
1, 1, 0, 255, 2, 1, 0, 255,
1, 2, 0, 255, 2, 2, 0, 255,
]);
}
{
// Crop an offset rect at the top-right corner
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: width,
codedHeight: height,
format: 'RGBA',
});
const dest = new Uint8Array(2 * 1 * 4);
const layout = await sample.copyTo(dest, { rect: { x: 2, y: 0, width: 2, height: 1 } });
expect(layout).toEqual([{ offset: 0, stride: 2 * 4 }]);
expect(Array.from(dest)).toEqual([
2, 0, 0, 255, 3, 0, 0, 255,
]);
}
{
// Crop combined with a custom destination stride (padding between rows)
using sample = new VideoSample(src.slice(), {
timestamp: 0,
codedWidth: width,
codedHeight: height,
format: 'RGBA',
});
const stride = 3 * 4; // wider than the crop, leaving trailing padding
const dest = new Uint8Array(stride * 2);
const layout = await sample.copyTo(dest, {
rect: { x: 0, y: 2, width: 2, height: 2 },
layout: [{ offset: 0, stride }],
});
expect(layout).toEqual([{ offset: 0, stride }]);
// Row 0 of crop (y=2): pixels (0,2) and (1,2), then 4 bytes of untouched padding
expect(Array.from(dest.subarray(0, 8))).toEqual([0, 2, 0, 255, 1, 2, 0, 255]);
expect(Array.from(dest.subarray(stride, stride + 8))).toEqual([0, 3, 0, 255, 1, 3, 0, 255]);
} }
}); });
@@ -201,12 +379,7 @@ test('null format', async () => {
expect(() => sample.allocationSize()).toThrow('when format is null'); expect(() => sample.allocationSize()).toThrow('when format is null');
await expect(async () => sample.copyTo(new ArrayBuffer())).rejects.toThrow('when format is null'); await expect(async () => sample.copyTo(new ArrayBuffer())).rejects.toThrow('when format is null');
// Even this throws :( // BUT: See https://github.com/Vanilagy/mediabunny/issues/267
// See https://github.com/Vanilagy/mediabunny/issues/267
expect(() => sample.allocationSize({ format: 'RGBA' })).toThrow('when format is null');
// Uncomment this if the RGBA conversion works again:
/*
const size = sample.allocationSize({ format: 'RGBA' }); const size = sample.allocationSize({ format: 'RGBA' });
expect(size).toBe(1280 * 720 * 4); expect(size).toBe(1280 * 720 * 4);
const buffer = new ArrayBuffer(size); const buffer = new ArrayBuffer(size);
@@ -223,5 +396,4 @@ test('null format', async () => {
await sample.copyTo(buffer, { format: 'RGBX' }); await sample.copyTo(buffer, { format: 'RGBX' });
await sample.copyTo(buffer, { format: 'BGRA' }); await sample.copyTo(buffer, { format: 'BGRA' });
await sample.copyTo(buffer, { format: 'BGRX' }); await sample.copyTo(buffer, { format: 'BGRX' });
*/
}); });
+76 -59
View File
@@ -7,7 +7,8 @@ import { assert, toUint8Array } from '../../src/misc.js';
import { EncodedPacketSink, VideoSampleSink } from '../../src/media-sink.js'; import { EncodedPacketSink, VideoSampleSink } from '../../src/media-sink.js';
import { NodeAvVideoDecoder } from '../../packages/server/src/video-decoder.js'; import { NodeAvVideoDecoder } from '../../packages/server/src/video-decoder.js';
import { NodeAvVideoEncoder } from '../../packages/server/src/video-encoder.js'; import { NodeAvVideoEncoder } from '../../packages/server/src/video-encoder.js';
import { VideoSample } from '../../src/sample.js'; import { NodeAvAudioDecoder } from '../../packages/server/src/audio-decoder.js';
import { AudioSample, VideoSample } from '../../src/sample.js';
import { buildVideoCodecString, VideoCodec } from '../../src/codec.js'; import { buildVideoCodecString, VideoCodec } from '../../src/codec.js';
import { EncodedPacket } from '../../src/packet.js'; import { EncodedPacket } from '../../src/packet.js';
import { import {
@@ -122,12 +123,6 @@ describe('Video', async () => {
expect(meta.decoderConfig!.codec.startsWith('avc1.')).toBe(true); expect(meta.decoderConfig!.codec.startsWith('avc1.')).toBe(true);
expect(meta.decoderConfig!.codedWidth).toBe(1280); expect(meta.decoderConfig!.codedWidth).toBe(1280);
expect(meta.decoderConfig!.codedHeight).toBe(720); expect(meta.decoderConfig!.codedHeight).toBe(720);
expect(meta.decoderConfig!.colorSpace).toEqual({
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
});
expect(meta.decoderConfig!.description).toBeDefined(); expect(meta.decoderConfig!.description).toBeDefined();
} }
@@ -208,12 +203,6 @@ describe('Video', async () => {
expect(sample.codedHeight).toBe(720); expect(sample.codedHeight).toBe(720);
expect(sample.timestamp).toBe(i / 30); expect(sample.timestamp).toBe(i / 30);
expect(sample.duration).toBe(1 / 30); expect(sample.duration).toBe(1 / 30);
expect(sample.colorSpace).toEqual({
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
});
const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' })); const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' }));
await sample.copyTo(buf, { format: 'RGBX' }); await sample.copyTo(buf, { format: 'RGBX' });
@@ -248,12 +237,6 @@ describe('Video', async () => {
expect(sample.codedHeight).toBe(720); expect(sample.codedHeight).toBe(720);
expect(sample.timestamp).toBe(i / 30); expect(sample.timestamp).toBe(i / 30);
expect(sample.duration).toBe(1 / 30); expect(sample.duration).toBe(1 / 30);
expect(sample.colorSpace).toEqual({
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
});
const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' })); const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' }));
await sample.copyTo(buf, { format: 'RGBX' }); await sample.copyTo(buf, { format: 'RGBX' });
@@ -292,14 +275,6 @@ describe('Video', async () => {
expect(sample.timestamp).toBe(i / 30); expect(sample.timestamp).toBe(i / 30);
expect(sample.duration).toBe(1 / 30); expect(sample.duration).toBe(1 / 30);
// Undefined, for some reason:
expect(sample.colorSpace).toEqual({
primaries: null,
transfer: null,
matrix: null,
fullRange: false,
});
const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' })); const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' }));
await sample.copyTo(buf, { format: 'RGBX' }); await sample.copyTo(buf, { format: 'RGBX' });
@@ -336,14 +311,6 @@ describe('Video', async () => {
expect(sample.timestamp).toBe(i / 30); expect(sample.timestamp).toBe(i / 30);
expect(sample.duration).toBe(1 / 30); expect(sample.duration).toBe(1 / 30);
// Undefined, for some reason:
expect(sample.colorSpace).toEqual({
primaries: null,
transfer: null,
matrix: null,
fullRange: false,
});
const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' })); const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' }));
await sample.copyTo(buf, { format: 'RGBX' }); await sample.copyTo(buf, { format: 'RGBX' });
@@ -368,12 +335,6 @@ describe('Video', async () => {
expect(sample.codedHeight).toBe(720); expect(sample.codedHeight).toBe(720);
expect(sample.timestamp).toBe(i / 30); expect(sample.timestamp).toBe(i / 30);
expect(sample.duration).toBe(1 / 30); expect(sample.duration).toBe(1 / 30);
expect(sample.colorSpace).toEqual({
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'bt470bg',
fullRange: false,
});
const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' })); const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' }));
await sample.copyTo(buf, { format: 'RGBX' }); await sample.copyTo(buf, { format: 'RGBX' });
@@ -399,12 +360,6 @@ describe('Video', async () => {
expect(sample.codedHeight).toBe(720); expect(sample.codedHeight).toBe(720);
expect(sample.timestamp).toBe(i / 30); expect(sample.timestamp).toBe(i / 30);
expect(sample.duration).toBe(1 / 30); expect(sample.duration).toBe(1 / 30);
expect(sample.colorSpace).toEqual({
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: null,
fullRange: false,
});
const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' })); const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' }));
await sample.copyTo(buf, { format: 'RGBX' }); await sample.copyTo(buf, { format: 'RGBX' });
@@ -430,12 +385,6 @@ describe('Video', async () => {
expect(sample.codedHeight).toBe(720); expect(sample.codedHeight).toBe(720);
expect(sample.timestamp).toBe(i / 30); expect(sample.timestamp).toBe(i / 30);
expect(sample.duration).toBe(1 / 30); expect(sample.duration).toBe(1 / 30);
expect(sample.colorSpace).toEqual({
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: false,
});
const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' })); const buf = new Uint8Array(sample.allocationSize({ format: 'RGBX' }));
await sample.copyTo(buf, { format: 'RGBX' }); await sample.copyTo(buf, { format: 'RGBX' });
@@ -668,12 +617,6 @@ describe('Video', async () => {
expect(sample.rotation).toBe(0); expect(sample.rotation).toBe(0);
expect(sample.timestamp).toBe(0); expect(sample.timestamp).toBe(0);
expect(sample.duration).toBe(1 / 25); expect(sample.duration).toBe(1 / 25);
expect(sample.colorSpace).toEqual({
primaries: null,
transfer: null,
matrix: 'bt470bg',
fullRange: true,
});
// Default expected YUV size // Default expected YUV size
expect(sample.allocationSize()).toBe(1920 * 1080 * 1.5); expect(sample.allocationSize()).toBe(1920 * 1080 * 1.5);
@@ -792,3 +735,77 @@ describe('Video', async () => {
]); ]);
}); });
}); });
/*
describe('Audio', async () => {
test('Decoder lifecycle', async () => {
using input = new Input({
source: new FilePathSource('./test/public/trim-buck-bunny-ffmpeg.ts'),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
assert(audioTrack);
const decoder = new NodeAvAudioDecoder();
// @ts-expect-error Readonly
decoder.codec = await audioTrack.getCodec();
// @ts-expect-error Readonly
decoder.config = await audioTrack.getDecoderConfig();
let sampleCount = 0;
const packetTimestamps: number[] = [];
// @ts-expect-error Readonly
decoder.onSample = (sample: AudioSample) => {
expect(sample.timestamp).toBe(packetTimestamps[sampleCount]);
if (sampleCount > 0) {
expect(sample.duration).toBeCloseTo(
packetTimestamps[sampleCount]! - packetTimestamps[sampleCount - 1]!,
);
}
sampleCount++;
sample.close();
};
await decoder.init();
const sink = new EncodedPacketSink(audioTrack);
let packetCount = 0;
for await (const packet of sink.packets()) {
packetTimestamps.push(packet.timestamp);
await decoder.decode(packet);
if (++packetCount === 10) {
break;
}
}
await decoder.flush();
expect(sampleCount).toBe(10);
// And, go again
sampleCount = 0;
packetTimestamps.length = 0;
packetCount = 0;
for await (const packet of sink.packets((await sink.getKeyPacket(5))!)) {
packetTimestamps.push(packet.timestamp);
await decoder.decode(packet);
if (++packetCount === 10) {
break;
}
}
await decoder.flush();
expect(sampleCount).toBe(10);
await decoder.close();
});
});
*/