mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-07 15:53:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a750316a | ||
|
|
a1008b8088 | ||
|
|
be151d3494 | ||
|
|
1b8e4f9f4b | ||
|
|
d708df9a2c | ||
|
|
e7c7a82fe3 | ||
|
|
4819bf6755 | ||
|
|
f71ba8bc9e |
+8
-5
@@ -4,7 +4,7 @@
|
||||
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
|
||||
|
||||
<script type="module">
|
||||
MediabunnyMp3Encoder.registerMp3Encoder();
|
||||
//MediabunnyMp3Encoder.registerMp3Encoder();
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
@@ -24,7 +24,7 @@
|
||||
chunked: true,
|
||||
chunkSize: 2**20
|
||||
});
|
||||
const outputFormat = new Mediabunny.Mp3OutputFormat({});
|
||||
const outputFormat = new Mediabunny.Mp4OutputFormat({});
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Cancel';
|
||||
@@ -92,6 +92,9 @@
|
||||
},
|
||||
*/
|
||||
video: () => ({
|
||||
width: 300,
|
||||
//alpha: 'keep',
|
||||
//width: 320,
|
||||
//discard: true,
|
||||
//discard: true,
|
||||
//crop: {
|
||||
@@ -126,7 +129,7 @@
|
||||
//width: 200,
|
||||
//height: 100,
|
||||
}),
|
||||
tags: {
|
||||
tags: {} ?? {
|
||||
title: 'Bigggy',
|
||||
artist: 'Buck Bunny',
|
||||
images: [{
|
||||
@@ -144,8 +147,8 @@
|
||||
}
|
||||
},
|
||||
trim: {
|
||||
//start: 10,
|
||||
//end: 20
|
||||
start: 0,
|
||||
end: 10
|
||||
},
|
||||
});
|
||||
console.log(conversion);
|
||||
|
||||
@@ -13,6 +13,7 @@ It has the following features:
|
||||
- Video rotation
|
||||
- Video cropping
|
||||
- Video frame rate adjustment
|
||||
- Video transparency removal/preservation
|
||||
- Audio resampling
|
||||
- Audio up/downmixing
|
||||
|
||||
@@ -41,6 +42,14 @@ const output = new 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();
|
||||
|
||||
// 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
|
||||
- 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
|
||||
|
||||
@@ -114,6 +123,7 @@ type ConversionVideoOptions = {
|
||||
frameRate?: number;
|
||||
codec?: VideoCodec;
|
||||
bitrate?: number | Quality;
|
||||
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -419,7 +419,7 @@ const context = canvas.getContext('2d', { alpha: true })!;
|
||||
|
||||
const source = new CanvasSource(canvas, {
|
||||
codec: 'vp9',
|
||||
quality: QUALITY_MEDIUM,
|
||||
bitrate: QUALITY_MEDIUM,
|
||||
alpha: 'keep', // => Also encode alpha data
|
||||
});
|
||||
output.addVideoTrack(source);
|
||||
@@ -484,7 +484,14 @@ const output = new 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) => {
|
||||
progress; // Number between 0 and 1, inclusive
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -7749,9 +7749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.19.1.tgz",
|
||||
"integrity": "sha512-nyaxskRhj7w634RZ/D0+xbc//nclDVBM1eG9hrrPL/lEG4PW1pBSRHsgPRXQX/PvUXNfJTYQsTl0SYFCrch9PA==",
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.20.1.tgz",
|
||||
"integrity": "sha512-p5vwGxXFQ+c0fvLph/VlHpntnOw6jGeWXDDNhtSHuVOOt6A346BhX8qLOuN05uWSav5x7PpIM1pjhtSIGC/fyw==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -12242,7 +12242,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"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.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
+128
-9
@@ -96,6 +96,13 @@ export type ConversionOptions = {
|
||||
* If no function is set, the input's metadata tags will be copied to the output.
|
||||
*/
|
||||
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;
|
||||
/** 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 +225,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 +249,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) => {
|
||||
@@ -371,6 +387,11 @@ export class Conversion {
|
||||
/** @internal */
|
||||
_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. */
|
||||
readonly utilizedTracks: InputTrack[] = [];
|
||||
/** 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') {
|
||||
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.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
|
||||
|
||||
const inputTags = await this.input.getMetadataTags();
|
||||
@@ -551,14 +569,105 @@ export class Conversion {
|
||||
}
|
||||
|
||||
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() {
|
||||
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) {
|
||||
throw new Error('Conversion cannot be executed twice.');
|
||||
}
|
||||
|
||||
this._executed = true;
|
||||
|
||||
if (this.onProgress) {
|
||||
@@ -672,6 +781,8 @@ export class Conversion {
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation)
|
||||
|| !!crop;
|
||||
|
||||
const alpha = trackOptions.alpha ?? 'discard';
|
||||
|
||||
let videoCodecs = this.output.format.getSupportedVideoCodecs();
|
||||
if (
|
||||
!needsTranscode
|
||||
@@ -704,6 +815,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 +859,7 @@ export class Conversion {
|
||||
codec: encodableCodec,
|
||||
bitrate,
|
||||
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
||||
alpha,
|
||||
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
|
||||
crop: trackOptions.crop,
|
||||
poolSize: 1,
|
||||
alpha: alpha === 'keep',
|
||||
});
|
||||
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
||||
const frameRate = trackOptions.frameRate;
|
||||
|
||||
@@ -90,19 +90,6 @@ import {
|
||||
} from '../reader';
|
||||
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 = {
|
||||
id: number;
|
||||
demuxer: IsobmffDemuxer;
|
||||
@@ -2065,7 +2052,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const startPos = slice.filePos;
|
||||
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);
|
||||
} else {
|
||||
this.metadataTags.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize);
|
||||
|
||||
@@ -57,8 +57,10 @@ export const readIsomVariableInteger = (slice: FileSlice) => {
|
||||
};
|
||||
|
||||
export const readMetadataStringShort = (slice: FileSlice) => {
|
||||
const stringLength = readU16Be(slice);
|
||||
let stringLength = readU16Be(slice);
|
||||
slice.skip(2); // Language
|
||||
|
||||
stringLength = Math.min(stringLength, slice.remainingLength);
|
||||
return textDecoder.decode(readBytes(slice, stringLength));
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -129,6 +129,11 @@ export class FileSlice {
|
||||
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) {
|
||||
this.bufferPos += byteCount;
|
||||
}
|
||||
@@ -153,7 +158,8 @@ const checkIsInRange = (slice: FileSlice, bytesToRead: number) => {
|
||||
if (slice.filePos < slice.start || slice.filePos + bytesToRead > slice.end) {
|
||||
throw new RangeError(
|
||||
`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
@@ -620,8 +620,8 @@ export class VideoSample implements Disposable {
|
||||
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
|
||||
options.crop?.left ?? 0,
|
||||
options.crop?.top ?? 0,
|
||||
options.crop?.width ?? this.codedWidth,
|
||||
options.crop?.height ?? this.codedHeight,
|
||||
options.crop?.width ?? rotatedWidth,
|
||||
options.crop?.height ?? rotatedHeight,
|
||||
rotation,
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -147,6 +147,7 @@ test('Read and write metadata, QuickTime', async () => {
|
||||
...songMetadata,
|
||||
raw: {
|
||||
'©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);
|
||||
// We don't know what the data type is, so the demuxer just returns Uint8Array
|
||||
expect(readTags.raw!['©sic']).toEqual(new Uint8Array([
|
||||
0, 7, // String length 7
|
||||
85, 196, // Language code for 'und'
|
||||
107, 111, 32, 109, 111, 100, 101, // 'ko mode'
|
||||
]));
|
||||
expect(readTags.raw!['©sic']).toBe('ko mode'); // Parsed as string because it begins with ©
|
||||
expect(readTags.raw!['cust']).toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user