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);
+});