Add metadata tag support to Conversion API

This commit is contained in:
Vanilagy
2025-09-05 23:34:24 +02:00
parent c6cb8f7b43
commit dcd99f8930
7 changed files with 230 additions and 13 deletions
+38
View File
@@ -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.
## 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
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`:
+39
View File
@@ -47,6 +47,7 @@ import {
} from './misc';
import { Output, TrackType } from './output';
import { AudioSample, VideoSample } from './sample';
import { MetadataTags, validateMetadataTags } from './tags';
/**
* 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`. */
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) {
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.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
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. */
+6 -1
View File
@@ -252,7 +252,12 @@ export class MatroskaDemuxer extends Demuxer {
// Load metadata tags from each segment lazily (only once)
for (const segment of this.segments) {
if (!segment.metadataTagsCollected) {
await this.loadSegmentMetadata(segment);
if (this.reader.fileSize !== null) {
await this.loadSegmentMetadata(segment);
} else {
// The seeking would be too crazy, let's not
}
segment.metadataTagsCollected = true;
}
}
+2
View File
@@ -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
* multiple times, only the metadata from the last call will be used.
*
* Must be called before output is started.
*/
setMetadataTags(tags: MetadataTags) {
validateMetadataTags(tags);
+32
View File
@@ -36,6 +36,8 @@ export abstract class Source {
abstract _retrieveSize(): MaybePromise<number | null>;
/** @internal */
abstract _read(start: number, end: number): MaybePromise<ReadResult | null>;
/** @internal */
abstract get _supportsRandomAccess(): boolean;
/** @internal */
private _sizePromise: Promise<number | null> | null = null;
@@ -113,6 +115,11 @@ export class BufferSource extends Source {
offset: 0,
};
}
/** @internal */
get _supportsRandomAccess() {
return true;
}
}
/**
@@ -210,6 +217,11 @@ export class BlobSource extends Source {
worker.running = false;
}
/** @internal */
get _supportsRandomAccess() {
return true;
}
}
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> {
return this._streamSource._retrieveSize();
}
/** @internal */
get _supportsRandomAccess() {
return true;
}
}
/**
@@ -728,6 +750,11 @@ export class StreamSource extends Source {
worker.running = false;
}
/** @internal */
get _supportsRandomAccess() {
return true;
}
}
type ReadableStreamSourcePendingSlice = {
@@ -987,6 +1014,11 @@ export class ReadableStreamSource extends Source {
this._pulling = false;
}
/** @internal */
get _supportsRandomAccess() {
return false;
}
}
type PrefetchProfile = (start: number, end: number, workers: ReadWorker[]) => {
+8
View File
@@ -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
* 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
* @public
*/
+105 -12
View File
@@ -17,6 +17,7 @@ import { ALL_FORMATS } from '../../src/input-format.js';
import { MetadataTags } from '../../src/tags.js';
import path from 'node:path';
import { AudioCodec, buildAudioCodecString } from '../../src/codec.js';
import { Conversion } from '../../src/conversion.js';
const __dirname = new URL('.', import.meta.url).pathname;
@@ -33,24 +34,18 @@ const createDummyAudioTrack = (codec: AudioCodec, output: Output) => {
data[2] = 224;
data[3] = 100;
// OpusHead description
const description = new Uint8Array(64);
description[0] = 0x4f;
description[1] = 0x70;
description[2] = 0x75;
description[3] = 0x73;
description[4] = 0x48;
description[5] = 0x65;
description[6] = 0x61;
description[7] = 0x64;
// Opus description
const description = new Uint8Array([
79, 112, 117, 115, 72, 101, 97, 100, 1, 2, 56, 1, 68, 172, 0, 0, 0, 0, 0,
]);
await source.add(
new EncodedPacket(data, 'key', 0, 1),
{
decoderConfig: {
codec: buildAudioCodecString(codec, 2, 48000),
codec: buildAudioCodecString(codec, 2, 44100),
numberOfChannels: 2,
sampleRate: 48000,
sampleRate: 44100,
description,
},
},
@@ -388,3 +383,101 @@ test('Read and write metadata, WAVE', async () => {
expect(readTags.raw!['INAM']).toBe(songMetadata.title);
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);
});