mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +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.
|
||||
|
||||
## 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`:
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user