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 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 -1
View File
@@ -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:
+9 -2
View File
@@ -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
+6 -6
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
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.
*/
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;
+3 -14
View File
@@ -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);
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
+102
View File
@@ -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);
});
+57 -5
View File
@@ -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 () => {