mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 16:53:47 +02:00
Add metadata tag support to Conversion API
This commit is contained in:
@@ -283,6 +283,44 @@ In this case, the output will be 15 seconds long.
|
|||||||
|
|
||||||
If only `start` is set, the clip will run until the end of the input file. If only `end` is set, the clip will start at the beginning of the input file.
|
If only `start` is set, the clip will run until the end of the input file. If only `end` is set, the clip will start at the beginning of the input file.
|
||||||
|
|
||||||
|
## Metadata tags
|
||||||
|
|
||||||
|
By default, any [descriptive metadata tags](../api/MetadataTags.md) of the input will be copied to the output. If you want to further control the metadata tags written to the output, you can use the `tags` options:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Set your own metadata:
|
||||||
|
const conversion = await Conversion.init({
|
||||||
|
// ...
|
||||||
|
tags: () => ({
|
||||||
|
title: 're:Turning',
|
||||||
|
artist: 'Alexander Panos',
|
||||||
|
}),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or, augment the input's metadata:
|
||||||
|
const conversion = await Conversion.init({
|
||||||
|
// ...
|
||||||
|
tags: inputTags => ({
|
||||||
|
...inputTags, // Keep the existing metadata
|
||||||
|
images: [{ // And add cover art
|
||||||
|
data: new Uint8Array(...),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
kind: 'coverFront',
|
||||||
|
}],
|
||||||
|
comment: undefined, // And remove any comments
|
||||||
|
}),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or, remove all metadata
|
||||||
|
const conversion = await Conversion.init({
|
||||||
|
// ...
|
||||||
|
tags: () => ({}),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Discarded tracks
|
## Discarded tracks
|
||||||
|
|
||||||
If an input track is excluded from the output file, it is considered *discarded*. The list of discarded tracks can be accessed after initializing a `Conversion`:
|
If an input track is excluded from the output file, it is considered *discarded*. The list of discarded tracks can be accessed after initializing a `Conversion`:
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
} from './misc';
|
} from './misc';
|
||||||
import { Output, TrackType } from './output';
|
import { Output, TrackType } from './output';
|
||||||
import { AudioSample, VideoSample } from './sample';
|
import { AudioSample, VideoSample } from './sample';
|
||||||
|
import { MetadataTags, validateMetadataTags } from './tags';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The options for media file conversion.
|
* The options for media file conversion.
|
||||||
@@ -84,6 +85,15 @@ export type ConversionOptions = {
|
|||||||
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */
|
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */
|
||||||
end: number;
|
end: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A callback that returns or resolves to the descriptive metadata tags that should be written to the output file.
|
||||||
|
* As input, this function will be passed the tags of the input file, allowing you to modify, augment or extend
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* If no function is set, the input's metadata tags will be copied to the output.
|
||||||
|
*/
|
||||||
|
tags?: (inputTags: MetadataTags) => MaybePromise<MetadataTags>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,6 +407,9 @@ export class Conversion {
|
|||||||
&& options.trim.start >= options.trim.end) {
|
&& options.trim.start >= options.trim.end) {
|
||||||
throw new TypeError('options.trim.start must be less than options.trim.end.');
|
throw new TypeError('options.trim.start must be less than options.trim.end.');
|
||||||
}
|
}
|
||||||
|
if (options.tags !== undefined && typeof options.tags !== 'function') {
|
||||||
|
throw new TypeError('options.tags, when provided, must be a function.');
|
||||||
|
}
|
||||||
|
|
||||||
this._options = options;
|
this._options = options;
|
||||||
this.input = options.input;
|
this.input = options.input;
|
||||||
@@ -480,6 +493,32 @@ export class Conversion {
|
|||||||
// Let's give the user a notice/warning about discarded tracks so they aren't confused
|
// 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);
|
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();
|
||||||
|
let outputTags: MetadataTags;
|
||||||
|
|
||||||
|
if (this._options.tags) {
|
||||||
|
const result = await this._options.tags(inputTags);
|
||||||
|
validateMetadataTags(result);
|
||||||
|
|
||||||
|
outputTags = result;
|
||||||
|
} else {
|
||||||
|
outputTags = inputTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somewhat dirty but pragmatic
|
||||||
|
const inputAndOutputFormatMatch = (await this.input.getFormat()).mimeType === this.output.format.mimeType;
|
||||||
|
const rawTagsAreUnchanged = inputTags.raw === outputTags.raw;
|
||||||
|
|
||||||
|
if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) {
|
||||||
|
// If the input and output formats aren't the same, copying over raw metadata tags makes no sense and only
|
||||||
|
// results in junk tags, so let's cut them out.
|
||||||
|
delete outputTags.raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.output.setMetadataTags(outputTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Executes the conversion process. Resolves once conversion is complete. */
|
/** Executes the conversion process. Resolves once conversion is complete. */
|
||||||
|
|||||||
@@ -252,7 +252,12 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
// Load metadata tags from each segment lazily (only once)
|
// Load metadata tags from each segment lazily (only once)
|
||||||
for (const segment of this.segments) {
|
for (const segment of this.segments) {
|
||||||
if (!segment.metadataTagsCollected) {
|
if (!segment.metadataTagsCollected) {
|
||||||
|
if (this.reader.fileSize !== null) {
|
||||||
await this.loadSegmentMetadata(segment);
|
await this.loadSegmentMetadata(segment);
|
||||||
|
} else {
|
||||||
|
// The seeking would be too crazy, let's not
|
||||||
|
}
|
||||||
|
|
||||||
segment.metadataTagsCollected = true;
|
segment.metadataTagsCollected = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ export class Output<
|
|||||||
/**
|
/**
|
||||||
* Sets descriptive metadata tags about the media file, such as title, author, date, or cover art. When called
|
* Sets descriptive metadata tags about the media file, such as title, author, date, or cover art. When called
|
||||||
* multiple times, only the metadata from the last call will be used.
|
* multiple times, only the metadata from the last call will be used.
|
||||||
|
*
|
||||||
|
* Must be called before output is started.
|
||||||
*/
|
*/
|
||||||
setMetadataTags(tags: MetadataTags) {
|
setMetadataTags(tags: MetadataTags) {
|
||||||
validateMetadataTags(tags);
|
validateMetadataTags(tags);
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export abstract class Source {
|
|||||||
abstract _retrieveSize(): MaybePromise<number | null>;
|
abstract _retrieveSize(): MaybePromise<number | null>;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
abstract _read(start: number, end: number): MaybePromise<ReadResult | null>;
|
abstract _read(start: number, end: number): MaybePromise<ReadResult | null>;
|
||||||
|
/** @internal */
|
||||||
|
abstract get _supportsRandomAccess(): boolean;
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
private _sizePromise: Promise<number | null> | null = null;
|
private _sizePromise: Promise<number | null> | null = null;
|
||||||
@@ -113,6 +115,11 @@ export class BufferSource extends Source {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
get _supportsRandomAccess() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,6 +217,11 @@ export class BlobSource extends Source {
|
|||||||
|
|
||||||
worker.running = false;
|
worker.running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
get _supportsRandomAccess() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
|
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
|
||||||
@@ -488,6 +500,11 @@ export class UrlSource extends Source {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
get _supportsRandomAccess() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -559,6 +576,11 @@ export class FilePathSource extends Source {
|
|||||||
_retrieveSize(): MaybePromise<number> {
|
_retrieveSize(): MaybePromise<number> {
|
||||||
return this._streamSource._retrieveSize();
|
return this._streamSource._retrieveSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
get _supportsRandomAccess() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -728,6 +750,11 @@ export class StreamSource extends Source {
|
|||||||
|
|
||||||
worker.running = false;
|
worker.running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
get _supportsRandomAccess() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadableStreamSourcePendingSlice = {
|
type ReadableStreamSourcePendingSlice = {
|
||||||
@@ -987,6 +1014,11 @@ export class ReadableStreamSource extends Source {
|
|||||||
|
|
||||||
this._pulling = false;
|
this._pulling = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
get _supportsRandomAccess() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrefetchProfile = (start: number, end: number, workers: ReadWorker[]) => {
|
type PrefetchProfile = (start: number, end: number, workers: ReadWorker[]) => {
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
* Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to directly read or
|
* Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to directly read or
|
||||||
* write the underlying metadata tags (which differ by format).
|
* write the underlying metadata tags (which differ by format).
|
||||||
*
|
*
|
||||||
|
* - For MP4/QuickTime files, the metadata refers to the data in `'moov'`-level `'udta'` and `'meta'` atoms.
|
||||||
|
* - For Matroska files, the metadata refers to the Tags and Attachments elements whose target is 50 (MOVIE).
|
||||||
|
* - For MP3 files, the metadata refers to the ID3v2 or ID3v1 tags.
|
||||||
|
* - For Ogg files, there is no global metadata so instead, the metadata refers to the combined metadata of all tracks,
|
||||||
|
* in Vorbis-style comment headers.
|
||||||
|
* - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk.
|
||||||
|
* - For ADTS files, there is no metadata.
|
||||||
|
*
|
||||||
* @group Metadata tags
|
* @group Metadata tags
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
|
|||||||
+105
-12
@@ -17,6 +17,7 @@ import { ALL_FORMATS } from '../../src/input-format.js';
|
|||||||
import { MetadataTags } from '../../src/tags.js';
|
import { MetadataTags } from '../../src/tags.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { AudioCodec, buildAudioCodecString } from '../../src/codec.js';
|
import { AudioCodec, buildAudioCodecString } from '../../src/codec.js';
|
||||||
|
import { Conversion } from '../../src/conversion.js';
|
||||||
|
|
||||||
const __dirname = new URL('.', import.meta.url).pathname;
|
const __dirname = new URL('.', import.meta.url).pathname;
|
||||||
|
|
||||||
@@ -33,24 +34,18 @@ const createDummyAudioTrack = (codec: AudioCodec, output: Output) => {
|
|||||||
data[2] = 224;
|
data[2] = 224;
|
||||||
data[3] = 100;
|
data[3] = 100;
|
||||||
|
|
||||||
// OpusHead description
|
// Opus description
|
||||||
const description = new Uint8Array(64);
|
const description = new Uint8Array([
|
||||||
description[0] = 0x4f;
|
79, 112, 117, 115, 72, 101, 97, 100, 1, 2, 56, 1, 68, 172, 0, 0, 0, 0, 0,
|
||||||
description[1] = 0x70;
|
]);
|
||||||
description[2] = 0x75;
|
|
||||||
description[3] = 0x73;
|
|
||||||
description[4] = 0x48;
|
|
||||||
description[5] = 0x65;
|
|
||||||
description[6] = 0x61;
|
|
||||||
description[7] = 0x64;
|
|
||||||
|
|
||||||
await source.add(
|
await source.add(
|
||||||
new EncodedPacket(data, 'key', 0, 1),
|
new EncodedPacket(data, 'key', 0, 1),
|
||||||
{
|
{
|
||||||
decoderConfig: {
|
decoderConfig: {
|
||||||
codec: buildAudioCodecString(codec, 2, 48000),
|
codec: buildAudioCodecString(codec, 2, 44100),
|
||||||
numberOfChannels: 2,
|
numberOfChannels: 2,
|
||||||
sampleRate: 48000,
|
sampleRate: 44100,
|
||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -388,3 +383,101 @@ test('Read and write metadata, WAVE', async () => {
|
|||||||
expect(readTags.raw!['INAM']).toBe(songMetadata.title);
|
expect(readTags.raw!['INAM']).toBe(songMetadata.title);
|
||||||
expect(readTags.raw!['IKEK']).toBe('RIFF INFO lowkey mid');
|
expect(readTags.raw!['IKEK']).toBe('RIFF INFO lowkey mid');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Conversion metadata tags, default case', async () => {
|
||||||
|
const output = new Output({
|
||||||
|
format: new Mp4OutputFormat(),
|
||||||
|
target: new BufferTarget(),
|
||||||
|
});
|
||||||
|
|
||||||
|
output.setMetadataTags(songMetadata);
|
||||||
|
|
||||||
|
const dummyTrack = createDummyAudioTrack('opus', output);
|
||||||
|
|
||||||
|
await output.start();
|
||||||
|
await dummyTrack.addPacket();
|
||||||
|
await output.finalize();
|
||||||
|
|
||||||
|
const input = new Input({
|
||||||
|
source: new BufferSource(output.target.buffer!),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output2 = new Output({
|
||||||
|
format: new MkvOutputFormat(),
|
||||||
|
target: new BufferTarget(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversion = await Conversion.init({ input, output: output2 });
|
||||||
|
await conversion.execute();
|
||||||
|
|
||||||
|
const input2 = new Input({
|
||||||
|
source: new BufferSource(output2.target.buffer!),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const readTags = await input2.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).toBe(songMetadata.discNumber);
|
||||||
|
expect(readTags.discsTotal).toBe(songMetadata.discsTotal);
|
||||||
|
expect(readTags.date).toEqual(readTags.date);
|
||||||
|
expect(readTags.images).toHaveLength(1);
|
||||||
|
expect(readTags.images![0]!.data).toEqual(coverArt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Conversion metadata tags, modified', async () => {
|
||||||
|
const output = new Output({
|
||||||
|
format: new Mp4OutputFormat(),
|
||||||
|
target: new BufferTarget(),
|
||||||
|
});
|
||||||
|
|
||||||
|
output.setMetadataTags(songMetadata);
|
||||||
|
|
||||||
|
const dummyTrack = createDummyAudioTrack('opus', output);
|
||||||
|
|
||||||
|
await output.start();
|
||||||
|
await dummyTrack.addPacket();
|
||||||
|
await output.finalize();
|
||||||
|
|
||||||
|
const input = new Input({
|
||||||
|
source: new BufferSource(output.target.buffer!),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output2 = new Output({
|
||||||
|
format: new MkvOutputFormat(),
|
||||||
|
target: new BufferTarget(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversion = await Conversion.init({
|
||||||
|
input,
|
||||||
|
output: output2,
|
||||||
|
tags: inputTags => ({
|
||||||
|
title: 'Blossom',
|
||||||
|
artist: inputTags.artist,
|
||||||
|
raw: inputTags.raw, // This should NOT be copied
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await conversion.execute();
|
||||||
|
|
||||||
|
const input2 = new Input({
|
||||||
|
source: new BufferSource(output2.target.buffer!),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const readTags = await input2.getMetadataTags();
|
||||||
|
|
||||||
|
expect(Object.keys(readTags).length).toBe(3);
|
||||||
|
expect(readTags.title).toBe('Blossom');
|
||||||
|
expect(readTags.artist).toBe(songMetadata.artist);
|
||||||
|
expect(Object.keys(readTags.raw!).length).toBe(2);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user