From ea9ba96ae3b7ef7a1835187ad61039ecf0414a6b Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Fri, 12 Dec 2025 10:44:57 +0100 Subject: [PATCH 1/6] Add Dark Mode Remotion Logo in README (#249) * Add Dark Mode Remotion Logo in README * Update logo on website --- README.md | 5 ++++- docs/index.md | 2 +- docs/public/sponsors/remotion-dark.png | Bin 0 -> 13526 bytes docs/public/sponsors/remotion-light.png | Bin 0 -> 19813 bytes docs/public/sponsors/remotion.png | Bin 9622 -> 0 bytes 5 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 docs/public/sponsors/remotion-dark.png create mode 100644 docs/public/sponsors/remotion-light.png delete mode 100644 docs/public/sponsors/remotion.png diff --git a/README.md b/README.md index 6d108d4..738f4fb 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
- Remotion + + + Remotion +      diff --git a/docs/index.md b/docs/index.md index 65a5618..2addbdb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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/' }, diff --git a/docs/public/sponsors/remotion-dark.png b/docs/public/sponsors/remotion-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..04e6ea1c30c498ea680ec2c1d9d9c47f150366ff GIT binary patch literal 13526 zcmeHu1zTG|uqcv3kOINof){tULV)7#?%qOhD^QBNOG=AdaVZ2XQmnKoRwTGP#fm?A z?|t{a_kP1W=i6^)XJ=-2_TxJJmEl;XQ+fI=ua5x(~W{kiSkd|6Nd5%mFmB79aOge;z4^_ zfinu=zj#cZ;J-`xsr{4rPl)~z^?x*eMEl>|sN^5f{|EmkCWIQY_XMzBD;s;Gpx_Yw zYp5uB1^=uycGfe38mX&^+j_clTibcs*mL{4zy2qMBIz&wgu2^9t?B*U-8{U-{iPWH zgG2lY|3~Iwr2h{VsH+sCk-8TBOHVI*dSPx}ZeB)dYO@pw(<0VN-;A2o9Mr<|6C{3+2Ma?^6>sIvz`p(`8UGD$IZ+0-@2bfCI3;y zHNBkepJx82Uz$(yKREv{+JDU>$@6dW|6?)#Y3YAZpG=j;mgM>GZIi~Puvo=JLE$%4 zQION~M?FNkj(=XzCp$54!#NN2)I9#FGg_*}f#&{K-bQfrFrE&#D5YW*9ishm3dPyx zgp^1SP$4 z-1`ULeOfHUPN`?-?ld{9^9q6y-Xv=U+~Up?vx`vvKpyhdpodAxWS}wL6%+?fyOuw{ z@=MA;BS`-7#F;K{)dnum8|-*Nq$5Xf*`dD?VTavgPB^k4lG)Ln)a{q+PcX4vtSD)J zTL#&Vlv^?MHZ=Qsl2T?RuExMF0V zoku9vOrB>MeV{xB;S}KUMO9u_+Nw9ixk;1tNXtxCf8_Pb0P3yKmU>6O<`}Rs-*ncV zWbks+C3ZJB91+oJ;Ybwo%f5Wt)@YcXi!A>iLh&cg9g_UtqsROqDH%#C_lBG?z<+IKL-Q$Zif`% zO{_U7W7*HVH_&i4yy9{W2q!#z&*pM9N(YrlU#<{KOu9#scs0b_p6{^RakZHqGg8f( zR##R|Im!zUJK7OAy+|X@KAWKQ(T-?Oz*yO~IxDU6@ zI~x66{8Sq@tX0DfzQsqxBLeUTP9P-zz1gDd)uMpwGIP~F1?PZ`++P8NW>nW_WCAW) zviwQ`a4VovDlJ$0O56Iip{JgKu5N|q;Ar_v;mBk2zJNSSahwR1OC1S_&RtW**@ym? zVTeJblbul0X4f`EbZAYZyD7?S5$5V52;Tn#GEx9nSMH{-59czz#si!cG=F9-inh9> z+GsJb#jI+~bB?@k?dwyOaDLLc#`o4y$#Hh)^(O=4#HbaCM*6FgJo@Da#|MfDCU-L1b7850MMdrhrMTRq-!r1g!Fl8K{Oo6OI88|twlp;v5HY8D>k zmG)M0U`CJ=6c3NGBn)w$`XRS7HNETIkt)%54=K^w&>qk&Y1++UUj-wQ6+s02HpSgd z7++Vxa9li~I@#{^}0l|b)FdkrKvF4qv9-UggLi`wSV(8y zVR3+SLI$xUo2E!rw?`3^dNs$4<5N+iIKwO;E8IhNZ#5M-!I!4qa_A5)p`bhY^NWO+ z8&J=dCg0L;QxiB8!qXbzC-KXCu46De°iH6^|g`cufJJ)X#`rbaRAty`!F2V73L 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 YSkPHoKaPIs-`6@SiW&;F@>UW53)RFT<^TWy literal 0 HcmV?d00001 diff --git a/docs/public/sponsors/remotion-light.png b/docs/public/sponsors/remotion-light.png new file mode 100644 index 0000000000000000000000000000000000000000..74b359227ba6646c0933da3f767083fe9c44d2b2 GIT binary patch literal 19813 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 KHmU`CJoyhZX(V9) literal 0 HcmV?d00001 diff --git a/docs/public/sponsors/remotion.png b/docs/public/sponsors/remotion.png deleted file mode 100644 index f76b0618f9d022027fe1de5b5834060c87fefd5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9622 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 Date: Tue, 16 Dec 2025 18:47:50 +0100 Subject: [PATCH 2/6] Add VideoSampleColorSpace --- src/index.ts | 1 + src/sample.ts | 42 +++++++++++++++++++++++--- test/node/node-sample-creation.test.ts | 27 +++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 test/node/node-sample-creation.test.ts diff --git a/src/index.ts b/src/index.ts index f5dbdf1..5434d1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,6 +168,7 @@ export { AudioSampleCopyToOptions, VideoSample, VideoSampleInit, + VideoSampleColorSpace, CropRectangle, } from './sample'; export { diff --git a/src/sample.ts b/src/sample.ts index fae15bf..02e454c 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -125,7 +125,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() { @@ -216,7 +216,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.'); @@ -239,7 +239,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) @@ -308,7 +308,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', @@ -805,6 +805,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; }; diff --git a/test/node/node-sample-creation.test.ts b/test/node/node-sample-creation.test.ts new file mode 100644 index 0000000..0cf4064 --- /dev/null +++ b/test/node/node-sample-creation.test.ts @@ -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(); +}); From 4dd747a0a6dceb266a0b5fe31ee3c37d2935efe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Droz?= Date: Wed, 17 Dec 2025 07:28:29 -0300 Subject: [PATCH 3/6] `Conversion.init(hardwareAcceleration)` option (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Support Conversion.init(hardwareAcceleration) option passed down to the VideoEncodingConfig. fix #253 * Few changes --------- Co-authored-by: Raphaël Droz Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- docs/guide/converting-media-files.md | 6 ++++-- src/conversion.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index c527f12..43d812a 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -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[] -``` \ No newline at end of file +``` diff --git a/src/conversion.ts b/src/conversion.ts index 6ea8994..862b6f0 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -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); From 15bdd072e1d430847f727db926d72919a1a1874e Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:10:25 +0100 Subject: [PATCH 4/6] Add options to VideoSample allocationSize and copyTo methods, add explicit error message when format is null (closes #256) --- docs/guide/packets-and-samples.md | 6 +- src/index.ts | 2 + src/sample.ts | 322 +++++++++++++++++++++++++++-- test/browser/video-samples.test.ts | 220 ++++++++++++++++++++ 4 files changed, 530 insertions(+), 20 deletions(-) create mode 100644 test/browser/video-samples.test.ts diff --git a/docs/guide/packets-and-samples.md b/docs/guide/packets-and-samples.md index 18baa04..aa5c295 100644 --- a/docs/guide/packets-and-samples.md +++ b/docs/guide/packets-and-samples.md @@ -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. ::: --- diff --git a/src/index.ts b/src/index.ts index 5434d1a..6cd723f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,8 +168,10 @@ export { AudioSampleCopyToOptions, VideoSample, VideoSampleInit, + VideoSamplePixelFormat, VideoSampleColorSpace, CropRectangle, + VIDEO_SAMPLE_PIXEL_FORMATS, } from './sample'; export { AudioBufferSink, diff --git a/src/sample.ts b/src/sample.ts index 02e454c..37ec910 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -17,6 +17,7 @@ import { SetRequired, isFirefox, polyfillSymbolDispose, + assertNever, } from './misc'; polyfillSymbolDispose(); @@ -69,6 +70,57 @@ if (typeof FinalizationRegistry !== 'undefined') { }); } +/** + * The list of {@link VideoSample} pixel formats. + * @group Samples + * @public + */ +export const VIDEO_SAMPLE_PIXEL_FORMATS = [ + // 4:2:0 Y, U, V + 'I420', + 'I420P10', + 'I420P12', + // 4:2:0 Y, U, V, A + 'I420A', + 'I420AP10', + 'I420AP12', + // 4:2:2 Y, U, V + 'I422', + 'I422P10', + 'I422P12', + // 4:2:2 Y, U, V, A + 'I422A', + 'I422AP10', + 'I422AP12', + // 4:4:4 Y, U, V + 'I444', + 'I444P10', + 'I444P12', + // 4:4:4 Y, U, V, A + 'I444A', + 'I444AP10', + 'I444AP12', + // 4:2:0 Y, UV + 'NV12', + // 4:4:4 RGBA + 'RGBA', + // 4:4:4 RGBX (opaque) + 'RGBX', + // 4:4:4 BGRA + 'BGRA', + // 4:4:4 BGRX (opaque) + 'BGRX', +] as const; +const VIDEO_SAMPLE_PIXEL_FORMATS_SET = new Set(VIDEO_SAMPLE_PIXEL_FORMATS); + +/** + * The internal pixel format with which a {@link VideoSample} is stored. + * [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format) for more. + * @group Samples + * @public + */ +export type VideoSamplePixelFormat = typeof VIDEO_SAMPLE_PIXEL_FORMATS[number]; + /** * Metadata used for VideoSample initialization. * @group Samples @@ -77,9 +129,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 +144,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 +157,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. */ @@ -189,8 +249,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 +269,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!; @@ -229,6 +290,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 @@ -301,6 +363,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; @@ -336,8 +399,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 +444,43 @@ 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`; + * specify an explicit RGB format in the options in this case. + */ + allocationSize(options: VideoFrameCopyToOptions = {}): number { + validateVideoFrameCopyToOptions(options); + if (this._closed) { throw new Error('VideoSample is closed.'); } + if ((options.format ?? this.format) === null) { + throw new Error( + 'Cannot get allocation size when format is null. Please manually provide an RGB pixel format in the' + + ' options instead.', + ); + } assert(this._data !== null); + if (!isVideoFrame(this._data)) { + if ( + options.colorSpace + || (options.format && options.format !== this.format) + || options.layout + || options.rect + ) { + // Temporarily convert to VideoFrame to get it done + const videoFrame = this.toVideoFrame(); + const size = videoFrame.allocationSize(options); + videoFrame.close(); + + return size; + } + } + if (isVideoFrame(this._data)) { - return this._data.allocationSize(); + return this._data.allocationSize(options); } else if (this._data instanceof Uint8Array) { return this._data.byteLength; } else { @@ -395,23 +488,54 @@ export class VideoSample implements Disposable { } } - /** Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. */ - async copyTo(destination: AllowSharedBufferSource) { + /** + * Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. Throws if `format` is `null`; + * specify an explicit RGB format in the options in this case. + * @returns The byte layout of the planes of the copied data. + */ + async copyTo(destination: AllowSharedBufferSource, options: VideoFrameCopyToOptions = {}): Promise { if (!isAllowSharedBufferSource(destination)) { throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.'); } + validateVideoFrameCopyToOptions(options); if (this._closed) { throw new Error('VideoSample is closed.'); } + if ((options.format ?? this.format) === null) { + throw new Error( + 'Cannot copy video sample data when format is null. Please manually provide an RGB pixel format in the' + + ' options instead.', + ); + } assert(this._data !== null); + if (!isVideoFrame(this._data)) { + if ( + options.colorSpace + || (options.format && options.format !== this.format) + || options.layout + || options.rect + ) { + // Temporarily convert to VideoFrame to get it done + const videoFrame = this.toVideoFrame(); + const layout = await videoFrame.copyTo(destination, options); + videoFrame.close(); + + return layout; + } + } + if (isVideoFrame(this._data)) { - 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 +544,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 +570,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, @@ -887,7 +1016,168 @@ export const validateCropRectangle = (crop: CropRectangle, prefix: string) => { } }; -const AUDIO_SAMPLE_FORMATS = new Set( +const validateVideoFrameCopyToOptions = (options: VideoFrameCopyToOptions) => { + if (!options || typeof options !== 'object') { + throw new TypeError('options must be an object.'); + } + if (options.colorSpace !== undefined && !['display-p3', 'srgb'].includes(options.colorSpace)) { + throw new TypeError('options.colorSpace, when provided, must be \'display-p3\' or \'srgb\'.'); + } + if (options.format !== undefined && typeof options.format !== 'string') { + throw new TypeError('options.format, when provided, must be a string.'); + } + if (options.layout !== undefined) { + if (!Array.isArray(options.layout)) { + throw new TypeError('options.layout, when provided, must be an array.'); + } + + for (const plane of options.layout) { + if (!plane || typeof plane !== 'object') { + throw new TypeError('Each entry in options.layout must be an object.'); + } + if (!Number.isInteger(plane.offset) || plane.offset < 0) { + throw new TypeError('plane.offset must be a non-negative integer.'); + } + if (!Number.isInteger(plane.stride) || plane.stride < 0) { + throw new TypeError('plane.stride must be a non-negative integer.'); + } + } + } + if (options.rect !== undefined) { + if (!options.rect || typeof options.rect !== 'object') { + throw new TypeError('options.rect, when provided, must be an object.'); + } + if (options.rect.x !== undefined && (!Number.isInteger(options.rect.x) || options.rect.x < 0)) { + throw new TypeError('options.rect.x, when provided, must be a non-negative integer.'); + } + if (options.rect.y !== undefined && (!Number.isInteger(options.rect.y) || options.rect.y < 0)) { + throw new TypeError('options.rect.y, when provided, must be a non-negative integer.'); + } + if (options.rect.width !== undefined && (!Number.isInteger(options.rect.width) || options.rect.width < 0)) { + throw new TypeError('options.rect.width, when provided, must be a non-negative integer.'); + } + if (options.rect.height !== undefined && (!Number.isInteger(options.rect.height) || options.rect.height < 0)) { + throw new TypeError('options.rect.height, when provided, must be a non-negative integer.'); + } + } +}; + +/** Implements logic from WebCodecs § 9.4.6 "Compute Layout and Allocation Size" */ +const createDefaultPlaneLayout = ( + format: VideoSamplePixelFormat, + codedWidth: number, + codedHeight: number, +): PlaneLayout[] => { + const planes = getPlaneConfigs(format); + const layouts: PlaneLayout[] = []; + let currentOffset = 0; + + for (const plane of planes) { + // Per § 9.8, dimensions are usually "rounded up to the nearest integer". + const planeWidth = Math.ceil(codedWidth / plane.widthDivisor); + const planeHeight = Math.ceil(codedHeight / plane.heightDivisor); + + const stride = planeWidth * plane.sampleBytes; + + // Tight packing + const planeSize = stride * planeHeight; + + layouts.push({ + offset: currentOffset, + stride: stride, + }); + + currentOffset += planeSize; + } + + return layouts; +}; + +type PlaneConfig = { + sampleBytes: number; + widthDivisor: number; // Horizontal sub-sampling factor + heightDivisor: number; // Vertical sub-sampling factor +}; + +/** Helper to retrieve plane configurations based on WebCodecs § 9.8 Pixel Format definitions. */ +const getPlaneConfigs = (format: VideoSamplePixelFormat): PlaneConfig[] => { + // Helper for standard YUV planes + const yuv = ( + yBytes: number, + uvBytes: number, + subX: number, + subY: number, + hasAlpha: boolean, + ): PlaneConfig[] => { + const configs: PlaneConfig[] = [ + { sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 }, + { sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY }, + { sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY }, + ]; + + if (hasAlpha) { + // Match luma dimensions + configs.push({ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 }); + } + + return configs; + }; + + switch (format) { + case 'I420': + return yuv(1, 1, 2, 2, false); + case 'I420P10': + case 'I420P12': + return yuv(2, 2, 2, 2, false); + case 'I420A': + return yuv(1, 1, 2, 2, true); + case 'I420AP10': + case 'I420AP12': + return yuv(2, 2, 2, 2, true); + + case 'I422': + return yuv(1, 1, 2, 1, false); + case 'I422P10': + case 'I422P12': + return yuv(2, 2, 2, 1, false); + case 'I422A': + return yuv(1, 1, 2, 1, true); + case 'I422AP10': + case 'I422AP12': + return yuv(2, 2, 2, 1, true); + + case 'I444': + return yuv(1, 1, 1, 1, false); + case 'I444P10': + case 'I444P12': + return yuv(2, 2, 1, 1, false); + case 'I444A': + return yuv(1, 1, 1, 1, true); + case 'I444AP10': + case 'I444AP12': + return yuv(2, 2, 1, 1, true); + + case 'NV12': + return [ + { sampleBytes: 1, widthDivisor: 1, heightDivisor: 1 }, + { sampleBytes: 2, widthDivisor: 2, heightDivisor: 2 }, // Interleaved U and V + ]; + + case 'RGBA': + case 'RGBX': + case 'BGRA': + case 'BGRX': + return [ + { sampleBytes: 4, widthDivisor: 1, heightDivisor: 1 }, + ]; + + default: + assertNever(format); + assert(false); + } +}; + +const AUDIO_SAMPLE_FORMATS = new Set( ['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'], ); diff --git a/test/browser/video-samples.test.ts b/test/browser/video-samples.test.ts new file mode 100644 index 0000000..b0c12e6 --- /dev/null +++ b/test/browser/video-samples.test.ts @@ -0,0 +1,220 @@ +import { expect, test } from 'vitest'; +import { VideoSample } from '../../src/sample.js'; + +test('allocationSize', async () => { + { + const canvas = new OffscreenCanvas(1280, 720); + canvas.getContext('2d'); + + using sample = new VideoSample(canvas, { timestamp: 0 }); + + const size1 = sample.allocationSize(); + expect(size1).toBe(1280 * 720 * 4); + + const size2 = sample.allocationSize({ + layout: [{ + offset: 0, + stride: 1300 * 4, + }], + }); + expect(size2).toBe(1300 * 720 * 4); + } + + { + const data = new Uint8Array(1280 * 720 * 4); + using sample = new VideoSample(data, { + timestamp: 0, + codedWidth: 1280, + codedHeight: 720, + format: 'RGBA', + }); + + const size1 = sample.allocationSize(); + expect(size1).toBe(1280 * 720 * 4); + + const size2 = sample.allocationSize({ + layout: [{ + offset: 0, + stride: 1300 * 4, + }], + }); + expect(size2).toBe(1300 * 720 * 4); + } + + { + const data = new Uint8Array(1280 * 720 * 1.5); + using sample = new VideoSample(data, { + timestamp: 0, + codedWidth: 1280, + codedHeight: 720, + format: 'I420', + }); + + const size1 = sample.allocationSize(); + expect(size1).toBe(1280 * 720 * 1.5); + + const size2 = sample.allocationSize({ format: 'RGBA' }); + expect(size2).toBe(1280 * 720 * 4); + + const size3 = sample.allocationSize({ + format: 'RGBA', + layout: [{ + offset: 0, + stride: 1300 * 4, + }], + }); + expect(size3).toBe(1300 * 720 * 4); + } +}); + +test('copyTo and plane layouts', async () => { + const buffer = new ArrayBuffer(1e7); + + { + const canvas = new OffscreenCanvas(1280, 720); + canvas.getContext('2d'); + + using sample = new VideoSample(canvas, { timestamp: 0 }); + const layout = await sample.copyTo(buffer); + + expect(layout).toEqual([{ + offset: 0, + stride: 1280 * 4, + }]); + } + + { + const canvas = new OffscreenCanvas(1280, 720); + canvas.getContext('2d'); + + using sample = new VideoSample(canvas, { timestamp: 0 }); + const layout = await sample.copyTo(buffer, { + layout: [{ + offset: 0, + stride: 1300 * 4, + }], + }); + + expect(layout).toEqual([{ + offset: 0, + stride: 1300 * 4, + }]); + } + + { + const data = new Uint8Array(1280 * 720 * 4); + using sample = new VideoSample(data, { + timestamp: 0, + codedWidth: 1280, + codedHeight: 720, + format: 'RGBA', + }); + const layout = await sample.copyTo(buffer); + + expect(layout).toEqual([{ + offset: 0, + stride: 1280 * 4, + }]); + } + + { + const data = new Uint8Array(1280 * 720 * 4); + using sample = new VideoSample(data, { + timestamp: 0, + codedWidth: 1280, + codedHeight: 720, + format: 'RGBA', + }); + const layout = await sample.copyTo(buffer, { + layout: [{ + offset: 0, + stride: 1300 * 4, + }], + }); + + expect(layout).toEqual([{ + offset: 0, + stride: 1300 * 4, + }]); + } + + { + const data = new Uint8Array(1300 * 720 * 4); + using sample = new VideoSample(data, { + timestamp: 0, + codedWidth: 1280, + codedHeight: 720, + format: 'RGBA', + layout: [{ + offset: 0, + stride: 1300 * 4, + }], + }); + const layout = await sample.copyTo(buffer); + + expect(layout).toEqual([{ + offset: 0, + stride: 1300 * 4, + }]); + + using clone = sample.clone(); + const clonedLayout = await clone.copyTo(buffer); + + expect(clonedLayout).toEqual([{ + offset: 0, + stride: 1300 * 4, + }]); + } + + { + const data = new Uint8Array(1280 * 720 * 1.5); + using sample = new VideoSample(data, { + timestamp: 0, + codedWidth: 1280, + codedHeight: 720, + format: 'I420', + }); + const layout = await sample.copyTo(buffer); + + expect(layout).toEqual([{ + offset: 0, + stride: 1280, + }, { + offset: 1280 * 720, + stride: 1280 / 2, + }, { + offset: 1280 * 720 + (1280 / 2) * (720 / 2), + stride: 1280 / 2, + }]); + } +}); + +test('null format', async () => { + const canvas = new OffscreenCanvas(1280, 720); + canvas.getContext('2d'); + + const frame = new VideoFrame(canvas, { timestamp: 0 }); + using sample = new VideoSample(frame); + // @ts-expect-error Sybau + sample.format = null; + + expect(() => sample.allocationSize()).toThrow('when format is null'); + await expect(async () => sample.copyTo(new ArrayBuffer())).rejects.toThrow('when format is null'); + + const size = sample.allocationSize({ format: 'RGBA' }); + expect(size).toBe(1280 * 720 * 4); + const buffer = new ArrayBuffer(size); + const layout = await sample.copyTo(buffer, { format: 'RGBA' }); + + expect(layout).toEqual([{ + offset: 0, + stride: 1280 * 4, + }]); + + sample.allocationSize({ format: 'RGBX' }); + sample.allocationSize({ format: 'BGRA' }); + sample.allocationSize({ format: 'BGRX' }); + await sample.copyTo(buffer, { format: 'RGBX' }); + await sample.copyTo(buffer, { format: 'BGRA' }); + await sample.copyTo(buffer, { format: 'BGRX' }); +}); From ccbf14565cba5ff1efbaeb4ad3d19fa8b0778a07 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:31:51 +0100 Subject: [PATCH 5/6] (Hopefully) improve error messages and stack traces a bit more (see #244) --- src/media-sink.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/media-sink.ts b/src/media-sink.ts index b94a38f..711d0c4 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -429,7 +429,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 +466,6 @@ export abstract class BaseMediaSampleSink< } }, (error) => { if (!outOfBandError) { - error.stack = decoderError.stack; // Provide a more useful stack trace outOfBandError = error; onQueueNotEmpty(); } @@ -613,7 +611,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 +638,6 @@ export abstract class BaseMediaSampleSink< } }, (error) => { if (!outOfBandError) { - error.stack = decoderError.stack; // Provide a more useful stack trace outOfBandError = error; onQueueNotEmpty(); } @@ -891,6 +887,8 @@ class VideoDecoderWrapper extends DecoderWrapper { } } + const stack = new Error('Decoding error').stack; + this.decoder = new VideoDecoder({ output: (frame) => { try { @@ -899,7 +897,10 @@ class VideoDecoderWrapper extends DecoderWrapper { 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); } @@ -999,6 +1000,8 @@ class VideoDecoderWrapper extends DecoderWrapper { } }; + const stack = new Error('Decoding error').stack; + this.alphaDecoder = new VideoDecoder({ output: (frame) => { try { @@ -1007,7 +1010,10 @@ class VideoDecoderWrapper extends DecoderWrapper { 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 +1769,8 @@ class AudioDecoderWrapper extends DecoderWrapper { void this.customDecoderCallSerializer.call(() => this.customDecoder!.init()); } else { + const stack = new Error('Decoding error').stack; + this.decoder = new AudioDecoder({ output: (data) => { try { @@ -1771,7 +1779,10 @@ class AudioDecoderWrapper extends DecoderWrapper { 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); } From b730a241386de758c43d8a00e7ca7901f1afbc1a Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:33:00 +0100 Subject: [PATCH 6/6] Bump minor version --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/mp3-encoder/package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index c35e2b0..796e73f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.26.0", + "version": "1.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.26.0", + "version": "1.27.0", "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.26.0", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.26.0.tgz", + "integrity": "sha512-0duZPn/vVpx+mKjOn3PNb/tBrtKUoq4n5+uO1JwyQm3cdaKYy7tVTEvTpXJxbpQ/CdnHn5YMVRg8mwi7fIgSYA==", "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.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index 178d998..02f0c56 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.26.0", + "version": "1.27.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index fe80545..ae9eaeb 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.26.0", + "version": "1.27.0", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs",