Add metadata read/write support to ISOBMFF, add more metadata fields, add raw metadata support

This commit is contained in:
Vanilagy
2025-09-04 21:28:23 +02:00
parent 7f310eccdf
commit efa933863d
9 changed files with 967 additions and 174 deletions
+290 -66
View File
@@ -18,6 +18,8 @@ import {
MATRIX_COEFFICIENTS_MAP,
colorSpaceIsComplete,
UNDETERMINED_LANGUAGE,
assertNever,
keyValueIterator,
} from '../misc';
import {
AudioCodec,
@@ -35,12 +37,14 @@ import {
GLOBAL_TIMESCALE,
intoTimescale,
IsobmffAudioTrackData,
IsobmffMuxer,
IsobmffSubtitleTrackData,
IsobmffTrackData,
IsobmffVideoTrackData,
Sample,
} from './isobmff-muxer';
import { parseOpusIdentificationHeader } from '../codec-data';
import { MediaMetadata, RichImageData } from '../metadata';
export class IsobmffBoxWriter {
private helper = new Uint8Array(8);
@@ -331,13 +335,13 @@ export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSi
* an application to interpret the sample data that is stored elsewhere.
*/
export const moov = (
trackDatas: IsobmffTrackData[],
creationTime: number,
muxer: IsobmffMuxer,
fragmented = false,
) => box('moov', undefined, [
mvhd(creationTime, trackDatas),
...trackDatas.map(x => trak(x, creationTime)),
fragmented ? mvex(trackDatas) : null,
mvhd(muxer.creationTime, muxer.trackDatas),
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
fragmented ? mvex(muxer.trackDatas) : null,
udta(muxer),
]);
/** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */
@@ -387,7 +391,7 @@ export const trak = (trackData: IsobmffTrackData, creationTime: number) => {
mdia(trackData, creationTime),
trackMetadata.name !== undefined
? box('udta', undefined, [
box('©nam', [
box('name', [ // VLC (and Mediabunny) also recognize ©nam
...textEncoder.encode(trackMetadata.name),
]),
])
@@ -395,57 +399,6 @@ export const trak = (trackData: IsobmffTrackData, creationTime: number) => {
]);
};
/*
const meta = (trackData: IsobmffTrackData) => {
const trackMetadata = getTrackMetadata(trackData);
if (trackData.muxer.isQuickTime) {
const keyMap: Record<keyof IsobmffMetadata, string> = {
name: 'com.apple.quicktime.title',
};
return box('meta', undefined, [
hdlr(false, 'mdta', ''),
fullBox('keys', 0, 0, [
u32(Object.keys(trackMetadata).length),
], Object.keys(trackMetadata).map(key =>
box('mdta', [
ascii(keyMap[key as keyof IsobmffMetadata]), // Key name
]),
)),
box('ilst', undefined, Object.values(trackMetadata)
.map((value, i) => box(u32(i + 1).map(x => String.fromCharCode(x)).join(''), undefined, [
data(value),
]))),
]);
} else {
const keyMap: Record<keyof IsobmffMetadata, string> = {
name: '©nam',
};
return fullBox('meta', 0, 0, undefined, [
hdlr(false, 'mdir', ''),
box('ilst', undefined, Object.entries(trackMetadata)
.map(([key, value]) => box(keyMap[key as keyof IsobmffMetadata], undefined, [
data(value),
]))),
]);
}
};
const data = (value: unknown) => {
if (typeof value === 'string') {
return box('data', [
u32(1), // Type indicator (UTF-8)
u32(0), // Locale indicator
...textEncoder.encode(value),
]);
}
throw new Error('Unhandled data type.');
};
*/
/** Track Header Box: Specifies the characteristics of a single track within a movie. */
export const tkhd = (
trackData: IsobmffTrackData,
@@ -506,18 +459,12 @@ export const mdhd = (
const needsU64 = !isU32(creationTime) || !isU32(localDuration);
const u32OrU64 = needsU64 ? u64 : u32;
let language = 0;
for (const character of (trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE)) {
language <<= 5;
language += character.charCodeAt(0) - 0x60;
}
return fullBox('mdhd', +needsU64, 0, [
u32OrU64(creationTime), // Creation time
u32OrU64(creationTime), // Modification time
u32(trackData.timescale), // Timescale
u32OrU64(localDuration), // Duration
u16(language), // Language
u16(getLanguageCodeInt(trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE)), // Language
u16(0), // Quality
]);
};
@@ -535,10 +482,15 @@ const TRACK_TYPE_TO_HANDLER_NAME: Record<IsobmffTrackData['type'], string> = {
};
/** Handler Reference Box. */
export const hdlr = (hasComponentType: boolean, handlerType: string, name: string) => fullBox('hdlr', 0, 0, [
export const hdlr = (
hasComponentType: boolean,
handlerType: string,
name: string,
manufacturer = '\0\0\0\0',
) => fullBox('hdlr', 0, 0, [
hasComponentType ? ascii('mhlr') : u32(0), // Component type
ascii(handlerType), // Component subtype
u32(0), // Component manufacturer
ascii(manufacturer), // Component manufacturer
u32(0), // Component flags
u32(0), // Component flags mask
ascii(name, true), // Component name
@@ -1318,6 +1270,266 @@ export const vttc = (
/** VTT Additional Text Box */
export const vtta = (notes: string) => box('vtta', [...textEncoder.encode(notes)]);
/** User Data Box */
const udta = (muxer: IsobmffMuxer) => {
const boxes: Box[] = [];
// Depending on the format, metadata is written differently
if (muxer.isQuickTime) {
addQuickTimeMetadataboxes(boxes, muxer.output._metadata);
} else {
const metaBox = meta(muxer.output._metadata);
if (metaBox) {
boxes.push(metaBox);
}
}
if (boxes.length === 0) {
return null;
}
return box('udta', undefined, boxes);
};
const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => {
// https://exiftool.org/TagNames/QuickTime.html (QuickTime UserData Tags)
// For QuickTime files, metadata is dumped into the udta box
for (const { key, value } of keyValueIterator(metadata)) {
switch (key) {
case 'title': {
boxes.push(metadataStringBoxShort('©nam', value));
}; break;
case 'description': {
boxes.push(metadataStringBoxShort('©des', value));
}; break;
case 'artist': {
boxes.push(metadataStringBoxShort('©ART', value));
}; break;
case 'album': {
boxes.push(metadataStringBoxShort('©alb', value));
}; break;
case 'albumArtist': {
boxes.push(metadataStringBoxShort('albr', value));
}; break;
case 'genre': {
boxes.push(metadataStringBoxShort('©gen', value));
}; break;
case 'date': {
boxes.push(metadataStringBoxShort('©day', value.toISOString().split('T')[0]!));
}; break;
case 'comment': {
boxes.push(metadataStringBoxShort('©cmt', value));
}; break;
case 'lyrics': {
boxes.push(metadataStringBoxShort('©lyr', value));
}; break;
case 'raw': {
// Handled later
}; break;
case 'discNumber':
case 'discNumberMax':
case 'trackNumber':
case 'trackNumberMax':
case 'images': {
// Not written for QuickTime (common Apple L)
}; break;
default: assertNever(key);
}
}
if (metadata.raw) {
for (const key in metadata.raw) {
const value = metadata.raw[key];
if (value == null || key.length !== 4 || boxes.some(x => x.type === key)) {
continue;
}
if (typeof value === 'string') {
boxes.push(metadataStringBoxShort(key, value));
} else if (value instanceof Uint8Array) {
boxes.push(box(key, Array.from(value)));
}
}
}
};
const metadataStringBoxShort = (name: string, value: string) => {
const encoded = textEncoder.encode(value);
return box(name, [
u16(encoded.length),
u16(getLanguageCodeInt('und')),
Array.from(encoded),
]);
};
const DATA_BOX_MIME_TYPE_MAP: Record<string, number> = {
'image/jpeg': 13,
'image/png': 14,
'image/bmp': 27,
};
/** Metadata Box */
const meta = (metadata: MediaMetadata) => {
const boxes: Box[] = [];
// https://exiftool.org/TagNames/QuickTime.html (QuickTime ItemList Tags)
// This is the metadata format used for MP4 files
for (const { key, value } of keyValueIterator(metadata)) {
switch (key) {
case 'title': {
boxes.push(metadataStringBoxLong('©nam', value));
}; break;
case 'description': {
boxes.push(metadataStringBoxLong('©des', value));
}; break;
case 'artist': {
boxes.push(metadataStringBoxLong('©ART', value));
}; break;
case 'album': {
boxes.push(metadataStringBoxLong('©alb', value));
}; break;
case 'albumArtist': {
boxes.push(metadataStringBoxLong('aART', value));
}; break;
case 'comment': {
boxes.push(metadataStringBoxLong('©cmt', value));
}; break;
case 'genre': {
boxes.push(metadataStringBoxLong('©gen', value));
}; break;
case 'lyrics': {
boxes.push(metadataStringBoxLong('©lyr', value));
}; break;
case 'date': {
boxes.push(metadataStringBoxLong('©day', value.toISOString().split('T')[0]!));
}; break;
case 'images': {
for (const image of value) {
if (image.kind !== 'coverFront') {
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
]),
]));
}
}; break;
case 'trackNumber': {
boxes.push(box('trkn', undefined, [
box('data', [
u32(0), // 8 bytes empty
u32(0),
u16(0), // Empty
u16(value),
u16(metadata.trackNumberMax ?? 0),
u16(0), // Empty
]),
]));
}; break;
case 'discNumber': {
boxes.push(box('disc', undefined, [
box('data', [
u32(0), // 8 bytes empty
u32(0),
u16(0), // Empty
u16(value),
u16(metadata.discNumberMax ?? 0),
u16(0), // Empty
]),
]));
}; break;
case 'trackNumberMax':
case 'discNumberMax':{
// These are included with 'trackNumber' and 'discNumber' respectively
}; break;
case 'raw': {
// Handled later
}; break;
default: assertNever(key);
}
}
if (metadata.raw) {
for (const key in metadata.raw) {
const value = metadata.raw[key];
if (value == null || key.length !== 4 || boxes.some(x => x.type === key)) {
continue;
}
if (typeof value === 'string') {
boxes.push(metadataStringBoxLong(key, value));
} else if (value instanceof Uint8Array) {
boxes.push(box(key, undefined, [
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
]),
]));
}
}
}
if (boxes.length === 0) {
return null;
}
return fullBox('meta', 0, 0, undefined, [
hdlr(false, 'mdir', '', 'appl'),
box('ilst', undefined, boxes),
]);
};
const metadataStringBoxLong = (name: string, value: string) => {
return box(name, undefined, [
box('data', [
u32(1), // Type indicator (UTF-8)
u32(0), // Locale indicator
...textEncoder.encode(value),
]),
]);
};
const VIDEO_CODEC_TO_BOX_NAME: Record<VideoCodec, string> = {
avc: 'avc1',
hevc: 'hvc1',
@@ -1426,3 +1638,15 @@ const SUBTITLE_CODEC_TO_CONFIGURATION_BOX: Record<
> = {
webvtt: vttC,
};
const getLanguageCodeInt = (code: string) => {
assert(code.length === 3); ;
let language = 0;
for (let i = 0; i < 3; i++) {
language <<= 5;
language += code.charCodeAt(i) - 0x60;
}
return language;
};