diff --git a/dev/convert.html b/dev/convert.html index 89cea90..4962384 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -24,7 +24,9 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.FlacOutputFormat({}); + const outputFormat = new Mediabunny.Mp4OutputFormat({ + metadataFormat: 'mdta', + }); const button = document.createElement('button'); button.textContent = 'Cancel'; @@ -92,7 +94,7 @@ }, */ video: () => ({ - discard: true, + //discard: true, //discard: true, //crop: { // left: 0, @@ -126,7 +128,7 @@ //width: 200, //height: 100, }), - tags: {} ?? { + tags: { title: 'Bigggy', artist: 'Buck Bunny', images: [{ diff --git a/dev/demux.html b/dev/demux.html index eff6137..ba6df08 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -14,6 +14,10 @@ source: new Mediabunny.BlobSource(file), }); + console.log(await input.getMetadataTags()); + + /* + const videoTrack = await input.getPrimaryVideoTrack(); const sink = new Mediabunny.VideoSampleSink(videoTrack); const sink2 = new Mediabunny.EncodedPacketSink(videoTrack); @@ -37,6 +41,7 @@ break; } } + */ /* const sink = new Mediabunny.EncodedPacketSink(videoTrack); diff --git a/docs/guide/output-formats.md b/docs/guide/output-formats.md index 79e7ec7..03d358b 100644 --- a/docs/guide/output-formats.md +++ b/docs/guide/output-formats.md @@ -64,6 +64,7 @@ The following options are available: type IsobmffOutputFormatOptions = { fastStart?: false | 'in-memory' | 'reserve' | 'fragmented'; minimumFragmentDuration?: number; + metadataFormat?: 'mdir' | 'mdta' | 'udta' | 'auto'; onFtyp?: (data: Uint8Array, position: number) => unknown; onMoov?: (data: Uint8Array, position: number) => unknown; @@ -94,6 +95,12 @@ type IsobmffOutputFormatOptions = { The default option; it behaves like `'in-memory'` when using [`BufferTarget`](./writing-media-files#buffertarget) and like `false` otherwise. - `minimumFragmentDuration`\ Only relevant when `fastStart` is `'fragmented'`. Sets the minimum duration in seconds a fragment must have to be finalized and written to the file. Defaults to 1 second. +- `metadataFormat`\ + The metadata format to use for writing metadata tags. + - `'auto'` (default): Behaves like `'mdir'` for MP4 and like `'udta'` for QuickTime, matching FFmpeg's default behavior. + - `'mdir'`: Write tags into `moov/udta/meta` using the 'mdir' handler format. + - `'mdta'`: Write tags into `moov/udta/meta` using the 'mdta' handler format, equivalent to FFmpeg's `use_metadata_tags` flag. This allows for custom keys of arbitrary length. + - `'udta'`: Write tags directly into `moov/udta`. - `onFtyp`\ Will be called once the ftyp (File Type) box of the output file has been written. - `onMoov`\ diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index 8b58f9d..96900d0 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -1274,14 +1274,18 @@ export const vtta = (notes: string) => box('vtta', [...textEncoder.encode(notes) const udta = (muxer: IsobmffMuxer) => { const boxes: Box[] = []; + const metadataFormat = muxer.format._options.metadataFormat ?? 'auto'; + const metadataTags = muxer.output._metadataTags; + // Depending on the format, metadata tags are written differently - if (muxer.isQuickTime) { + if (metadataFormat === 'mdir' || (metadataFormat === 'auto' && !muxer.isQuickTime)) { + const metaBox = metaMdir(metadataTags); + if (metaBox) boxes.push(metaBox); + } else if (metadataFormat === 'mdta') { + const metaBox = metaMdta(metadataTags); + if (metaBox) boxes.push(metaBox); + } else if (metadataFormat === 'udta' || (metadataFormat === 'auto' && muxer.isQuickTime)) { addQuickTimeMetadataTagBoxes(boxes, muxer.output._metadataTags); - } else { - const metaBox = meta(muxer.output._metadataTags); - if (metaBox) { - boxes.push(metaBox); - } } if (boxes.length === 0) { @@ -1381,9 +1385,14 @@ const DATA_BOX_MIME_TYPE_MAP: Record = { 'image/bmp': 27, }; -/** Metadata Box */ -const meta = (tags: MetadataTags) => { - const boxes: Box[] = []; +/** + * Generates key-value metadata for inclusion in the "meta" box. + */ +const generateMetadataPairs = (tags: MetadataTags, isMdta: boolean) => { + const pairs: { + key: string; + value: Box; + }[] = []; // https://exiftool.org/TagNames/QuickTime.html (QuickTime ItemList Tags) // This is the metadata format used for MP4 files @@ -1391,39 +1400,42 @@ const meta = (tags: MetadataTags) => { for (const { key, value } of keyValueIterator(tags)) { switch (key) { case 'title': { - boxes.push(metadataTagStringBoxLong('©nam', value)); + pairs.push({ key: isMdta ? 'title' : '©nam', value: dataStringBoxLong(value) }); }; break; case 'description': { - boxes.push(metadataTagStringBoxLong('©des', value)); + pairs.push({ key: isMdta ? 'description' : '©des', value: dataStringBoxLong(value) }); }; break; case 'artist': { - boxes.push(metadataTagStringBoxLong('©ART', value)); + pairs.push({ key: isMdta ? 'artist' : '©ART', value: dataStringBoxLong(value) }); }; break; case 'album': { - boxes.push(metadataTagStringBoxLong('©alb', value)); + pairs.push({ key: isMdta ? 'album' : '©alb', value: dataStringBoxLong(value) }); }; break; case 'albumArtist': { - boxes.push(metadataTagStringBoxLong('aART', value)); + pairs.push({ key: isMdta ? 'album_artist' : 'aART', value: dataStringBoxLong(value) }); }; break; case 'comment': { - boxes.push(metadataTagStringBoxLong('©cmt', value)); + pairs.push({ key: isMdta ? 'comment' : '©cmt', value: dataStringBoxLong(value) }); }; break; case 'genre': { - boxes.push(metadataTagStringBoxLong('©gen', value)); + pairs.push({ key: isMdta ? 'genre' : '©gen', value: dataStringBoxLong(value) }); }; break; case 'lyrics': { - boxes.push(metadataTagStringBoxLong('©lyr', value)); + pairs.push({ key: isMdta ? 'lyrics' : '©lyr', value: dataStringBoxLong(value) }); }; break; case 'date': { - boxes.push(metadataTagStringBoxLong('©day', value.toISOString().slice(0, 10))); + pairs.push({ + key: isMdta ? 'date' : '©day', + value: dataStringBoxLong(value.toISOString().slice(0, 10)), + }); }; break; case 'images': { @@ -1432,40 +1444,45 @@ const meta = (tags: MetadataTags) => { continue; } - boxes.push(box('covr', undefined, [ - box('data', [ - u32(DATA_BOX_MIME_TYPE_MAP[image.mimeType] ?? 0), // Type indicator - u32(0), // Locale indicator - Array.from(image.data), // Kinda slow, hopefully temp - ]), - ])); + pairs.push({ key: 'covr', value: box('data', [ + u32(DATA_BOX_MIME_TYPE_MAP[image.mimeType] ?? 0), // Type indicator + u32(0), // Locale indicator + Array.from(image.data), // Kinda slow, hopefully temp + ]) }); } }; break; case 'trackNumber': { - boxes.push(box('trkn', undefined, [ - box('data', [ + if (isMdta) { + const string = tags.tracksTotal !== undefined + ? `${value}/${tags.tracksTotal}` + : value.toString(); + + pairs.push({ key: 'track', value: dataStringBoxLong(string) }); + } else { + pairs.push({ key: 'trkn', value: box('data', [ u32(0), // 8 bytes empty u32(0), u16(0), // Empty u16(value), u16(tags.tracksTotal ?? 0), u16(0), // Empty - ]), - ])); + ]) }); + } }; break; case 'discNumber': { - boxes.push(box('disc', undefined, [ - box('data', [ + if (!isMdta) { + // Only written for mdir + pairs.push({ key: 'disc', value: box('data', [ u32(0), // 8 bytes empty u32(0), u16(0), // Empty u16(value), u16(tags.discsTotal ?? 0), u16(0), // Empty - ]), - ])); + ]) }); + } }; break; case 'tracksTotal': @@ -1484,49 +1501,74 @@ const meta = (tags: MetadataTags) => { if (tags.raw) { for (const key in tags.raw) { const value = tags.raw[key]; - if (value == null || key.length !== 4 || boxes.some(x => x.type === key)) { + if (value == null || (!isMdta && key.length !== 4) || pairs.some(x => x.key === key)) { continue; } if (typeof value === 'string') { - boxes.push(metadataTagStringBoxLong(key, value)); + pairs.push({ key, value: dataStringBoxLong(value) }); } else if (value instanceof Uint8Array) { - boxes.push(box(key, undefined, [ - box('data', [ - u32(0), // Type indicator - u32(0), // Locale indicator - Array.from(value), - ]), - ])); + pairs.push({ key, value: box('data', [ + u32(0), // Type indicator + u32(0), // Locale indicator + Array.from(value), + ]) }); } else if (value instanceof RichImageData) { - boxes.push(box(key, undefined, [ - box('data', [ - u32(DATA_BOX_MIME_TYPE_MAP[value.mimeType] ?? 0), // Type indicator - u32(0), // Locale indicator - Array.from(value.data), // Kinda slow, hopefully temp - ]), - ])); + pairs.push({ key, value: box('data', [ + u32(DATA_BOX_MIME_TYPE_MAP[value.mimeType] ?? 0), // Type indicator + u32(0), // Locale indicator + Array.from(value.data), // Kinda slow, hopefully temp + ]) }); } } } - if (boxes.length === 0) { + return pairs; +}; + +/** Metadata Box (mdir format) */ +const metaMdir = (tags: MetadataTags) => { + const pairs = generateMetadataPairs(tags, false); + + if (pairs.length === 0) { return null; } + // fullBox format return fullBox('meta', 0, 0, undefined, [ - hdlr(false, 'mdir', '', 'appl'), - box('ilst', undefined, boxes), + hdlr(false, 'mdir', '', 'appl'), // mdir handler + box('ilst', undefined, pairs.map(pair => box(pair.key, undefined, [pair.value]))), // Item list without keys box ]); }; -const metadataTagStringBoxLong = (name: string, value: string) => { - return box(name, undefined, [ - box('data', [ - u32(1), // Type indicator (UTF-8) - u32(0), // Locale indicator - ...textEncoder.encode(value), - ]), +/** Metadata Box (mdta format with keys box) */ +const metaMdta = (tags: MetadataTags) => { + const pairs = generateMetadataPairs(tags, true); + + if (pairs.length === 0) { + return null; + } + + // box without version and flags + return box('meta', undefined, [ + hdlr(false, 'mdta', ''), // mdta handler + fullBox('keys', 0, 0, [ + u32(pairs.length), + ], pairs.map(pair => box('mdta', [ // Hacky since these aren't boxes technically, but if not box why box-shaped? + ...textEncoder.encode(pair.key), + ]))), + box('ilst', undefined, pairs.map((pair, i) => { + const boxName = String.fromCharCode(...u32(i + 1)); + return box(boxName, undefined, [pair.value]); + })), + ]); +}; + +const dataStringBoxLong = (value: string) => { + return box('data', [ + u32(1), // Type indicator (UTF-8) + u32(0), // Locale indicator + ...textEncoder.encode(value), ]); }; diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index d526f7d..044fa28 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -2323,7 +2323,7 @@ export class IsobmffDemuxer extends Demuxer { }; break; case 'trkn': { - if (data instanceof Uint8Array) { + if (data instanceof Uint8Array && data.length >= 6) { const view = toDataView(data); const trackNumber = view.getUint16(2, false); @@ -2340,7 +2340,7 @@ export class IsobmffDemuxer extends Demuxer { case 'disc': case 'disk': { - if (data instanceof Uint8Array) { + if (data instanceof Uint8Array && data.length >= 6) { const view = toDataView(data); const discNumber = view.getUint16(2, false); diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index a0a794c..e866f9d 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -138,7 +138,7 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round = }; export class IsobmffMuxer extends Muxer { - private format: IsobmffOutputFormat; + format: IsobmffOutputFormat; private writer: Writer; private boxWriter: IsobmffBoxWriter; private fastStart: NonNullable; diff --git a/src/output-format.ts b/src/output-format.ts index 4dd43cd..8bc542c 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -140,6 +140,18 @@ export type IsobmffOutputFormatOptions = { */ minimumFragmentDuration?: number; + /** + * The metadata format to use for writing metadata tags. + * + * - `'auto'` (default): Behaves like `'mdir'` for MP4 and like `'udta'` for QuickTime, matching FFmpeg's default + * behavior. + * - `'mdir'`: Write tags into `moov/udta/meta` using the 'mdir' handler format. + * - `'mdta'`: Write tags into `moov/udta/meta` using the 'mdta' handler format, equivalent to FFmpeg's + * `use_metadata_tags` flag. This allows for custom keys of arbitrary length. + * - `'udta'`: Write tags directly into `moov/udta`. + */ + metadataFormat?: 'auto' | 'mdir' | 'mdta' | 'udta'; + /** * Will be called once the ftyp (File Type) box of the output file has been written. * @@ -216,6 +228,14 @@ export abstract class IsobmffOutputFormat extends OutputFormat { if (options.onMoof !== undefined && typeof options.onMoof !== 'function') { throw new TypeError('options.onMoof, when provided, must be a function.'); } + if ( + options.metadataFormat !== undefined + && !['mdir', 'mdta', 'udta', 'auto'].includes(options.metadataFormat) + ) { + throw new TypeError( + 'options.metadataFormat, when provided, must be either \'auto\', \'mdir\', \'mdta\', or \'udta\'.', + ); + } super();