From 1b8e4f9f4b28a876acf693ef78f74e99a1feb624 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:12:48 +0200 Subject: [PATCH] Add alpha option to Conversion API --- dev/convert.html | 4 +- docs/guide/converting-media-files.md | 1 + src/conversion.ts | 21 +++++- test/browser/transparency.test.ts | 102 +++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index ebbbf05..4ab8d80 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -24,7 +24,7 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp3OutputFormat({}); + const outputFormat = new Mediabunny.WebMOutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; @@ -92,6 +92,8 @@ }, */ video: () => ({ + alpha: 'keep', + width: 320, //discard: true, //discard: true, //crop: { diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index 164963d..d794931 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -114,6 +114,7 @@ type ConversionVideoOptions = { frameRate?: number; codec?: VideoCodec; bitrate?: number | Quality; + alpha?: 'discard' | 'keep'; // Defaults to 'discard' forceTranscode?: boolean; }; ``` diff --git a/src/conversion.ts b/src/conversion.ts index 066a392..120c846 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -153,6 +153,12 @@ export type ConversionVideoOptions = { codec?: VideoCodec; /** The desired bitrate of the output video. */ bitrate?: number | Quality; + /** + * Whether to discard or keep the transparency information of the input video. The default is `'discard'`. Note that + * for `'keep'` to produce a transparent video, you must use an output config that supports it, such as WebM with + * VP9. + */ + alpha?: 'discard' | 'keep'; /** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */ forceTranscode?: boolean; }; @@ -212,7 +218,7 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) throw new TypeError('options.video.height, when provided, must be a positive integer.'); } if (videoOptions?.fit !== undefined && !['fill', 'contain', 'cover'].includes(videoOptions.fit)) { - throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".'); + throw new TypeError('options.video.fit, when provided, must be one of \'fill\', \'contain\', or \'cover\'.'); } if ( videoOptions?.width !== undefined @@ -236,6 +242,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) ) { throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.'); } + if (videoOptions?.alpha !== undefined && !['discard', 'keep'].includes(videoOptions.alpha)) { + throw new TypeError('options.video.alpha, when provided, must be either \'discard\' or \'keep\'.'); + } }; const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => { @@ -672,6 +681,8 @@ export class Conversion { || (totalRotation !== 0 && !outputSupportsRotation) || !!crop; + const alpha = trackOptions.alpha ?? 'discard'; + let videoCodecs = this.output.format.getSupportedVideoCodecs(); if ( !needsTranscode @@ -704,6 +715,12 @@ export class Conversion { return; } + if (alpha === 'discard') { + // Feels hacky given that the rest of the packet is readonly. But, works for now. + delete packet.sideData.alpha; + delete packet.sideData.alphaByteLength; + } + await source.add(packet, meta); this._reportProgress(track.id, packet.timestamp + packet.duration); } @@ -742,6 +759,7 @@ export class Conversion { codec: encodableCodec, bitrate, sizeChangeBehavior: trackOptions.fit ?? 'passThrough', + alpha, onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), }; @@ -796,6 +814,7 @@ export class Conversion { rotation: totalRotation, // Bake the rotation into the output crop: trackOptions.crop, poolSize: 1, + alpha: alpha === 'keep', }); const iterator = sink.canvases(this._startTimestamp, this._endTimestamp); const frameRate = trackOptions.frameRate; diff --git a/test/browser/transparency.test.ts b/test/browser/transparency.test.ts index f7cb489..743e826 100644 --- a/test/browser/transparency.test.ts +++ b/test/browser/transparency.test.ts @@ -9,6 +9,7 @@ import { BufferTarget } from '../../src/target.js'; import { CanvasSource, VideoSampleSource } from '../../src/media-source.js'; import { canEncodeVideo, QUALITY_HIGH } from '../../src/encode.js'; import { VideoSample } from '../../src/sample.js'; +import { Conversion } from '../../src/conversion.js'; test('Can decode transparent video', async () => { using input = new Input({ @@ -269,3 +270,104 @@ test('Positive encodability check with alpha', async () => { const result = await canEncodeVideo('vp9', { alpha: 'keep' }); expect(result).toBe(true); }); + +test('Can transmux transparent video, discards alpha by default', async () => { + using input = new Input({ + source: new UrlSource('/transparency.webm'), + formats: ALL_FORMATS, + }); + const output = new Output({ + format: new WebMOutputFormat(), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ + input, + output, + }); + await conversion.execute(); + + using outputInput = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const videoTrack = (await outputInput.getPrimaryVideoTrack())!; + expect(await videoTrack.canBeTransparent()).toBe(false); + + const sink = new VideoSampleSink(videoTrack); + const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; + expect(sample.hasAlpha).toBe(false); +}); + +test('Can transmux transparent video, can keep alpha', async () => { + using input = new Input({ + source: new UrlSource('/transparency.webm'), + formats: ALL_FORMATS, + }); + const output = new Output({ + format: new WebMOutputFormat(), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ + input, + output, + video: { + alpha: 'keep', + }, + }); + await conversion.execute(); + + using outputInput = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const videoTrack = (await outputInput.getPrimaryVideoTrack())!; + expect(await videoTrack.canBeTransparent()).toBe(true); + + const sink = new VideoSampleSink(videoTrack); + const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; + expect(sample.format).toContain('A'); + expect(sample.hasAlpha).toBe(true); +}); + +test('Can reencode transparent video, keeping alpha', async () => { + using input = new Input({ + source: new UrlSource('/transparency.webm'), + formats: ALL_FORMATS, + }); + const output = new Output({ + format: new WebMOutputFormat(), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ + input, + output, + video: { + width: 320, + alpha: 'keep', + }, + trim: { + start: 0, + end: 0.5, + }, + }); + await conversion.execute(); + + using outputInput = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const videoTrack = (await outputInput.getPrimaryVideoTrack())!; + expect(await videoTrack.canBeTransparent()).toBe(true); + expect(videoTrack.displayWidth).toBe(320); + + const sink = new VideoSampleSink(videoTrack); + const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; + expect(sample.format).toContain('A'); + expect(sample.hasAlpha).toBe(true); +});