mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-28 11:23:45 +02:00
Add metadata read/write support to ISOBMFF, add more metadata fields, add raw metadata support
This commit is contained in:
+12
-6
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user