Compare commits

...
8 Commits
13 changed files with 340 additions and 48 deletions
+8 -5
View File
@@ -4,7 +4,7 @@
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script> <script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
<script type="module"> <script type="module">
MediabunnyMp3Encoder.registerMp3Encoder(); //MediabunnyMp3Encoder.registerMp3Encoder();
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
@@ -24,7 +24,7 @@
chunked: true, chunked: true,
chunkSize: 2**20 chunkSize: 2**20
}); });
const outputFormat = new Mediabunny.Mp3OutputFormat({}); const outputFormat = new Mediabunny.Mp4OutputFormat({});
const button = document.createElement('button'); const button = document.createElement('button');
button.textContent = 'Cancel'; button.textContent = 'Cancel';
@@ -92,6 +92,9 @@
}, },
*/ */
video: () => ({ video: () => ({
width: 300,
//alpha: 'keep',
//width: 320,
//discard: true, //discard: true,
//discard: true, //discard: true,
//crop: { //crop: {
@@ -126,7 +129,7 @@
//width: 200, //width: 200,
//height: 100, //height: 100,
}), }),
tags: { tags: {} ?? {
title: 'Bigggy', title: 'Bigggy',
artist: 'Buck Bunny', artist: 'Buck Bunny',
images: [{ images: [{
@@ -144,8 +147,8 @@
} }
}, },
trim: { trim: {
//start: 10, start: 0,
//end: 20 end: 10
}, },
}); });
console.log(conversion); console.log(conversion);
+13 -1
View File
@@ -13,6 +13,7 @@ It has the following features:
- Video rotation - Video rotation
- Video cropping - Video cropping
- Video frame rate adjustment - Video frame rate adjustment
- Video transparency removal/preservation
- Audio resampling - Audio resampling
- Audio up/downmixing - Audio up/downmixing
@@ -41,6 +42,14 @@ const output = new Output({
}); });
const conversion = await Conversion.init({ input, output }); const conversion = await Conversion.init({ input, output });
if (!conversion.isValid) {
// Conversion is invalid and cannot be executed without error.
// This field gives reasons for why tracks were discarded:
conversion.discardedTracks; // => DiscardedTrack[]
return;
}
await conversion.execute(); await conversion.execute();
// output.target.buffer contains the final file // output.target.buffer contains the final file
@@ -57,7 +66,7 @@ Unconfigured, the conversion process handles all the details automatically, such
- Copying media data whenever possible, otherwise transcoding it - Copying media data whenever possible, otherwise transcoding it
- Dropping tracks that aren't supported in the output format - Dropping tracks that aren't supported in the output format
You should consider inspecting the [discarded tracks](#discarded-tracks) before executing a `Conversion`. You should consider inspecting `isValid` and the [discarded tracks](#discarded-tracks) before executing a `Conversion`.
### Monitoring progress ### Monitoring progress
@@ -114,6 +123,7 @@ type ConversionVideoOptions = {
frameRate?: number; frameRate?: number;
codec?: VideoCodec; codec?: VideoCodec;
bitrate?: number | Quality; bitrate?: number | Quality;
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
forceTranscode?: boolean; forceTranscode?: boolean;
}; };
``` ```
@@ -352,6 +362,8 @@ type DiscardedTrack = {
Since you can inspect this list before executing a `Conversion`, this gives you the option to decide if you still want to move forward with the conversion process. Since you can inspect this list before executing a `Conversion`, this gives you the option to decide if you still want to move forward with the conversion process.
If `isValid` is `false`, then the discarded tracks caused the `Conversion` to become invalid. For example, this can happen when a format requires a specific codec but that codec cannot be encoded.
--- ---
The following reasons exist: The following reasons exist:
+9 -2
View File
@@ -419,7 +419,7 @@ const context = canvas.getContext('2d', { alpha: true })!;
const source = new CanvasSource(canvas, { const source = new CanvasSource(canvas, {
codec: 'vp9', codec: 'vp9',
quality: QUALITY_MEDIUM, bitrate: QUALITY_MEDIUM,
alpha: 'keep', // => Also encode alpha data alpha: 'keep', // => Also encode alpha data
}); });
output.addVideoTrack(source); output.addVideoTrack(source);
@@ -484,7 +484,14 @@ const output = new Output({
}); });
const conversion = await Conversion.init({ input, output }); const conversion = await Conversion.init({ input, output });
conversion.discardedTracks; // List of tracks that won't make it into the output if (!conversion.isValid) {
// The conversion isn't possible and would error upon execution.
// Check `discardedTracks` for the reasons.
return;
}
// List of tracks that won't make it into the output:
conversion.discardedTracks;
conversion.onProgress = (progress) => { conversion.onProgress = (progress) => {
progress; // Number between 0 and 1, inclusive progress; // Number between 0 and 1, inclusive
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"version": "1.20.0", "version": "1.21.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mediabunny", "name": "mediabunny",
"version": "1.20.0", "version": "1.21.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@@ -7749,9 +7749,9 @@
} }
}, },
"node_modules/mediabunny": { "node_modules/mediabunny": {
"version": "1.19.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.19.1.tgz", "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.20.1.tgz",
"integrity": "sha512-nyaxskRhj7w634RZ/D0+xbc//nclDVBM1eG9hrrPL/lEG4PW1pBSRHsgPRXQX/PvUXNfJTYQsTl0SYFCrch9PA==", "integrity": "sha512-p5vwGxXFQ+c0fvLph/VlHpntnOw6jGeWXDDNhtSHuVOOt6A346BhX8qLOuN05uWSav5x7PpIM1pjhtSIGC/fyw==",
"license": "MPL-2.0", "license": "MPL-2.0",
"peer": true, "peer": true,
"workspaces": [ "workspaces": [
@@ -12242,7 +12242,7 @@
}, },
"packages/mp3-encoder": { "packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"version": "1.20.0", "version": "1.21.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"devDependencies": { "devDependencies": {
"@types/emscripten": "^1.40.1" "@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.20.0", "version": "1.21.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.20.0", "version": "1.21.0",
"description": "MP3 encoder extension for Mediabunny, based on LAME.", "description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+128 -9
View File
@@ -96,6 +96,13 @@ export type ConversionOptions = {
* If no function is set, the input's metadata tags will be copied to the output. * If no function is set, the input's metadata tags will be copied to the output.
*/ */
tags?: MetadataTags | ((inputTags: MetadataTags) => MaybePromise<MetadataTags>); tags?: MetadataTags | ((inputTags: MetadataTags) => MaybePromise<MetadataTags>);
/**
* Whether to show potential console warnings about discarded tracks after calling `Conversion.init()`, defaults to
* `true`. Set this to `false` if you're properly handling the `discardedTracks` and `isValid` fields already and
* want to keep the console output clean.
*/
showWarnings?: boolean;
}; };
/** /**
@@ -153,6 +160,12 @@ export type ConversionVideoOptions = {
codec?: VideoCodec; codec?: VideoCodec;
/** The desired bitrate of the output video. */ /** The desired bitrate of the output video. */
bitrate?: number | Quality; 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. */ /** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean; forceTranscode?: boolean;
}; };
@@ -212,7 +225,7 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
throw new TypeError('options.video.height, when provided, must be a positive integer.'); throw new TypeError('options.video.height, when provided, must be a positive integer.');
} }
if (videoOptions?.fit !== undefined && !['fill', 'contain', 'cover'].includes(videoOptions.fit)) { 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 ( if (
videoOptions?.width !== undefined videoOptions?.width !== undefined
@@ -236,6 +249,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
) { ) {
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.'); 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) => { const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
@@ -371,6 +387,11 @@ export class Conversion {
/** @internal */ /** @internal */
_lastProgress = 0; _lastProgress = 0;
/**
* Whether this conversion, as it has been configured, is valid and can be executed. If this field is `false`, check
* the `discardedTracks` field for reasons.
*/
isValid = false;
/** The list of tracks that are included in the output file. */ /** The list of tracks that are included in the output file. */
readonly utilizedTracks: InputTrack[] = []; readonly utilizedTracks: InputTrack[] = [];
/** The list of tracks from the input file that have been discarded, alongside the discard reason. */ /** The list of tracks from the input file that have been discarded, alongside the discard reason. */
@@ -440,6 +461,9 @@ export class Conversion {
if (typeof options.tags === 'object') { if (typeof options.tags === 'object') {
validateMetadataTags(options.tags); validateMetadataTags(options.tags);
} }
if (options.showWarnings !== undefined && typeof options.showWarnings !== 'boolean') {
throw new TypeError('options.showWarnings, when provided, must be a boolean.');
}
this._options = options; this._options = options;
this.input = options.input; this.input = options.input;
@@ -518,12 +542,6 @@ export class Conversion {
} }
} }
const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user');
if (unintentionallyDiscardedTracks.length > 0) {
// Let's give the user a notice/warning about discarded tracks so they aren't confused
console.warn('Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks);
}
// Now, let's deal with metadata tags // Now, let's deal with metadata tags
const inputTags = await this.input.getMetadataTags(); const inputTags = await this.input.getMetadataTags();
@@ -551,14 +569,105 @@ export class Conversion {
} }
this.output.setMetadataTags(outputTags); this.output.setMetadataTags(outputTags);
// Let's check if the conversion can actually be executed
this.isValid = this._totalTrackCount >= outputTrackCounts.total.min
&& this._addedCounts.video >= outputTrackCounts.video.min
&& this._addedCounts.audio >= outputTrackCounts.audio.min
&& this._addedCounts.subtitle >= outputTrackCounts.subtitle.min;
if (this._options.showWarnings ?? true) {
const warnElements: unknown[] = [];
const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user');
if (unintentionallyDiscardedTracks.length > 0) {
// Let's give the user a notice/warning about discarded tracks so they aren't confused
warnElements.push(
'Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks,
);
}
if (!this.isValid) {
warnElements.push('\n\n' + this._getInvalidityExplanation().join(''));
}
if (warnElements.length > 0) {
console.warn(...warnElements);
}
}
} }
/** Executes the conversion process. Resolves once conversion is complete. */ /** @internal */
_getInvalidityExplanation() {
const elements: string[] = [];
if (this.discardedTracks.length === 0) {
elements.push(
'Due to missing tracks, this conversion cannot be executed.',
);
} else {
const encodabilityIsTheProblem = this.discardedTracks.every(x =>
x.reason === 'discarded_by_user' || x.reason === 'no_encodable_target_codec',
);
elements.push(
'Due to discarded tracks, this conversion cannot be executed.',
);
if (encodabilityIsTheProblem) {
const codecs = this.discardedTracks.flatMap((x) => {
if (x.reason === 'discarded_by_user') return [];
if (x.track.type === 'video') {
return this.output.format.getSupportedVideoCodecs();
} else if (x.track.type === 'audio') {
return this.output.format.getSupportedAudioCodecs();
} else {
return this.output.format.getSupportedSubtitleCodecs();
}
});
if (codecs.length === 1) {
elements.push(
`\nTracks were discarded because your environment is not able to encode '${codecs[0]}'.`,
);
} else {
elements.push(
'\nTracks were discarded because your environment is not able to encode any of the following'
+ ` codecs: ${codecs.map(x => `'${x}'`).join(', ')}.`,
);
}
if (codecs.includes('mp3')) {
elements.push(
`\nThe @mediabunny/mp3-encoder extension package provides support for encoding MP3.`,
);
}
} else {
elements.push('\nCheck the discardedTracks field for more info.');
}
}
return elements;
}
/**
* Executes the conversion process. Resolves once conversion is complete.
*
* Will throw if `isValid` is `false`.
*/
async execute() { async execute() {
if (!this.isValid) {
throw new Error(
'Cannot execute this conversion because its output configuration is invalid. Make sure to always check'
+ ' the isValid field before executing a conversion.\n'
+ this._getInvalidityExplanation().join(''),
);
}
if (this._executed) { if (this._executed) {
throw new Error('Conversion cannot be executed twice.'); throw new Error('Conversion cannot be executed twice.');
} }
this._executed = true; this._executed = true;
if (this.onProgress) { if (this.onProgress) {
@@ -672,6 +781,8 @@ export class Conversion {
|| (totalRotation !== 0 && !outputSupportsRotation) || (totalRotation !== 0 && !outputSupportsRotation)
|| !!crop; || !!crop;
const alpha = trackOptions.alpha ?? 'discard';
let videoCodecs = this.output.format.getSupportedVideoCodecs(); let videoCodecs = this.output.format.getSupportedVideoCodecs();
if ( if (
!needsTranscode !needsTranscode
@@ -704,6 +815,12 @@ export class Conversion {
return; 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); await source.add(packet, meta);
this._reportProgress(track.id, packet.timestamp + packet.duration); this._reportProgress(track.id, packet.timestamp + packet.duration);
} }
@@ -742,6 +859,7 @@ export class Conversion {
codec: encodableCodec, codec: encodableCodec,
bitrate, bitrate,
sizeChangeBehavior: trackOptions.fit ?? 'passThrough', sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
alpha,
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
}; };
@@ -796,6 +914,7 @@ export class Conversion {
rotation: totalRotation, // Bake the rotation into the output rotation: totalRotation, // Bake the rotation into the output
crop: trackOptions.crop, crop: trackOptions.crop,
poolSize: 1, poolSize: 1,
alpha: alpha === 'keep',
}); });
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp); const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
const frameRate = trackOptions.frameRate; const frameRate = trackOptions.frameRate;
+3 -14
View File
@@ -90,19 +90,6 @@ import {
} from '../reader'; } from '../reader';
import { MetadataTags, RichImageData } from '../tags'; import { MetadataTags, RichImageData } from '../tags';
// https://exiftool.org/TagNames/QuickTime.html
const UDTA_STRING_KEYS = new Set([
'@day', '@mak', '@mod', '@swr', '@xyz', 'CAME', 'CNCV', 'CNFV', 'CNMN', 'FIRM', 'FOV\0', 'GoPr', 'LENS', 'PXMN',
'SIGM', 'SNum', 'TAGS', 'albm', 'albr', 'angl', 'auth', 'ccid', 'cdis', 'clfn', 'clid', 'clsf', 'cmid', 'cmnm',
'coll', 'cprt', 'cver', 'cvru', 'date', 'dscp', 'fsid', 'gnre', 'hinv', 'icnu', 'info', 'infu', 'kgtt', 'loci',
'lrcu', 'mcvr', 'name', 'perf', 'pmcc', 'reel', 'rtng', 'scen', 'shot', 'slno', 'thmb', 'titl', 'tnam', 'urat',
'uuid', 'vndr', 'yrrc', '©ART', '©TIM', '©TSC', '©TSZ', '©alb', '©arg', '©ark', '©cmt', '©cok', '©com', '©cpy',
'©day', '©dir', '©ed1', '©ed2', '©ed3', '©ed4', '©ed5', '©ed6', '©ed7', '©ed8', '©ed9', '©enc', '©fmt', '©fpt',
'©frl', '©fyw', '©gen', '©gpt', '©grl', '©grp', '©gyw', '©inf', '©isr', '©lab', '©lal', '©lyr', '©mak', '©mal',
'©mdl', '©mod', '©nam', '©pdk', '©phg', '©prd', '©prf', '©prk', '©prl', '©req', '©snk', '©snm', '©src', '©swf',
'©swk', '©swr', '©too', '©trk', '©wrt', '©xsp', '©xyz', '©ysp', '©zsp',
]);
type InternalTrack = { type InternalTrack = {
id: number; id: number;
demuxer: IsobmffDemuxer; demuxer: IsobmffDemuxer;
@@ -2065,7 +2052,9 @@ export class IsobmffDemuxer extends Demuxer {
const startPos = slice.filePos; const startPos = slice.filePos;
this.metadataTags.raw ??= {}; this.metadataTags.raw ??= {};
if (UDTA_STRING_KEYS.has(boxInfo.name)) { if (boxInfo.name[0] === '©') {
// https://mp4workshop.com/about
// Box name starting with © indicates "international text"
this.metadataTags.raw[boxInfo.name] ??= readMetadataStringShort(slice); this.metadataTags.raw[boxInfo.name] ??= readMetadataStringShort(slice);
} else { } else {
this.metadataTags.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize); this.metadataTags.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize);
+3 -1
View File
@@ -57,8 +57,10 @@ export const readIsomVariableInteger = (slice: FileSlice) => {
}; };
export const readMetadataStringShort = (slice: FileSlice) => { export const readMetadataStringShort = (slice: FileSlice) => {
const stringLength = readU16Be(slice); let stringLength = readU16Be(slice);
slice.skip(2); // Language slice.skip(2); // Language
stringLength = Math.min(stringLength, slice.remainingLength);
return textDecoder.decode(readBytes(slice, stringLength)); return textDecoder.decode(readBytes(slice, stringLength));
}; };
+7 -1
View File
@@ -129,6 +129,11 @@ export class FileSlice {
this.bufferPos = value - this.offset; this.bufferPos = value - this.offset;
} }
/** The number of bytes left from the current pos to the end of the slice. */
get remainingLength() {
return Math.max(this.end - this.filePos, 0);
}
skip(byteCount: number) { skip(byteCount: number) {
this.bufferPos += byteCount; this.bufferPos += byteCount;
} }
@@ -153,7 +158,8 @@ const checkIsInRange = (slice: FileSlice, bytesToRead: number) => {
if (slice.filePos < slice.start || slice.filePos + bytesToRead > slice.end) { if (slice.filePos < slice.start || slice.filePos + bytesToRead > slice.end) {
throw new RangeError( throw new RangeError(
`Tried reading [${slice.filePos}, ${slice.filePos + bytesToRead}), but slice is` `Tried reading [${slice.filePos}, ${slice.filePos + bytesToRead}), but slice is`
+ ` [${slice.start}, ${slice.end}).`, + ` [${slice.start}, ${slice.end}). This is likely an internal error, please report it alongside the file`
+ ` that caused it.`,
); );
} }
}; };
+2 -2
View File
@@ -620,8 +620,8 @@ export class VideoSample implements Disposable {
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion( const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
options.crop?.left ?? 0, options.crop?.left ?? 0,
options.crop?.top ?? 0, options.crop?.top ?? 0,
options.crop?.width ?? this.codedWidth, options.crop?.width ?? rotatedWidth,
options.crop?.height ?? this.codedHeight, options.crop?.height ?? rotatedHeight,
rotation, rotation,
); );
+102
View File
@@ -9,6 +9,7 @@ import { BufferTarget } from '../../src/target.js';
import { CanvasSource, VideoSampleSource } from '../../src/media-source.js'; import { CanvasSource, VideoSampleSource } from '../../src/media-source.js';
import { canEncodeVideo, QUALITY_HIGH } from '../../src/encode.js'; import { canEncodeVideo, QUALITY_HIGH } from '../../src/encode.js';
import { VideoSample } from '../../src/sample.js'; import { VideoSample } from '../../src/sample.js';
import { Conversion } from '../../src/conversion.js';
test('Can decode transparent video', async () => { test('Can decode transparent video', async () => {
using input = new Input({ using input = new Input({
@@ -269,3 +270,104 @@ test('Positive encodability check with alpha', async () => {
const result = await canEncodeVideo('vp9', { alpha: 'keep' }); const result = await canEncodeVideo('vp9', { alpha: 'keep' });
expect(result).toBe(true); 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);
});
+57 -5
View File
@@ -147,6 +147,7 @@ test('Read and write metadata, QuickTime', async () => {
...songMetadata, ...songMetadata,
raw: { raw: {
'©sic': 'ko mode', '©sic': 'ko mode',
'cust': new Uint8Array([1, 2, 3, 4]),
}, },
}); });
@@ -179,11 +180,62 @@ test('Read and write metadata, QuickTime', async () => {
expect(readTags.raw!['©nam']).toBe(songMetadata.title); expect(readTags.raw!['©nam']).toBe(songMetadata.title);
// We don't know what the data type is, so the demuxer just returns Uint8Array // We don't know what the data type is, so the demuxer just returns Uint8Array
expect(readTags.raw!['©sic']).toEqual(new Uint8Array([ expect(readTags.raw!['©sic']).toBe('ko mode'); // Parsed as string because it begins with ©
0, 7, // String length 7 expect(readTags.raw!['cust']).toEqual(new Uint8Array([1, 2, 3, 4]));
85, 196, // Language code for 'und' });
107, 111, 32, 109, 111, 100, 101, // 'ko mode'
])); test('Read and write metadata, MP4 with mdta format', async () => {
const output = new Output({
format: new Mp4OutputFormat({
metadataFormat: 'mdta',
}),
target: new BufferTarget(),
});
output.setMetadataTags({
...songMetadata,
raw: {
'AIGC': 'yes',
'this key is': 'crazy long',
'bytes': new Uint8Array([2, 3, 4, 5]),
},
});
const dummyTrack = createDummyAudioTrack('aac', output);
await output.start();
await dummyTrack.addPacket();
await output.finalize();
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const readTags = await input.getMetadataTags();
expect(readTags.title).toBe(songMetadata.title);
expect(readTags.description).toBe(songMetadata.description);
expect(readTags.artist).toBe(songMetadata.artist);
expect(readTags.album).toBe(songMetadata.album);
expect(readTags.albumArtist).toBe(songMetadata.albumArtist);
expect(readTags.comment).toBe(songMetadata.comment);
expect(readTags.lyrics).toBe(songMetadata.lyrics);
expect(readTags.trackNumber).toBe(songMetadata.trackNumber);
expect(readTags.tracksTotal).toBe(songMetadata.tracksTotal);
expect(readTags.discNumber).toBeUndefined(); // Not written
expect(readTags.discsTotal).toBeUndefined();
expect(readTags.date).toEqual(readTags.date);
expect(readTags.images).toHaveLength(1);
expect(readTags.images![0]!.data).toEqual(coverArt);
expect(readTags.images![0]!.mimeType).toEqual('image/jpeg');
expect(readTags.images![0]!.kind).toEqual('coverFront');
expect(readTags.images![0]!.description).toBeUndefined(); // Lost in MP4
expect(readTags.raw!['title']).toBe(songMetadata.title);
expect(readTags.raw!['AIGC']).toBe('yes');
expect(readTags.raw!['this key is']).toBe('crazy long');
expect(readTags.raw!['bytes']).toEqual(new Uint8Array([2, 3, 4, 5]));
}); });
test('Read MOV metadata tags, ilst with keys', async () => { test('Read MOV metadata tags, ilst with keys', async () => {