diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index c96e629..03fc298 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -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`: diff --git a/src/conversion.ts b/src/conversion.ts index c3c1a0a..3351d34 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -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; }; /** @@ -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. */ diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index d1a22df..66a0455 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -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; } } diff --git a/src/output.ts b/src/output.ts index 9e47e86..5ce5e48 100644 --- a/src/output.ts +++ b/src/output.ts @@ -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); diff --git a/src/source.ts b/src/source.ts index ae24aaf..54aedae 100644 --- a/src/source.ts +++ b/src/source.ts @@ -36,6 +36,8 @@ export abstract class Source { abstract _retrieveSize(): MaybePromise; /** @internal */ abstract _read(start: number, end: number): MaybePromise; + /** @internal */ + abstract get _supportsRandomAccess(): boolean; /** @internal */ private _sizePromise: Promise | 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 { 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[]) => { diff --git a/src/tags.ts b/src/tags.ts index d583027..825d18a 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -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 */ diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index 214322b..dcf94a4 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -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); +});