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
+12 -6
View File
@@ -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);
+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;
};
+408 -31
View File
@@ -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<number, string> | 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;
}
+6 -6
View File
@@ -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) {
+30 -1
View File
@@ -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;
}
};
+53 -8
View File
@@ -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<string, string | Uint8Array | RichImageData | null>;
};
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);
};
+18
View File
@@ -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<T extends Record<string, unknown>> = {
[K in keyof T]-?: {
key: K;
value: T[K] extends infer R | undefined ? R : T[K];
}
}[keyof T];
export const keyValueIterator = function* <T extends Record<string, unknown>>(object: T) {
for (const key in object) {
const value = object[key];
if (value === undefined) {
continue;
}
yield { key, value } as KeyValuePair<T>;
}
};
+113 -36
View File
@@ -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<string>();
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);
}
}
+37 -20
View File
@@ -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;