diff --git a/dev/convert.html b/dev/convert.html
index 3f2ae08..10e164b 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -18,15 +18,13 @@
const file = fileInput.files[0];
const source = new Mediabunny.BlobSource(file);
- const target = new Mediabunny.NullTarget() ?? new Mediabunny.BufferTarget() ?? new Mediabunny.StreamTarget(new WritableStream({
+ const target = new Mediabunny.BufferTarget() ?? new Mediabunny.StreamTarget(new WritableStream({
write: console.log
}), {
chunked: true,
chunkSize: 2**20
});
- const outputFormat = new Mediabunny.Mp4OutputFormat({
- onMoov: console.log
- });
+ const outputFormat = new Mediabunny.Mp4OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -57,7 +55,14 @@
kind: 'coverFront',
mimeType: 'image/jpeg'
}],
- lyrics: "There's no way\nThat it's not going there"
+ trackNumber: 4,
+ trackNumberMax: 10,
+ discNumber: 5,
+ discNumberMax: 8,
+ lyrics: "There's no way\nThat it's not going there",
+ raw: {
+ //'TBRU': 'this is a BRUH metadatum',
+ }
});
const conversion = await Mediabunny.Conversion.init({
@@ -100,6 +105,7 @@
},
*/
video: () => ({
+ discard: true,
//codec: 'avc',
//fit: 'contain',
//frameRate: 27.123,
@@ -123,7 +129,7 @@
}),
trim: {
start: 0,
- end: 20
+ end: 1
},
});
console.log(conversion);
diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts
index e25824a..f42e92e 100644
--- a/src/isobmff/isobmff-boxes.ts
+++ b/src/isobmff/isobmff-boxes.ts
@@ -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 = {
- 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 = {
- 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 = {
};
/** 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 = {
+ '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 = {
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;
+};
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index f43c784..aa6e5ba 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -57,6 +57,7 @@ import {
TransformationMatrix,
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
UNDETERMINED_LANGUAGE,
+ toDataView,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { buildIsobmffMimeType } from './isobmff-misc';
@@ -64,9 +65,11 @@ import {
MAX_BOX_HEADER_SIZE,
MIN_BOX_HEADER_SIZE,
readBoxHeader,
+ readDataBox,
readFixed_16_16,
readFixed_2_30,
readIsomVariableInteger,
+ readMetadataStringShort,
} from './isobmff-reader';
import {
FileSlice,
@@ -83,6 +86,20 @@ import {
readU8,
readAscii,
} from '../reader';
+import { MediaMetadata, RichImageData } from '../metadata';
+
+// https://exiftool.org/TagNames/QuickTime.html
+const UDTA_STRING_KEYS = new Set([
+ '@day', '@mak', '@mod', '@swr', '@xyz', 'CAME', 'CNCV', 'CNFV', 'CNMN', 'FIRM', 'FOV\0', 'GoPr', 'LENS', 'PXMN',
+ 'SIGM', 'SNum', 'TAGS', 'albm', 'albr', 'angl', 'auth', 'ccid', 'cdis', 'clfn', 'clid', 'clsf', 'cmid', 'cmnm',
+ 'coll', 'cprt', 'cver', 'cvru', 'date', 'dscp', 'fsid', 'gnre', 'hinv', 'icnu', 'info', 'infu', 'kgtt', 'loci',
+ 'lrcu', 'mcvr', 'name', 'perf', 'pmcc', 'reel', 'rtng', 'scen', 'shot', 'slno', 'thmb', 'titl', 'tnam', 'urat',
+ 'uuid', 'vndr', 'yrrc', '©ART', '©TIM', '©TSC', '©TSZ', '©alb', '©arg', '©ark', '©cmt', '©cok', '©com', '©cpy',
+ '©day', '©dir', '©ed1', '©ed2', '©ed3', '©ed4', '©ed5', '©ed6', '©ed7', '©ed8', '©ed9', '©enc', '©fmt', '©fpt',
+ '©frl', '©fyw', '©gen', '©gpt', '©grl', '©grp', '©gyw', '©inf', '©isr', '©lab', '©lal', '©lyr', '©mak', '©mal',
+ '©mdl', '©mod', '©nam', '©pdk', '©phg', '©prd', '©prf', '©prk', '©prl', '©req', '©snk', '©snm', '©src', '©swf',
+ '©swk', '©swr', '©too', '©trk', '©wrt', '©xsp', '©xyz', '©ysp', '©zsp',
+]);
type InternalTrack = {
id: number;
@@ -232,6 +249,8 @@ export class IsobmffDemuxer extends Demuxer {
movieTimescale = -1;
movieDurationInTimescale = -1;
isQuickTime = false;
+ metadata: MediaMetadata = {};
+ currentMetadataKeys: Map | null = null;
isFragmented = false;
fragmentTrackDefaults: FragmentTrackDefaults[] = [];
@@ -269,6 +288,11 @@ export class IsobmffDemuxer extends Demuxer {
});
}
+ async getMetadata() {
+ await this.readMetadata();
+ return this.metadata;
+ }
+
readMetadata() {
return this.metadataPromise ??= (async () => {
let currentPos = 0;
@@ -605,6 +629,22 @@ export class IsobmffDemuxer extends Demuxer {
}
}
+ // eslint-disable-next-line @stylistic/generator-star-spacing
+ *iterateContiguousBoxes(slice: FileSlice) {
+ const startIndex = slice.filePos;
+
+ while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {
+ const startPos = slice.filePos;
+ const boxInfo = readBoxHeader(slice);
+ if (!boxInfo) {
+ break;
+ }
+
+ yield { boxInfo, slice };
+ slice.filePos = startPos + boxInfo.totalSize;
+ }
+ }
+
traverseBox(slice: FileSlice): boolean {
const startPos = slice.filePos;
const boxInfo = readBoxHeader(slice);
@@ -620,8 +660,7 @@ export class IsobmffDemuxer extends Demuxer {
case 'minf':
case 'dinf':
case 'mfra':
- case 'edts':
- case 'udta': {
+ case 'edts': {
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
}; break;
@@ -683,7 +722,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'tkhd': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
const version = readU8(slice);
const flags = readU24Be(slice);
@@ -729,7 +770,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'elst': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
const version = readU8(slice);
slice.skip(3); // Flags
@@ -777,7 +820,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'mdhd': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
const version = readU8(slice);
slice.skip(3); // Flags
@@ -811,7 +856,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'hdlr': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
slice.skip(8); // Version + flags + pre-defined
const handlerType = readAscii(slice, 4);
@@ -843,7 +890,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stbl': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
track.sampleTableByteOffset = startPos;
@@ -852,7 +901,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stsd': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (track.info === null || track.sampleTable) {
break;
@@ -1048,21 +1099,30 @@ export class IsobmffDemuxer extends Demuxer {
case 'avcC': {
const track = this.currentTrack;
- assert(track && track.info);
+ if (!track) {
+ break;
+ }
+ assert(track.info);
track.info.codecDescription = readBytes(slice, boxInfo.contentSize);
}; break;
case 'hvcC': {
const track = this.currentTrack;
- assert(track && track.info);
+ if (!track) {
+ break;
+ }
+ assert(track.info);
track.info.codecDescription = readBytes(slice, boxInfo.contentSize);
}; break;
case 'vpcC': {
const track = this.currentTrack;
- assert(track && track.info?.type === 'video');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'video');
slice.skip(4); // Version + flags
@@ -1090,7 +1150,10 @@ export class IsobmffDemuxer extends Demuxer {
case 'av1C': {
const track = this.currentTrack;
- assert(track && track.info?.type === 'video');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'video');
slice.skip(1); // Marker + version
@@ -1124,7 +1187,10 @@ export class IsobmffDemuxer extends Demuxer {
case 'colr': {
const track = this.currentTrack;
- assert(track && track.info?.type === 'video');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'video');
const colourType = readAscii(slice, 4);
if (colourType !== 'nclx') {
@@ -1150,7 +1216,10 @@ export class IsobmffDemuxer extends Demuxer {
case 'esds': {
const track = this.currentTrack;
- assert(track && track.info?.type === 'audio');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'audio');
slice.skip(4); // Version + flags
@@ -1224,7 +1293,10 @@ export class IsobmffDemuxer extends Demuxer {
case 'enda': {
const track = this.currentTrack;
- assert(track && track.info?.type === 'audio');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'audio');
const littleEndian = readU16Be(slice) & 0xff; // 0xff is from FFmpeg
@@ -1245,7 +1317,10 @@ export class IsobmffDemuxer extends Demuxer {
case 'pcmC': {
const track = this.currentTrack;
- assert(track && track.info?.type === 'audio');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'audio');
slice.skip(1 + 3); // Version + flags
@@ -1310,7 +1385,10 @@ export class IsobmffDemuxer extends Demuxer {
case 'dOps': { // Used for Opus audio
const track = this.currentTrack;
- assert(track && track.info?.type === 'audio');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'audio');
slice.skip(1); // Version
@@ -1348,7 +1426,10 @@ export class IsobmffDemuxer extends Demuxer {
case 'dfLa': { // Used for FLAC audio
const track = this.currentTrack;
- assert(track && track.info?.type === 'audio');
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'audio');
slice.skip(4); // Version + flags
@@ -1402,7 +1483,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stts': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1433,7 +1516,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'ctts': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1460,7 +1545,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stsz': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1483,7 +1570,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stz2': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1506,7 +1595,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stss': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1531,7 +1622,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stsc': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1569,7 +1662,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'stco': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1587,7 +1682,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'co64': {
const track = this.currentTrack;
- assert(track);
+ if (!track) {
+ break;
+ }
if (!track.sampleTable) {
break;
@@ -1950,14 +2047,294 @@ export class IsobmffDemuxer extends Demuxer {
this.currentFragment.implicitBaseDataOffset = currentOffset;
}; break;
- // These appear in udta:
- case '©nam':
- case 'name': {
- if (!this.currentTrack) {
+ // Metadata section
+ // https://exiftool.org/TagNames/QuickTime.html
+ // https://mp4workshop.com/about
+
+ case 'udta': { // Contains either movie metadata or track metadata
+ const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
+
+ for (const { boxInfo, slice } of iterator) {
+ if (boxInfo.name !== 'meta' && !this.currentTrack) {
+ const startPos = slice.filePos;
+ this.metadata.raw ??= {};
+
+ if (UDTA_STRING_KEYS.has(boxInfo.name)) {
+ this.metadata.raw[boxInfo.name] = readMetadataStringShort(slice);
+ } else {
+ this.metadata.raw[boxInfo.name] = readBytes(slice, boxInfo.contentSize);
+ }
+
+ slice.filePos = startPos;
+ }
+
+ switch (boxInfo.name) {
+ case 'meta': {
+ slice.skip(-boxInfo.headerSize);
+ this.traverseBox(slice);
+ }; break;
+
+ case '©nam':
+ case 'name': {
+ if (this.currentTrack) {
+ this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize));
+ } else {
+ this.metadata.title = readMetadataStringShort(slice);
+ }
+ }; break;
+
+ case '©des': {
+ if (!this.currentTrack) {
+ this.metadata.description = readMetadataStringShort(slice);
+ }
+ }; break;
+
+ case '©ART': {
+ if (!this.currentTrack) {
+ this.metadata.artist = readMetadataStringShort(slice);
+ }
+ }; break;
+
+ case '©alb': {
+ if (!this.currentTrack) {
+ this.metadata.album = readMetadataStringShort(slice);
+ }
+ }; break;
+
+ case 'albr': {
+ if (!this.currentTrack) {
+ this.metadata.albumArtist = readMetadataStringShort(slice);
+ }
+ }; break;
+
+ case '©gen': {
+ if (!this.currentTrack) {
+ this.metadata.genre = readMetadataStringShort(slice);
+ }
+ }; break;
+
+ case '©day': {
+ if (!this.currentTrack) {
+ const date = new Date(readMetadataStringShort(slice));
+ if (!Number.isNaN(date.getTime())) {
+ this.metadata.date = date;
+ }
+ }
+ }; break;
+
+ case '©cmt': {
+ if (!this.currentTrack) {
+ this.metadata.comment = readMetadataStringShort(slice);
+ }
+ }; break;
+
+ case '©lyr': {
+ if (!this.currentTrack) {
+ this.metadata.lyrics = readMetadataStringShort(slice);
+ }
+ }; break;
+ }
+ }
+ }; break;
+
+ case 'meta': {
+ if (this.currentTrack) {
+ break; // Only care about movie-level metadata for now
+ }
+
+ // The 'meta' box comes in two flavors, one with flags/version and one without. To know which is which,
+ // let's read the next 4 bytes, which are either the version or the size of the first subbox.
+ const word = readU32Be(slice);
+ const isQuickTime = word !== 0;
+
+ this.currentMetadataKeys = new Map();
+
+ if (isQuickTime) {
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
+ } else {
+ this.readContiguousBoxes(slice.slice(contentStartPos + 4, boxInfo.contentSize - 4));
+ }
+
+ this.currentMetadataKeys = null;
+ }; break;
+
+ case 'keys': {
+ if (!this.currentMetadataKeys) {
break;
}
- this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize));
+ slice.skip(4); // Version + flags
+
+ const entryCount = readU32Be(slice);
+
+ for (let i = 0; i < entryCount; i++) {
+ const keySize = readU32Be(slice);
+ slice.skip(4); // Key namespace
+ const keyName = textDecoder.decode(readBytes(slice, keySize - 8));
+
+ this.currentMetadataKeys.set(i + 1, keyName);
+ }
+ }; break;
+
+ case 'ilst': {
+ if (!this.currentMetadataKeys) {
+ break;
+ }
+
+ const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
+
+ for (const { boxInfo, slice } of iterator) {
+ let metadataKey = boxInfo.name;
+
+ // Interpret the box name as a u32be
+ const nameAsNumber = (metadataKey.charCodeAt(0) << 24)
+ + (metadataKey.charCodeAt(1) << 16)
+ + (metadataKey.charCodeAt(2) << 8)
+ + metadataKey.charCodeAt(3);
+
+ if (this.currentMetadataKeys.has(nameAsNumber)) {
+ // An entry exists for this number
+ metadataKey = this.currentMetadataKeys.get(nameAsNumber)!;
+ }
+
+ const data = readDataBox(slice);
+
+ this.metadata.raw ??= {};
+ this.metadata.raw[metadataKey] = data;
+
+ switch (metadataKey) {
+ case '©nam':
+ case 'titl':
+ case 'com.apple.quicktime.title':
+ case 'title': {
+ if (typeof data === 'string') {
+ this.metadata.title = data;
+ }
+ }; break;
+
+ case '©des':
+ case 'desc':
+ case 'dscp':
+ case 'com.apple.quicktime.description':
+ case 'description': {
+ if (typeof data === 'string') {
+ this.metadata.description = data;
+ }
+ }; break;
+
+ case '©ART':
+ case 'com.apple.quicktime.artist':
+ case 'artist': {
+ if (typeof data === 'string') {
+ this.metadata.artist = data;
+ }
+ }; break;
+
+ case '©alb':
+ case 'albm':
+ case 'com.apple.quicktime.album':
+ case 'album': {
+ if (typeof data === 'string') {
+ this.metadata.album = data;
+ }
+ }; break;
+
+ case 'aART':
+ case 'album_artist': {
+ if (typeof data === 'string') {
+ this.metadata.albumArtist = data;
+ }
+ }; break;
+
+ case '©cmt':
+ case 'com.apple.quicktime.comment':
+ case 'comment': {
+ if (typeof data === 'string') {
+ this.metadata.comment = data;
+ }
+ }; break;
+
+ case '©gen':
+ case 'gnre':
+ case 'com.apple.quicktime.genre':
+ case 'genre': {
+ if (typeof data === 'string') {
+ this.metadata.genre = data;
+ }
+ }; break;
+
+ case '©lyr':
+ case 'lyrics': {
+ if (typeof data === 'string') {
+ this.metadata.lyrics = data;
+ }
+ }; break;
+
+ case '©day':
+ case 'rldt':
+ case 'com.apple.quicktime.creationdate':
+ case 'date': {
+ if (typeof data === 'string') {
+ const date = new Date(data);
+ if (!Number.isNaN(date.getTime())) {
+ this.metadata.date = date;
+ }
+ }
+ }; break;
+
+ case 'covr':
+ case 'com.apple.quicktime.artwork': {
+ if (data instanceof RichImageData) {
+ this.metadata.images ??= [];
+ this.metadata.images.push({
+ data: data.data,
+ kind: 'coverFront',
+ mimeType: data.mimeType,
+ });
+ } else if (data instanceof Uint8Array) {
+ this.metadata.images ??= [];
+ this.metadata.images.push({
+ data,
+ kind: 'coverFront',
+ mimeType: 'image/*',
+ });
+ }
+ }; break;
+
+ case 'trkn': {
+ if (data instanceof Uint8Array) {
+ const view = toDataView(data);
+
+ const trackNumber = view.getUint16(2, false);
+ const trackNumberMax = view.getUint16(4, false);
+
+ if (trackNumber > 0) {
+ this.metadata.trackNumber = trackNumber;
+ }
+ if (trackNumberMax > 0) {
+ this.metadata.trackNumberMax = trackNumberMax;
+ }
+ }
+ }; break;
+
+ case 'disc':
+ case 'disk': {
+ if (data instanceof Uint8Array) {
+ const view = toDataView(data);
+
+ const discNumber = view.getUint16(2, false);
+ const discNumberMax = view.getUint16(4, false);
+
+ if (discNumber > 0) {
+ this.metadata.discNumber = discNumber;
+ }
+ if (discNumberMax > 0) {
+ this.metadata.discNumberMax = discNumberMax;
+ }
+ }
+ }; break;
+ }
+ }
}; break;
}
diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts
index 1ffd0c8..cd14688 100644
--- a/src/isobmff/isobmff-muxer.ts
+++ b/src/isobmff/isobmff-muxer.ts
@@ -152,10 +152,10 @@ export class IsobmffMuxer extends Muxer {
private mdat: Box | null = null;
- private trackDatas: IsobmffTrackData[] = [];
+ trackDatas: IsobmffTrackData[] = [];
private allTracksKnown = promiseWithResolvers();
- private creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET;
+ creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET;
private finalizedChunks: Chunk[] = [];
private nextFragmentNumber = 1;
@@ -988,7 +988,7 @@ export class IsobmffMuxer extends Muxer {
}
// Write the moov box now that we have all decoder configs
- const movieBox = moov(this.trackDatas, this.creationTime, true);
+ const movieBox = moov(this, true);
this.boxWriter.writeBox(movieBox);
if (this.format._options.onMoov) {
@@ -1140,7 +1140,7 @@ export class IsobmffMuxer extends Muxer {
// size of the moov box and can compute the proper chunk positions.
for (let i = 0; i < 2; i++) {
- const movieBox = moov(this.trackDatas, this.creationTime);
+ const movieBox = moov(this);
const movieBoxSize = this.boxWriter.measureBox(movieBox);
mdatSize = this.boxWriter.measureBox(this.mdat);
let currentChunkPos = this.writer.getPos() + movieBoxSize + mdatSize;
@@ -1162,7 +1162,7 @@ export class IsobmffMuxer extends Muxer {
this.writer.startTrackingWrites();
}
- const movieBox = moov(this.trackDatas, this.creationTime);
+ const movieBox = moov(this);
this.boxWriter.writeBox(movieBox);
if (this.format._options.onMoov) {
@@ -1218,7 +1218,7 @@ export class IsobmffMuxer extends Muxer {
this.writer.startTrackingWrites();
}
- const movieBox = moov(this.trackDatas, this.creationTime);
+ const movieBox = moov(this);
this.boxWriter.writeBox(movieBox);
if (this.format._options.onMoov) {
diff --git a/src/isobmff/isobmff-reader.ts b/src/isobmff/isobmff-reader.ts
index f1d0605..3aa5170 100644
--- a/src/isobmff/isobmff-reader.ts
+++ b/src/isobmff/isobmff-reader.ts
@@ -6,7 +6,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { FileSlice, readAscii, readI32Be, readU32Be, readU64Be, readU8 } from '../reader';
+import { RichImageData } from '../metadata';
+import { textDecoder } from '../misc';
+import { FileSlice, readAscii, readBytes, readI32Be, readU16Be, readU32Be, readU64Be, readU8 } from '../reader';
export const MIN_BOX_HEADER_SIZE = 8;
export const MAX_BOX_HEADER_SIZE = 16;
@@ -53,3 +55,30 @@ export const readIsomVariableInteger = (slice: FileSlice) => {
return result;
};
+
+export const readMetadataStringShort = (slice: FileSlice) => {
+ const stringLength = readU16Be(slice);
+ slice.skip(2); // Language
+ return textDecoder.decode(readBytes(slice, stringLength));
+};
+
+export const readDataBox = (slice: FileSlice) => {
+ const header = readBoxHeader(slice);
+ if (!header || header.name !== 'data') {
+ return null;
+ }
+
+ const typeIndicator = readU32Be(slice);
+ slice.skip(4); // Locale indicator
+ const data = readBytes(slice, header.contentSize - 8);
+
+ switch (typeIndicator) {
+ case 1: return textDecoder.decode(data); // UTF-8
+ case 2: return new TextDecoder('utf-16be').decode(data); // UTF-16-BE
+ case 13: return new RichImageData(data, 'image/jpeg');
+ case 14: return new RichImageData(data, 'image/png');
+ case 27: return new RichImageData(data, 'image/bmp');
+
+ default: return data;
+ }
+};
diff --git a/src/metadata.ts b/src/metadata.ts
index 1737491..adda62f 100644
--- a/src/metadata.ts
+++ b/src/metadata.ts
@@ -2,13 +2,16 @@
export type MediaMetadata = {
title?: string;
+ description?: string;
artist?: string;
album?: string;
albumArtist?: string;
trackNumber?: number;
+ trackNumberMax?: number;
discNumber?: number;
+ discNumberMax?: number;
genre?: string;
- releasedAt?: Date;
+ date?: Date;
lyrics?: string;
images?: {
data: Uint8Array;
@@ -18,8 +21,13 @@ export type MediaMetadata = {
description?: string;
}[];
comment?: string;
+ raw?: Record;
};
+export class RichImageData {
+ constructor(public data: Uint8Array, public mimeType: string) {}
+}
+
export const validateMediaMetadata = (metadata: MediaMetadata) => {
if (!metadata || typeof metadata !== 'object') {
throw new TypeError('metadata must be an object.');
@@ -27,6 +35,9 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => {
if (metadata.title !== undefined && typeof metadata.title !== 'string') {
throw new TypeError('metadata.title, when provided, must be a string.');
}
+ if (metadata.description !== undefined && typeof metadata.description !== 'string') {
+ throw new TypeError('metadata.description, when provided, must be a string.');
+ }
if (metadata.artist !== undefined && typeof metadata.artist !== 'string') {
throw new TypeError('metadata.artist, when provided, must be a string.');
}
@@ -39,21 +50,30 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => {
if (metadata.trackNumber !== undefined && (!Number.isInteger(metadata.trackNumber) || metadata.trackNumber <= 0)) {
throw new TypeError('metadata.trackNumber, when provided, must be a positive integer.');
}
+ if (
+ metadata.trackNumberMax !== undefined
+ && (!Number.isInteger(metadata.trackNumberMax) || metadata.trackNumberMax <= 0)
+ ) {
+ throw new TypeError('metadata.trackNumberMax, when provided, must be a positive integer.');
+ }
if (metadata.discNumber !== undefined && (!Number.isInteger(metadata.discNumber) || metadata.discNumber <= 0)) {
throw new TypeError('metadata.discNumber, when provided, must be a positive integer.');
}
+ if (
+ metadata.discNumberMax !== undefined
+ && (!Number.isInteger(metadata.discNumberMax) || metadata.discNumberMax <= 0)
+ ) {
+ throw new TypeError('metadata.discNumberMax, when provided, must be a positive integer.');
+ }
if (metadata.genre !== undefined && typeof metadata.genre !== 'string') {
throw new TypeError('metadata.genre, when provided, must be a string.');
}
- if (metadata.releasedAt !== undefined && !(metadata.releasedAt instanceof Date)) {
- throw new TypeError('metadata.releasedAt, when provided, must be a Date.');
+ if (metadata.date !== undefined && !(metadata.date instanceof Date)) {
+ throw new TypeError('metadata.date, when provided, must be a Date.');
}
if (metadata.lyrics !== undefined && typeof metadata.lyrics !== 'string') {
throw new TypeError('metadata.lyrics, when provided, must be a string.');
}
- if (metadata.comment !== undefined && typeof metadata.comment !== 'string') {
- throw new TypeError('metadata.comment, when provided, must be a string.');
- }
if (metadata.images !== undefined) {
if (!Array.isArray(metadata.images)) {
throw new TypeError('metadata.images, when provided, must be an array.');
@@ -73,18 +93,43 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => {
}
}
}
+ if (metadata.comment !== undefined && typeof metadata.comment !== 'string') {
+ throw new TypeError('metadata.comment, when provided, must be a string.');
+ }
+ if (metadata.raw !== undefined) {
+ if (!metadata.raw || typeof metadata.raw !== 'object') {
+ throw new TypeError('metadata.raw, when provided, must be an object.');
+ }
+
+ for (const value of Object.values(metadata.raw)) {
+ if (
+ value !== null
+ && typeof value !== 'string'
+ && !(value instanceof Uint8Array)
+ && !(value instanceof RichImageData)
+ ) {
+ throw new TypeError(
+ 'Each value in metadata.raw must be a string, Uint8Array, RichImageData, or null.',
+ );
+ }
+ }
+ }
};
export const mediaMetadataIsEmpty = (metadata: MediaMetadata) => {
return metadata.title === undefined
+ && metadata.description === undefined
&& metadata.artist === undefined
&& metadata.album === undefined
&& metadata.albumArtist === undefined
&& metadata.trackNumber === undefined
+ && metadata.trackNumberMax === undefined
&& metadata.discNumber === undefined
+ && metadata.discNumberMax === undefined
&& metadata.genre === undefined
- && metadata.releasedAt === undefined
+ && metadata.date === undefined
&& metadata.lyrics === undefined
+ && (!metadata.images || metadata.images.length === 0)
&& metadata.comment === undefined
- && (!metadata.images || metadata.images.length === 0);
+ && (metadata.raw === undefined || Object.keys(metadata.raw).length === 0);
};
diff --git a/src/misc.ts b/src/misc.ts
index c794b10..a94ff73 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -657,3 +657,21 @@ export const coalesceIndex = (a: number, b: number) => {
export const closedIntervalsOverlap = (startA: number, endA: number, startB: number, endB: number) => {
return startA <= endB && startB <= endA;
};
+
+type KeyValuePair> = {
+ [K in keyof T]-?: {
+ key: K;
+ value: T[K] extends infer R | undefined ? R : T[K];
+ }
+}[keyof T];
+
+export const keyValueIterator = function* >(object: T) {
+ for (const key in object) {
+ const value = object[key];
+ if (value === undefined) {
+ continue;
+ }
+
+ yield { key, value } as KeyValuePair;
+ }
+};
diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts
index 81549d6..03870a9 100644
--- a/src/mp3/mp3-muxer.ts
+++ b/src/mp3/mp3-muxer.ts
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { assert, toDataView } from '../misc';
+import { assert, assertNever, keyValueIterator, textEncoder, toDataView } from '../misc';
import { mediaMetadataIsEmpty, MediaMetadata } from '../metadata';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack } from '../output';
@@ -15,6 +15,7 @@ import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
import { getXingOffset, INFO, readFrameHeader, XING } from '../../shared/mp3-misc';
import { Mp3Writer, XingFrameData } from './mp3-writer';
+import { Id3V2TextEncoding } from './mp3-reader';
export class Mp3Muxer extends Muxer {
private format: Mp3OutputFormat;
@@ -129,43 +130,119 @@ export class Mp3Muxer extends Muxer {
this.mp3Writer.writeSynchsafeU32(0); // Size placeholder
const startPos = this.writer.getPos();
+ const writtenTags = new Set();
- if (metadata.title !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TIT2', metadata.title);
+ for (const { key, value } of keyValueIterator(metadata)) {
+ switch (key) {
+ case 'title': {
+ this.mp3Writer.writeId3V2TextFrame('TIT2', value);
+ writtenTags.add('TIT2');
+ }; break;
+
+ case 'description': {
+ this.mp3Writer.writeId3V2TextFrame('TIT3', value);
+ writtenTags.add('TIT3');
+ }; break;
+
+ case 'artist': {
+ this.mp3Writer.writeId3V2TextFrame('TPE1', value);
+ writtenTags.add('TPE1');
+ }; break;
+
+ case 'album': {
+ this.mp3Writer.writeId3V2TextFrame('TALB', value);
+ writtenTags.add('TALB');
+ }; break;
+
+ case 'albumArtist': {
+ this.mp3Writer.writeId3V2TextFrame('TPE2', value);
+ writtenTags.add('TPE2');
+ }; break;
+
+ case 'trackNumber': {
+ const string = metadata.trackNumberMax !== undefined
+ ? `${value}/${metadata.trackNumberMax}`
+ : value.toString();
+
+ this.mp3Writer.writeId3V2TextFrame('TRCK', string);
+ writtenTags.add('TRCK');
+ }; break;
+
+ case 'discNumber': {
+ const string = metadata.discNumberMax !== undefined
+ ? `${value}/${metadata.discNumberMax}`
+ : value.toString();
+
+ this.mp3Writer.writeId3V2TextFrame('TPOS', string);
+ writtenTags.add('TPOS');
+ }; break;
+
+ case 'genre': {
+ this.mp3Writer.writeId3V2TextFrame('TCON', value);
+ writtenTags.add('TCON');
+ }; break;
+
+ case 'date': {
+ this.mp3Writer.writeId3V2TextFrame('TDRC', value.toISOString().split('.')[0]!);
+ writtenTags.add('TDRC');
+ }; break;
+
+ case 'lyrics': {
+ this.mp3Writer.writeId3V2LyricsFrame(value);
+ writtenTags.add('USLT');
+ }; break;
+
+ case 'comment': {
+ this.mp3Writer.writeId3V2CommentFrame(value);
+ writtenTags.add('COMM');
+ }; break;
+
+ case 'images': {
+ const pictureTypeMap = { coverFront: 0x03, coverBack: 0x04, unknown: 0x00 };
+ for (const image of value) {
+ const pictureType = pictureTypeMap[image.kind];
+ const description = image.description ?? '';
+ this.mp3Writer.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data);
+ }
+ }; break;
+
+ case 'trackNumberMax':
+ case 'discNumberMax': {
+ // Handled with trackNumber and discNumber respectively
+ }; break;
+
+ case 'raw': {
+ // Handled later
+ }; break;
+
+ default: assertNever(key);
+ }
}
- if (metadata.artist !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TPE1', metadata.artist);
- }
- if (metadata.album !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TALB', metadata.album);
- }
- if (metadata.albumArtist !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TPE2', metadata.albumArtist);
- }
- if (metadata.trackNumber !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TRCK', metadata.trackNumber.toString());
- }
- if (metadata.discNumber !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TPOS', metadata.discNumber.toString());
- }
- if (metadata.genre !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TCON', metadata.genre);
- }
- if (metadata.releasedAt !== undefined) {
- this.mp3Writer.writeId3V2TextFrame('TDRC', metadata.releasedAt.toISOString().split('.')[0]!);
- }
- if (metadata.lyrics !== undefined) {
- this.mp3Writer.writeId3V2LyricsFrame(metadata.lyrics);
- }
- if (metadata.comment !== undefined) {
- this.mp3Writer.writeId3V2CommentFrame(metadata.comment);
- }
- if (metadata.images) {
- const pictureTypeMap = { coverFront: 0x03, coverBack: 0x04, unknown: 0x00 };
- for (const image of metadata.images) {
- const pictureType = pictureTypeMap[image.kind];
- const description = image.description ?? '';
- this.mp3Writer.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data);
+
+ if (metadata.raw) {
+ for (const key in metadata.raw) {
+ const value = metadata.raw[key];
+ if (value == null || key.length !== 4 || writtenTags.has(key)) {
+ continue;
+ }
+
+ let bytes: Uint8Array;
+ if (typeof value === 'string') {
+ const encoded = textEncoder.encode(value);
+ bytes = new Uint8Array(encoded.byteLength + 2);
+ bytes[0] = Id3V2TextEncoding.UTF_8;
+ bytes.set(encoded, 1);
+ // Last byte is the null terminator
+ } else if (value instanceof Uint8Array) {
+ bytes = value;
+ } else {
+ continue;
+ }
+
+ this.mp3Writer.writeAscii(key);
+ this.mp3Writer.writeSynchsafeU32(bytes.byteLength);
+ this.mp3Writer.writeU16(0x0000);
+ this.writer.write(bytes);
}
}
diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts
index 0bb7066..5d581f3 100644
--- a/src/mp3/mp3-reader.ts
+++ b/src/mp3/mp3-reader.ts
@@ -65,21 +65,6 @@ export const ID3_V1_GENRES = [
'Dubstep', 'Garage rock', 'Psybient',
];
-/*
-export const readId3 = (slice: FileSlice) => {
- const tag = readAscii(slice, 3);
- if (tag !== 'ID3') {
- slice.skip(-3);
- return null;
- }
-
- slice.skip(3);
-
- const size = decodeSynchsafe(readU32Be(slice));
- return { size };
-};
-*/
-
export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
header: FrameHeader;
startPos: number;
@@ -105,6 +90,11 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti
};
export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => {
+ const startPos = slice.filePos;
+ metadata.raw ??= {};
+ metadata.raw['TAG'] = readBytes(slice, ID3_V1_TAG_SIZE - 3); // Dump the whole tag into the raw metadata
+ slice.filePos = startPos;
+
const title = readId3V1String(slice, 30);
if (title) metadata.title ??= title;
@@ -117,7 +107,7 @@ export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => {
const yearText = readId3V1String(slice, 4);
const year = Number.parseInt(yearText, 10);
if (Number.isInteger(year) && year > 0) {
- metadata.releasedAt ??= new Date(year, 0, 1);
+ metadata.date ??= new Date(year, 0, 1);
}
const commentBytes = readBytes(slice, 30);
@@ -216,6 +206,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M
break;
}
+ const frameStartPos = reader.pos;
const frameEndPos = reader.pos + frame.size;
let frameEncrypted = false;
@@ -248,12 +239,28 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M
reader.ununsynchronizeRegion(reader.pos, frameEndPos);
}
+ metadata.raw ??= {};
+ if (frame.id[0] === 'T') {
+ // It's a text frame, let's decode as text
+ metadata.raw[frame.id] = reader.readId3V2EncodingAndText(frameEndPos);
+ } else {
+ // For the others, let's just get the bytes
+ metadata.raw[frame.id] = reader.readBytes(frame.size);
+ }
+
+ reader.pos = frameStartPos;
+
switch (frame.id) {
case 'TIT2':
case 'TT2': {
metadata.title ??= reader.readId3V2EncodingAndText(frameEndPos);
}; break;
+ case 'TIT3':
+ case 'TT3': {
+ metadata.description ??= reader.readId3V2EncodingAndText(frameEndPos);
+ }; break;
+
case 'TPE1':
case 'TP1': {
metadata.artist ??= reader.readId3V2EncodingAndText(frameEndPos);
@@ -272,21 +279,31 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M
case 'TRCK':
case 'TRK': {
const trackText = reader.readId3V2EncodingAndText(frameEndPos);
- const trackNum = Number.parseInt(trackText, 10);
+ const parts = trackText.split('/');
+ const trackNum = Number.parseInt(parts[0]!, 10);
+ const trackNumMax = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(trackNum) && trackNum > 0) {
metadata.trackNumber ??= trackNum;
}
+ if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) {
+ metadata.trackNumberMax ??= trackNumMax;
+ }
}; break;
case 'TPOS':
case 'TPA': {
const discText = reader.readId3V2EncodingAndText(frameEndPos);
- const discNum = Number.parseInt(discText, 10);
+ const parts = discText.split('/');
+ const discNum = Number.parseInt(parts[0]!, 10);
+ const discNumMax = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(discNum) && discNum > 0) {
metadata.discNumber ??= discNum;
}
+ if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) {
+ metadata.discNumberMax ??= discNumMax;
+ }
}; break;
case 'TCON':
@@ -319,7 +336,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M
const date = new Date(dateText);
if (!Number.isNaN(date.getTime())) {
- metadata.releasedAt ??= date;
+ metadata.date ??= date;
}
}; break;
@@ -329,7 +346,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M
const year = Number.parseInt(yearText, 10);
if (Number.isInteger(year)) {
- metadata.releasedAt ??= new Date(year, 0, 1);
+ metadata.date ??= new Date(year, 0, 1);
}
}; break;