Compare commits

...
20 Commits
Author SHA1 Message Date
Vanilagy 7580935639 Bump patch 2026-01-06 13:36:29 +01:00
Vanilagy 53824536fa Remove packet iteration upper bound for mediaSamplesInRange (fixes #269) 2026-01-06 13:35:52 +01:00
Vanilagy 8f43086e3d Make allocationSize and copyTo always throw if internal format is null (closes #267) 2026-01-05 11:07:32 +01:00
Vanilagy 9343607fe1 Add deil sponsor 2026-01-02 12:33:35 +01:00
Vanilagy f877fabc60 Bump patch 2025-12-30 16:20:52 +01:00
Vanilagy a2115b1de7 Add workaround for WebKit AudioData.copyTo bug (fixes #260) 2025-12-30 16:10:53 +01:00
Vanilagy 8d46d50c39 Document that ADTS is append-only 2025-12-30 14:11:32 +01:00
Vanilagy 19219fc1ce Add Polotno sponsor 2025-12-29 20:28:13 +01:00
Vanilagy 05070f7c88 Bump patch 2025-12-24 14:17:46 +01:00
Vanilagy 736cc50fa7 Fix aborted UrlSource not aborting request 2025-12-24 14:15:31 +01:00
Vanilagy 9ba63f5d95 Add Roman Rädle 2025-12-23 20:16:43 +01:00
Vanilagy 17c6bff1dc Bump patch 2025-12-19 16:05:46 +01:00
Vanilagy c86af052f5 Add test for Annex B to length-prefixed conversion 2025-12-19 16:04:27 +01:00
Vanilagy 831e838f74 Implement workaround for https://issues.chromium.org/issues/470109459 2025-12-19 13:06:11 +01:00
Vanilagy b730a24138 Bump minor version 2025-12-17 15:33:00 +01:00
Vanilagy ccbf14565c (Hopefully) improve error messages and stack traces a bit more (see #244) 2025-12-17 15:31:51 +01:00
Vanilagy 15bdd072e1 Add options to VideoSample allocationSize and copyTo methods, add explicit error message when format is null (closes #256) 2025-12-17 15:10:25 +01:00
4dd747a0a6 Conversion.init(hardwareAcceleration) option (#254)
* Support Conversion.init(hardwareAcceleration) option passed down to the VideoEncodingConfig.

fix #253

* Few changes

---------

Co-authored-by: Raphaël Droz <[email protected]>
Co-authored-by: Vanilagy <[email protected]>
2025-12-17 10:28:29 +00:00
Vanilagy 228721d68f Add VideoSampleColorSpace 2025-12-16 18:47:50 +01:00
Jonny BurgerandGitHub ea9ba96ae3 Add Dark Mode Remotion Logo in README (#249)
* Add Dark Mode Remotion Logo in README

* Update logo on website
2025-12-12 10:44:57 +01:00
22 changed files with 937 additions and 145 deletions
+4 -1
View File
@@ -17,7 +17,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<div align="center">
<a href="https://remotion.dev/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/remotion.png" width="60" height="60" alt="Remotion">
<picture>
<source srcset="./docs/public/sponsors/remotion-dark.png" media="(prefers-color-scheme: dark)">
<img src="./docs/public/sponsors/remotion-light.png" width="60" height="60" alt="Remotion">
</picture>
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.gling.ai/" target="_blank" rel="sponsored">
+4 -2
View File
@@ -126,6 +126,7 @@ type ConversionVideoOptions = {
codec?: VideoCodec;
bitrate?: number | Quality;
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
keyFrameInterval?: number;
forceTranscode?: boolean;
process?: (sample: VideoSample) => MaybePromise<
@@ -193,9 +194,10 @@ Use the `codec` property to control the codec of the output track. This should b
Use the `bitrate` property to control the bitrate of the output video. For example, you can use this field to compress the video track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value.
Use the `keyFrameInterval` property to control the maximum interval in seconds between key frames in the output video. Setting this fields forces a transcode.
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
Use the `hardwareAcceleration` property to control whether hardware or software acceleration is used for video transcoding.
### Processing video
The `process` property can be used to define a custom video sample processing function, e.g. for [applying overlays](./quick-start#add-a-video-overlay), color transformations, or timestamp modifications. You are expected to perform this processing yourself, for example using the Canvas API.
@@ -448,4 +450,4 @@ On the flip side, you can always query which input tracks made it into the outpu
```ts
const conversion = await Conversion.init({ input, output });
conversion.utilizedTracks; // => InputTrack[]
```
```
+4
View File
@@ -272,6 +272,10 @@ const output = new Output({
});
```
::: info
This format ensures [append-only writing](#append-only-writing).
:::
The following options are available:
```ts
type AdtsOutputFormatOptions = {
+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:
```ts
const bytes = new Uint8Array(bytesNeeded);
videoSample.copyTo(bytes);
const planeLayout = await videoSample.copyTo(bytes);
```
::: info
The data will always be in the pixel format specified in the `format` field.
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()`.
You can pass additional options to `allocationSize` and `copyTo` to extract data in a different pixel format.
:::
---
+4 -1
View File
@@ -91,7 +91,7 @@ const bundleSizes = [
const sponsors = {
gold: [
{ image: '/sponsors/remotion.png', name: 'Remotion', url: 'https://remotion.dev/' },
{ image: '/sponsors/remotion-light.png', name: 'Remotion', url: 'https://remotion.dev/' },
{ image: '/sponsors/gling.svg', name: 'Gling AI', url: 'https://www.gling.ai/' },
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
@@ -106,6 +106,8 @@ const sponsors = {
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/82552321', name: 'Polotno', url: 'https://github.com/polotno-project' },
{ image: 'https://avatars.githubusercontent.com/u/489051', name: 'Roman Rädle', url: 'https://github.com/raedle' },
{ image: 'https://avatars.githubusercontent.com/u/197597', name: 'Christopher Chedeau', url: 'https://github.com/vjeux' },
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
@@ -114,6 +116,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/139718', name: 'Anton Kosiakin', url: 'https://github.com/deil' },
{ image: 'https://avatars.githubusercontent.com/u/56988069', name: 'SyhabouthAlex', url: 'https://github.com/SyhabouthAlex' },
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.26.0",
"version": "1.27.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.26.0",
"version": "1.27.4",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7739,9 +7739,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.25.8",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.8.tgz",
"integrity": "sha512-2WCa9WtEbHOvg5rAWXQjVE+O/r01fpj65ymZdA2XhIeaWmdDQ40p32F0WY6tdBi+aeY+4ldwhAOpo0eyVrYTGg==",
"version": "1.27.3",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.27.3.tgz",
"integrity": "sha512-hlzmgzMznp9DhA5fMJKS5yEAyfCUMxAc+DbSPxD4J1J2cYVl1L+pZLndkt5xLlD5aB5eHEnphHMW14ammMlUXg==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12065,7 +12065,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.26.0",
"version": "1.27.4",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.26.0",
"version": "1.27.4",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.26.0",
"version": "1.27.4",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+57 -25
View File
@@ -24,6 +24,7 @@ import {
toUint8Array,
getChromiumVersion,
isChromium,
setUint24,
} from './misc';
import { PacketType } from './packet';
import { MetadataTags } from './metadata';
@@ -161,38 +162,54 @@ const removeEmulationPreventionBytes = (data: Uint8Array) => {
return new Uint8Array(result);
};
/** Converts an AVC packet in Annex B format to length-prefixed format. */
export const transformAnnexBToLengthPrefixed = (packetData: Uint8Array) => {
const NAL_UNIT_LENGTH_SIZE = 4;
const ANNEX_B_START_CODE = new Uint8Array([0, 0, 0, 1]);
const nalUnits = findNalUnitsInAnnexB(packetData);
if (nalUnits.length === 0) {
// If no NAL units were found, it's not valid Annex B data
return null;
}
let totalSize = 0;
for (const nalUnit of nalUnits) {
totalSize += NAL_UNIT_LENGTH_SIZE + nalUnit.byteLength;
}
const avccData = new Uint8Array(totalSize);
const dataView = new DataView(avccData.buffer);
export const concatNalUnitsInAnnexB = (nalUnits: Uint8Array[]) => {
const totalLength = nalUnits.reduce((a, b) => a + ANNEX_B_START_CODE.byteLength + b.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
// Write each NAL unit with its length prefix
for (const nalUnit of nalUnits) {
const length = nalUnit.byteLength;
result.set(ANNEX_B_START_CODE, offset);
offset += ANNEX_B_START_CODE.byteLength;
dataView.setUint32(offset, length, false);
offset += 4;
avccData.set(nalUnit, offset);
result.set(nalUnit, offset);
offset += nalUnit.byteLength;
}
return avccData;
return result;
};
export const concatNalUnitsInLengthPrefixed = (nalUnits: Uint8Array[], lengthSize: 1 | 2 | 3 | 4) => {
const totalLength = nalUnits.reduce((a, b) => a + lengthSize + b.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const nalUnit of nalUnits) {
const dataView = new DataView(result.buffer, result.byteOffset, result.byteLength);
switch (lengthSize) {
case 1:
dataView.setUint8(offset, nalUnit.byteLength);
break;
case 2:
dataView.setUint16(offset, nalUnit.byteLength, false);
break;
case 3:
setUint24(dataView, offset, nalUnit.byteLength, false);
break;
case 4:
dataView.setUint32(offset, nalUnit.byteLength, false);
break;
}
offset += lengthSize;
result.set(nalUnit, offset);
offset += nalUnit.byteLength;
}
return result;
};
// Data specified in ISO 14496-15
@@ -227,7 +244,22 @@ export const extractAvcNalUnits = (packetData: Uint8Array, decoderConfig: VideoD
}
};
const extractNalUnitTypeForAvc = (data: Uint8Array) => {
export const concatAvcNalUnits = (nalUnits: Uint8Array[], decoderConfig: VideoDecoderConfig) => {
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[4]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
return concatNalUnitsInLengthPrefixed(nalUnits, lengthSize);
} else {
// Stream is in Annex B format
return concatNalUnitsInAnnexB(nalUnits);
}
};
export const extractNalUnitTypeForAvc = (data: Uint8Array) => {
return data[0]! & 0x1F;
};
+15
View File
@@ -186,6 +186,11 @@ export type ConversionVideoOptions = {
* Setting this fields forces a transcode.
*/
keyFrameInterval?: number;
/**
* A hint that configures the hardware acceleration method used when transcoding. This is best left on
* `'no-preference'`, the default.
*/
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
/** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
/**
@@ -346,6 +351,15 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
) {
throw new TypeError('options.video.processedHeight, when provided, must be a positive integer.');
}
if (
videoOptions?.hardwareAcceleration !== undefined
&& !['no-preference', 'prefer-hardware', 'prefer-software'].includes(videoOptions.hardwareAcceleration)
) {
throw new TypeError(
'options.video.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or'
+ ' \'prefer-software\'.',
);
}
};
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
@@ -984,6 +998,7 @@ export class Conversion {
keyFrameInterval: trackOptions.keyFrameInterval,
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
alpha,
hardwareAcceleration: trackOptions.hardwareAcceleration,
};
const source = new VideoSampleSource(encodingConfig);
+3
View File
@@ -168,7 +168,10 @@ export {
AudioSampleCopyToOptions,
VideoSample,
VideoSampleInit,
VideoSamplePixelFormat,
VideoSampleColorSpace,
CropRectangle,
VIDEO_SAMPLE_PIXEL_FORMATS,
} from './sample';
export {
AudioBufferSink,
+8 -4
View File
@@ -25,11 +25,12 @@ import {
import { BufferTarget } from '../target';
import { EncodedPacket, PacketType } from '../packet';
import {
concatNalUnitsInLengthPrefixed,
extractAvcDecoderConfigurationRecord,
extractHevcDecoderConfigurationRecord,
findNalUnitsInAnnexB,
serializeAvcDecoderConfigurationRecord,
serializeHevcDecoderConfigurationRecord,
transformAnnexBToLengthPrefixed,
} from '../codec-data';
import { buildIsobmffMimeType } from './isobmff-misc';
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
@@ -464,15 +465,18 @@ export class IsobmffMuxer extends Muxer {
let packetData = packet.data;
if (trackData.info.requiresAnnexBTransformation) {
const transformedData = transformAnnexBToLengthPrefixed(packetData);
if (!transformedData) {
const nalUnits = findNalUnitsInAnnexB(packetData);
if (nalUnits.length === 0) {
// It's not valid Annex B data
throw new Error(
'Failed to transform packet data. Make sure all packets are provided in Annex B format, as'
+ ' specified in ITU-T-REC-H.264 and ITU-T-REC-H.265.',
);
}
packetData = transformedData;
// We don't strip things like SPS or PPS NALUs here, mainly because they can also appear in the middle
// of a stream and potentially modify the parameters of it. So, let's just leave them in to be sure.
packetData = concatNalUnitsInLengthPrefixed(nalUnits, 4);
}
const timestamp = this.validateAndNormalizeTimestamp(
+43 -26
View File
@@ -8,9 +8,12 @@
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
import {
concatAvcNalUnits,
deserializeAvcDecoderConfigurationRecord,
determineVideoPacketType,
extractAvcNalUnits,
extractHevcNalUnits,
extractNalUnitTypeForAvc,
extractNalUnitTypeForHevc,
HevcNalUnitType,
parseAvcSps,
@@ -429,7 +432,6 @@ export abstract class BaseMediaSampleSink<
// The following is the "pump" process that keeps pumping packets into the decoder
(async () => {
const decoderError = new Error();
const decoder = await this._createDecoder((sample) => {
onQueueDequeue();
if (sample.timestamp >= endTimestamp) {
@@ -467,7 +469,6 @@ export abstract class BaseMediaSampleSink<
}
}, (error) => {
if (!outOfBandError) {
error.stack = decoderError.stack; // Provide a more useful stack trace
outOfBandError = error;
onQueueNotEmpty();
}
@@ -479,23 +480,13 @@ export abstract class BaseMediaSampleSink<
let currentPacket: EncodedPacket | null = keyPacket;
let endPacket: EncodedPacket | undefined = undefined;
if (endTimestamp < Infinity) {
// When an end timestamp is set, we cannot simply use that for the packet iterator due to out-of-order
// frames (B-frames). Instead, we'll need to keep decoding packets until we get a frame that exceeds
// this end time. However, we can still put a bound on it: Since key frames are by definition never
// out of order, we can stop at the first key frame after the end timestamp.
const packet = await packetSink.getPacket(endTimestamp);
const keyPacket = !packet
? null
: packet.type === 'key' && packet.timestamp === endTimestamp
? packet
: await packetSink.getNextKeyPacket(packet, { verifyKeyPackets: true });
if (keyPacket) {
endPacket = keyPacket;
}
}
// B-frames make it exceedingly difficult to properly define an upper bound for packet iteration if an end
// timestamp is set, so we just don't do it. The case that makes it especially tricky is when the frames
// following a key frame have a lower timestamp than the keyframe; something that quite frequently happens
// in HEVC streams. The price to pay for not upper-bounding the packet iterator is a slight increase in
// decoder work at the end of the range, but the added correctness and reliability makes this tradeoff worth
// it.
const endPacket = undefined;
const packets = packetSink.packets(keyPacket ?? undefined, endPacket);
await packets.next(); // Skip the start packet as we already have it
@@ -613,7 +604,6 @@ export abstract class BaseMediaSampleSink<
// The following is the "pump" process that keeps pumping packets into the decoder
(async () => {
const decoderError = new Error();
const decoder = await this._createDecoder((sample) => {
onQueueDequeue();
@@ -641,7 +631,6 @@ export abstract class BaseMediaSampleSink<
}
}, (error) => {
if (!outOfBandError) {
error.stack = decoderError.stack; // Provide a more useful stack trace
outOfBandError = error;
onQueueNotEmpty();
}
@@ -891,6 +880,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
}
}
const stack = new Error('Decoding error').stack;
this.decoder = new VideoDecoder({
output: (frame) => {
try {
@@ -899,7 +890,10 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
this.onError(error as Error);
}
},
error: onError,
error: (error) => {
error.stack = stack; // Provide a more useful stack trace, the default one sucks
this.onError(error);
},
});
this.decoder.configure(this.decoderConfig);
}
@@ -927,8 +921,6 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
this.raslSkipped = true;
}
this.currentPacketIndex++;
if (this.customDecoder) {
this.customDecoderQueueSize++;
void this.customDecoderCallSerializer
@@ -941,9 +933,24 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
}
// Workaround for https://issues.chromium.org/issues/470109459
if (isChromium() && this.currentPacketIndex === 0 && this.codec === 'avc') {
const nalUnits = extractAvcNalUnits(packet.data, this.decoderConfig);
const filteredNalUnits = nalUnits.filter((x) => {
const type = extractNalUnitTypeForAvc(x);
// These trip up Chromium's key frame detection, so let's strip them
return !(type >= 20 && type <= 31);
});
const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
}
this.decoder.decode(packet.toEncodedVideoChunk());
this.decodeAlphaData(packet);
}
this.currentPacketIndex++;
}
decodeAlphaData(packet: EncodedPacket) {
@@ -999,6 +1006,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
}
};
const stack = new Error('Decoding error').stack;
this.alphaDecoder = new VideoDecoder({
output: (frame) => {
try {
@@ -1007,7 +1016,10 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
this.onError(error as Error);
}
},
error: this.onError,
error: (error) => {
error.stack = stack; // Provide a more useful stack trace, the default one sucks
this.onError(error);
},
});
this.alphaDecoder.configure(this.decoderConfig);
}
@@ -1763,6 +1775,8 @@ class AudioDecoderWrapper extends DecoderWrapper<AudioSample> {
void this.customDecoderCallSerializer.call(() => this.customDecoder!.init());
} else {
const stack = new Error('Decoding error').stack;
this.decoder = new AudioDecoder({
output: (data) => {
try {
@@ -1771,7 +1785,10 @@ class AudioDecoderWrapper extends DecoderWrapper<AudioSample> {
this.onError(error as Error);
}
},
error: onError,
error: (error) => {
error.stack = stack; // Provide a more useful stack trace, the default one sucks
this.onError(error);
},
});
this.decoder.configure(decoderConfig);
}
+471 -67
View File
@@ -17,6 +17,8 @@ import {
SetRequired,
isFirefox,
polyfillSymbolDispose,
assertNever,
isWebKit,
} from './misc';
polyfillSymbolDispose();
@@ -69,6 +71,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.
* @group Samples
@@ -77,9 +130,9 @@ if (typeof FinalizationRegistry !== 'undefined') {
export type VideoSampleInit = {
/**
* 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. */
codedWidth?: number;
/** The height of the frame in pixels. */
@@ -92,6 +145,8 @@ export type VideoSampleInit = {
duration?: number;
/** The color space of the frame. */
colorSpace?: VideoColorSpaceInit;
/** The byte layout of the planes of the frame. */
layout?: PlaneLayout[];
};
/**
@@ -103,14 +158,20 @@ export type VideoSampleInit = {
export class VideoSample implements Disposable {
/** @internal */
_data!: VideoFrame | OffscreenCanvas | Uint8Array | null;
/**
* Used for the ArrayBuffer-backed case.
* @internal
*/
_layout!: PlaneLayout[] | null;
/** @internal */
_closed: boolean = false;
/**
* The internal pixel format in which the frame is stored.
* [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format)
* The internal pixel format in which the frame is stored. Will be `null` if it's using an arbitrary internal
* format not representable by `VideoPixelFormat`.
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format)
*/
readonly format!: VideoPixelFormat | null;
readonly format!: VideoSamplePixelFormat | null;
/** The width of the frame in pixels. */
readonly codedWidth!: number;
/** The height of the frame in pixels. */
@@ -125,7 +186,7 @@ export class VideoSample implements Disposable {
/** The duration of the frame in seconds. */
readonly duration!: number;
/** The color space of the frame. */
readonly colorSpace!: VideoColorSpace;
readonly colorSpace!: VideoSampleColorSpace;
/** The width of the frame in pixels after rotation. */
get displayWidth() {
@@ -189,8 +250,8 @@ export class VideoSample implements Disposable {
if (!init || typeof init !== 'object') {
throw new TypeError('init must be an object.');
}
if (!('format' in init) || typeof init.format !== 'string') {
throw new TypeError('init.format must be a string.');
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.');
@@ -209,6 +270,7 @@ export class VideoSample implements Disposable {
}
this._data = toUint8Array(data).slice(); // Copy it
this._layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth!, init.codedHeight!);
this.format = init.format;
this.codedWidth = init.codedWidth!;
@@ -216,7 +278,7 @@ export class VideoSample implements Disposable {
this.rotation = init.rotation ?? 0;
this.timestamp = init.timestamp!;
this.duration = init.duration ?? 0;
this.colorSpace = new VideoColorSpace(init.colorSpace);
this.colorSpace = new VideoSampleColorSpace(init.colorSpace);
} 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.');
@@ -229,6 +291,7 @@ export class VideoSample implements Disposable {
}
this._data = data;
this._layout = null;
this.format = data.format;
// Copying the display dimensions here, assuming no innate VideoFrame rotation
@@ -239,7 +302,7 @@ export class VideoSample implements Disposable {
this.rotation = init?.rotation ?? 0;
this.timestamp = init?.timestamp ?? data.timestamp / 1e6;
this.duration = init?.duration ?? (data.duration ?? 0) / 1e6;
this.colorSpace = data.colorSpace;
this.colorSpace = new VideoSampleColorSpace(data.colorSpace);
} else if (
(typeof HTMLImageElement !== 'undefined' && data instanceof HTMLImageElement)
|| (typeof SVGImageElement !== 'undefined' && data instanceof SVGImageElement)
@@ -301,6 +364,7 @@ export class VideoSample implements Disposable {
// Draw it to a canvas
context.drawImage(data, 0, 0);
this._data = canvas;
this._layout = null;
this.format = 'RGBX';
this.codedWidth = width;
@@ -308,7 +372,7 @@ export class VideoSample implements Disposable {
this.rotation = init.rotation ?? 0;
this.timestamp = init.timestamp!;
this.duration = init.duration ?? 0;
this.colorSpace = new VideoColorSpace({
this.colorSpace = new VideoSampleColorSpace({
matrix: 'rgb',
primaries: 'bt709',
transfer: 'iec61966-2-1',
@@ -336,8 +400,11 @@ export class VideoSample implements Disposable {
rotation: this.rotation,
});
} else if (this._data instanceof Uint8Array) {
return new VideoSample(this._data.slice(), {
assert(this._layout);
return new VideoSample(this._data, {
format: this.format!,
layout: this._layout,
codedWidth: this.codedWidth,
codedHeight: this.codedHeight,
timestamp: this.timestamp,
@@ -378,16 +445,42 @@ export class VideoSample implements Disposable {
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`.
*/
allocationSize(options: VideoFrameCopyToOptions = {}): number {
validateVideoFrameCopyToOptions(options);
if (this._closed) {
throw new Error('VideoSample is closed.');
}
if (this.format === null) {
// https://github.com/Vanilagy/mediabunny/issues/267
// https://github.com/w3c/webcodecs/issues/920
throw new Error('Cannot get allocation size when format is null. Sorry!');
}
assert(this._data !== null);
if (!isVideoFrame(this._data)) {
if (
options.colorSpace
|| (options.format && options.format !== this.format)
|| options.layout
|| options.rect
) {
// Temporarily convert to VideoFrame to get it done
// TODO: Compute this directly without needing to go through VideoFrame
const videoFrame = this.toVideoFrame();
const size = videoFrame.allocationSize(options);
videoFrame.close();
return size;
}
}
if (isVideoFrame(this._data)) {
return this._data.allocationSize();
return this._data.allocationSize(options);
} else if (this._data instanceof Uint8Array) {
return this._data.byteLength;
} else {
@@ -395,23 +488,51 @@ 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`.
* @returns The byte layout of the planes of the copied data.
*/
async copyTo(destination: AllowSharedBufferSource, options: VideoFrameCopyToOptions = {}): Promise<PlaneLayout[]> {
if (!isAllowSharedBufferSource(destination)) {
throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.');
}
validateVideoFrameCopyToOptions(options);
if (this._closed) {
throw new Error('VideoSample is closed.');
}
if (this.format === null) {
throw new Error('Cannot copy video sample data when format is null. Sorry!');
}
assert(this._data !== null);
if (!isVideoFrame(this._data)) {
if (
options.colorSpace
|| (options.format && options.format !== this.format)
|| options.layout
|| options.rect
) {
// Temporarily convert to VideoFrame to get it done
// TODO: Do this directly without needing to go through VideoFrame
const videoFrame = this.toVideoFrame();
const layout = await videoFrame.copyTo(destination, options);
videoFrame.close();
return layout;
}
}
if (isVideoFrame(this._data)) {
await this._data.copyTo(destination);
return this._data.copyTo(destination, options);
} else if (this._data instanceof Uint8Array) {
assert(this._layout);
const dest = toUint8Array(destination);
dest.set(this._data);
return this._layout;
} else {
const canvas = this._data;
const context = canvas.getContext('2d');
@@ -420,6 +541,11 @@ export class VideoSample implements Disposable {
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
const dest = toUint8Array(destination);
dest.set(imageData.data);
return [{
offset: 0,
stride: 4 * this.codedWidth,
}];
}
}
@@ -441,7 +567,7 @@ export class VideoSample implements Disposable {
});
} else if (this._data instanceof Uint8Array) {
return new VideoFrame(this._data, {
format: this.format!,
format: this.format! as VideoPixelFormat,
codedWidth: this.codedWidth,
codedHeight: this.codedHeight,
timestamp: this.microsecondTimestamp,
@@ -805,6 +931,40 @@ export class VideoSample implements Disposable {
}
}
/**
* 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) {
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;
};
@@ -853,7 +1013,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'],
);
@@ -1092,6 +1413,7 @@ export class AudioSample implements Disposable {
const { planeIndex, format, frameCount: optFrameCount, frameOffset: optFrameOffset } = options;
const srcFormat = this.format;
const destFormat = format ?? this.format;
if (!destFormat) throw new Error('Destination format not determined');
@@ -1126,58 +1448,31 @@ export class AudioSample implements Disposable {
const writeFn = getWriteFunction(destFormat);
if (isAudioData(this._data)) {
if (destIsPlanar) {
if (destFormat === 'f32-planar') {
// Simple, since the browser must support f32-planar, we can just delegate here
this._data.copyTo(destination, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: 'f32-planar',
});
} else {
// Allocate temporary buffer for f32-planar data
const tempBuffer = new ArrayBuffer(copyFrameCount * 4);
const tempArray = new Float32Array(tempBuffer);
this._data.copyTo(tempArray, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: 'f32-planar',
});
// Convert each f32 sample to destination format
const tempView = new DataView(tempBuffer);
for (let i = 0; i < copyFrameCount; i++) {
const destOffset = i * destBytesPerSample;
const sample = tempView.getFloat32(i * 4, true);
writeFn(destView, destOffset, sample);
}
}
if (isWebKit() && numChannels > 2 && destFormat !== srcFormat) {
// WebKit bug workaround
doAudioDataCopyToWebKitWorkaround(
this._data,
destView,
srcFormat,
destFormat,
numChannels,
planeIndex,
frameOffset,
copyFrameCount,
);
} else {
// Destination is interleaved.
// Allocate a temporary Float32Array to hold one channel's worth of data.
const numCh = numChannels;
const temp = new Float32Array(copyFrameCount);
for (let ch = 0; ch < numCh; ch++) {
this._data.copyTo(temp, {
planeIndex: ch,
frameOffset,
frameCount: copyFrameCount,
format: 'f32-planar',
});
for (let i = 0; i < copyFrameCount; i++) {
const destIndex = i * numCh + ch;
const destOffset = destIndex * destBytesPerSample;
writeFn(destView, destOffset, temp[i]!);
}
}
// Per spec, only f32-planar conversion must be supported, but in practice, all browsers support all
// destination formats, so let's just delegate here:
this._data.copyTo(destination, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: destFormat,
});
}
} else {
const uint8Data = this._data;
const srcView = toDataView(uint8Data);
const srcFormat = this.format;
const readFn = getReadFunction(srcFormat);
const srcBytesPerSample = getBytesPerSample(srcFormat);
const srcIsPlanar = formatIsPlanar(srcFormat);
@@ -1520,3 +1815,112 @@ const getWriteFunction = (format: AudioSampleFormat): (view: DataView, offset: n
const isAudioData = (x: unknown): x is AudioData => {
return typeof AudioData !== 'undefined' && x instanceof AudioData;
};
/**
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format
* crashes the tab when there are more than 2 channels. This function works around that by always
* copying with the source format and then manually converting to the destination format.
*
* See https://bugs.webkit.org/show_bug.cgi?id=302521.
*/
const doAudioDataCopyToWebKitWorkaround = (
audioData: AudioData,
destView: DataView,
srcFormat: AudioSampleFormat,
destFormat: AudioSampleFormat,
numChannels: number,
planeIndex: number,
frameOffset: number,
copyFrameCount: number,
) => {
const readFn = getReadFunction(srcFormat);
const writeFn = getWriteFunction(destFormat);
const srcBytesPerSample = getBytesPerSample(srcFormat);
const destBytesPerSample = getBytesPerSample(destFormat);
const srcIsPlanar = formatIsPlanar(srcFormat);
const destIsPlanar = formatIsPlanar(destFormat);
if (destIsPlanar) {
if (srcIsPlanar) {
// src planar -> dest planar: copy single plane and convert
const data = new ArrayBuffer(copyFrameCount * srcBytesPerSample);
const dataView = toDataView(data);
audioData.copyTo(data, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
const srcOffset = i * srcBytesPerSample;
const destOffset = i * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
} else {
// src interleaved -> dest planar: copy all interleaved data, extract one channel
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
const dataView = toDataView(data);
audioData.copyTo(data, {
planeIndex: 0,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
const srcOffset = (i * numChannels + planeIndex) * srcBytesPerSample;
const destOffset = i * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
}
} else {
if (srcIsPlanar) {
// src planar -> dest interleaved: copy each plane and interleave
const planeSize = copyFrameCount * srcBytesPerSample;
const data = new ArrayBuffer(planeSize);
const dataView = toDataView(data);
for (let ch = 0; ch < numChannels; ch++) {
audioData.copyTo(data, {
planeIndex: ch,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
const srcOffset = i * srcBytesPerSample;
const destOffset = (i * numChannels + ch) * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
}
} else {
// src interleaved -> dest interleaved: copy all and convert
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
const dataView = toDataView(data);
audioData.copyTo(data, {
planeIndex: 0,
frameOffset,
frameCount: copyFrameCount,
format: srcFormat,
});
for (let i = 0; i < copyFrameCount; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const idx = i * numChannels + ch;
const srcOffset = idx * srcBytesPerSample;
const destOffset = idx * destBytesPerSample;
const sample = readFn(dataView, srcOffset);
writeFn(destView, destOffset, sample);
}
}
}
}
};
+6 -7
View File
@@ -263,6 +263,11 @@ export class BlobSource extends Source {
}
worker.running = false;
if (worker.aborted) {
// MDN: "Calling this method signals a loss of interest in the stream by a consumer."
await reader?.cancel();
}
}
/** @internal */
@@ -556,7 +561,7 @@ export class UrlSource extends Source {
}
if (worker.aborted) {
break;
continue; // Cleanup happens in next iteration
}
const { done, value } = readResult;
@@ -578,14 +583,8 @@ export class UrlSource extends Source {
this.onread?.(worker.currentPos, worker.currentPos + value.length);
this._orchestrator.supplyWorkerData(worker, value);
}
if (worker.aborted) {
break;
}
}
worker.running = false;
// The previous UrlSource had logic for circumventing https://issues.chromium.org/issues/436025873; I haven't
// been able to observe this bug with the new UrlSource (maybe because we're using response streaming), so the
// logic for that has vanished for now. Leaving a comment here if this becomes relevant again.
+227
View File
@@ -0,0 +1,227 @@
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');
// Even this throws :(
// See https://github.com/Vanilagy/mediabunny/issues/267
expect(() => sample.allocationSize({ format: 'RGBA' })).toThrow('when format is null');
// Uncomment this if the RGBA conversion works again:
/*
const size = sample.allocationSize({ format: 'RGBA' });
expect(size).toBe(1280 * 720 * 4);
const buffer = new ArrayBuffer(size);
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' });
*/
});
+54
View File
@@ -0,0 +1,54 @@
import { expect, test } from 'vitest';
import { Input } from '../../src/input.js';
import { BufferSource, FilePathSource } from '../../src/source.js';
import path from 'node:path';
import { ALL_FORMATS } from '../../src/input-format.js';
import { Output } from '../../src/output.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { Conversion } from '../../src/conversion.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
import { extractAvcNalUnits } from '../../src/codec-data.js';
const __dirname = new URL('.', import.meta.url).pathname;
test('Annex B to length-prefixed conversion, MP4', async () => {
using originalInput = new Input({
source: new FilePathSource(path.join(__dirname, '..', 'public/annex-b-avc.mkv')),
formats: ALL_FORMATS,
});
const originalVideoTrack = (await originalInput.getPrimaryVideoTrack())!;
const originalDecoderConfig = (await originalVideoTrack.getDecoderConfig())!;
expect(originalDecoderConfig.description).toBeUndefined();
expect(originalVideoTrack.codec).toBe('avc');
const originalSink = new EncodedPacketSink(originalVideoTrack);
const originalFirstPacket = await originalSink.getFirstPacket();
expect([...originalFirstPacket!.data.slice(0, 4)]).toEqual([0, 0, 0, 1]);
const originalNalUnits = extractAvcNalUnits(originalFirstPacket!.data, originalDecoderConfig);
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({ input: originalInput, output });
await conversion.execute();
using newInput = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const newVideoTrack = (await newInput.getPrimaryVideoTrack())!;
const newDecoderConfig = (await newVideoTrack.getDecoderConfig())!;
expect(newDecoderConfig.description).toBeDefined();
expect(newVideoTrack.codec).toBe('avc');
const newSink = new EncodedPacketSink(newVideoTrack);
const newFirstPacket = await newSink.getFirstPacket();
expect([...newFirstPacket!.data.slice(0, 4)]).not.toEqual([0, 0, 0, 1]); // Successfully converted
const newNalUnits = extractAvcNalUnits(newFirstPacket!.data, newDecoderConfig);
expect(newNalUnits).toEqual(originalNalUnits); // Content is the same though
});
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'vitest';
import { AudioSample, VideoSample } from '../../src/sample.js';
test('VideoSample creation from bytes', async () => {
const bytes = new Uint8Array(1024);
const sample = new VideoSample(bytes, {
codedWidth: 1280,
codedHeight: 720,
format: 'RGBA',
timestamp: 0,
});
sample.close();
});
test('AudioSample creation from bytes', async () => {
const bytes = new Uint8Array(1024);
const sample = new AudioSample({
data: bytes,
numberOfChannels: 2,
format: 's16',
sampleRate: 48000,
timestamp: 0,
});
sample.close();
});
Binary file not shown.