846 lines
28 KiB
JavaScript
846 lines
28 KiB
JavaScript
import { getCoverBlob, getTrackTitle, getMimeType, getFullArtistString } from './utils.js';
|
|
import { METADATA_STRINGS } from './metadata.js';
|
|
|
|
const { DEFAULT_TITLE, DEFAULT_ARTIST, DEFAULT_ALBUM } = METADATA_STRINGS;
|
|
|
|
export async function readM4aMetadata(file, metadata) {
|
|
try {
|
|
const chunkSize = Math.min(file.size, 5 * 1024 * 1024);
|
|
const buffer = await file.slice(0, chunkSize).arrayBuffer();
|
|
const view = new DataView(buffer);
|
|
|
|
const atoms = parseMp4Atoms(view);
|
|
|
|
const moov = atoms.find((a) => a.type === 'moov');
|
|
if (!moov) return;
|
|
|
|
const moovStart = moov.offset + 8;
|
|
const moovLen = moov.size - 8;
|
|
const moovData = new DataView(view.buffer, moovStart, moovLen);
|
|
const moovAtoms = parseMp4Atoms(moovData);
|
|
|
|
// mvhd metadata tag
|
|
const mvhd = moovAtoms.find((a) => a.type === 'mvhd');
|
|
if (mvhd) {
|
|
const mvhdStart = moovStart + mvhd.offset + 8;
|
|
const version = view.getUint8(mvhdStart);
|
|
|
|
// resolution and length, basically
|
|
let timeScale, duration;
|
|
|
|
if (version === 0) {
|
|
// 32-bit format
|
|
timeScale = view.getUint32(mvhdStart + 12, false);
|
|
duration = view.getUint32(mvhdStart + 16, false);
|
|
} else if (version === 1) {
|
|
// 64-bit format
|
|
timeScale = view.getUint32(mvhdStart + 20, false);
|
|
const durHigh = view.getUint32(mvhdStart + 24, false);
|
|
const durLow = view.getUint32(mvhdStart + 28, false);
|
|
duration = durHigh * 0x100000000 + durLow;
|
|
}
|
|
|
|
if (timeScale > 0) {
|
|
metadata.duration = duration / timeScale;
|
|
}
|
|
}
|
|
|
|
const udta = moovAtoms.find((a) => a.type === 'udta');
|
|
if (!udta) return;
|
|
|
|
const udtaStart = moovStart + udta.offset + 8;
|
|
const udtaLen = udta.size - 8;
|
|
const udtaData = new DataView(view.buffer, udtaStart, udtaLen);
|
|
const udtaAtoms = parseMp4Atoms(udtaData);
|
|
|
|
const meta = udtaAtoms.find((a) => a.type === 'meta');
|
|
if (!meta) return;
|
|
|
|
const metaStart = udtaStart + meta.offset + 12;
|
|
const metaLen = meta.size - 12;
|
|
const metaData = new DataView(view.buffer, metaStart, metaLen);
|
|
const metaAtoms = parseMp4Atoms(metaData);
|
|
|
|
const ilst = metaAtoms.find((a) => a.type === 'ilst');
|
|
if (!ilst) return;
|
|
|
|
const ilstStart = metaStart + ilst.offset + 8;
|
|
const ilstLen = ilst.size - 8;
|
|
const ilstData = new DataView(view.buffer, ilstStart, ilstLen);
|
|
const items = parseMp4Atoms(ilstData);
|
|
|
|
let artistStr = null;
|
|
|
|
for (const item of items) {
|
|
const itemStart = ilstStart + item.offset + 8;
|
|
const itemLen = item.size - 8;
|
|
const itemData = new DataView(view.buffer, itemStart, itemLen);
|
|
const dataAtom = parseMp4Atoms(itemData).find((a) => a.type === 'data');
|
|
if (dataAtom) {
|
|
const contentLen = dataAtom.size - 16;
|
|
const contentOffset = itemStart + dataAtom.offset + 16;
|
|
|
|
if (item.type === '©nam') {
|
|
metadata.title = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
|
} else if (item.type === '©ART') {
|
|
artistStr = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
|
} else if (item.type === '©alb') {
|
|
metadata.album.title = new TextDecoder().decode(
|
|
new Uint8Array(view.buffer, contentOffset, contentLen)
|
|
);
|
|
} else if (item.type === 'ISRC') {
|
|
metadata.isrc = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
|
|
} else if (item.type === 'cprt') {
|
|
metadata.copyright = new TextDecoder().decode(
|
|
new Uint8Array(view.buffer, contentOffset, contentLen)
|
|
);
|
|
} else if (item.type === 'covr') {
|
|
const pictureData = new Uint8Array(view.buffer, contentOffset, contentLen);
|
|
const mime = getMimeType(pictureData);
|
|
const blob = new Blob([pictureData], { type: mime });
|
|
metadata.album.cover = URL.createObjectURL(blob);
|
|
} else if (item.type === 'rtng') {
|
|
metadata.explicit =
|
|
contentLen > 0 && new Uint8Array(view.buffer, contentOffset, contentLen)[0] === 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (artistStr) {
|
|
metadata.artists = artistStr.split(/; |\/|\\/).map((name) => ({ name: name.trim() }));
|
|
}
|
|
} catch (e) {
|
|
console.warn('Error parsing M4A:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds metadata to M4A files using MP4 atoms
|
|
*/
|
|
export async function addM4aMetadata(m4aBlob, track, api) {
|
|
try {
|
|
const arrayBuffer = await m4aBlob.arrayBuffer();
|
|
const dataView = new DataView(arrayBuffer);
|
|
|
|
// Parse MP4 atoms
|
|
const atoms = parseMp4Atoms(dataView);
|
|
|
|
// Create metadata atoms
|
|
const metadataAtoms = createMp4MetadataAtoms(track);
|
|
|
|
// Fetch album artwork if available
|
|
if (track.album?.cover) {
|
|
try {
|
|
const imageBlob = await getCoverBlob(api, track.album.cover);
|
|
if (imageBlob) {
|
|
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
|
|
metadataAtoms.cover = {
|
|
type: 'covr',
|
|
data: imageBytes,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to embed album art in M4A:', error);
|
|
}
|
|
}
|
|
|
|
// Rebuild MP4 file with metadata
|
|
const newMp4Data = rebuildMp4WithMetadata(dataView, atoms, metadataAtoms);
|
|
|
|
return new Blob([newMp4Data], { type: 'audio/mp4' });
|
|
} catch (error) {
|
|
console.error('Failed to add M4A metadata:', error);
|
|
return m4aBlob;
|
|
}
|
|
}
|
|
|
|
export function parseMp4Atoms(dataView) {
|
|
const atoms = [];
|
|
let offset = 0;
|
|
|
|
while (offset + 8 <= dataView.byteLength) {
|
|
// MP4 atoms use big-endian byte order
|
|
let size = dataView.getUint32(offset, false);
|
|
|
|
// Handle special size values
|
|
if (size === 0) {
|
|
// Size 0 means the atom extends to the end of the file
|
|
size = dataView.byteLength - offset;
|
|
} else if (size === 1) {
|
|
// Size 1 means 64-bit extended size follows (after the type field)
|
|
if (offset + 16 > dataView.byteLength) {
|
|
break;
|
|
}
|
|
// Read 64-bit size from offset+8 (big-endian)
|
|
const sizeHigh = dataView.getUint32(offset + 8, false);
|
|
const sizeLow = dataView.getUint32(offset + 12, false);
|
|
if (sizeHigh !== 0) {
|
|
console.warn('64-bit MP4 atoms larger than 4GB are not supported - file may be processed incompletely');
|
|
break;
|
|
}
|
|
size = sizeLow;
|
|
}
|
|
|
|
if (size < 8 || offset + size > dataView.byteLength) {
|
|
break;
|
|
}
|
|
|
|
const type = String.fromCharCode(
|
|
dataView.getUint8(offset + 4),
|
|
dataView.getUint8(offset + 5),
|
|
dataView.getUint8(offset + 6),
|
|
dataView.getUint8(offset + 7)
|
|
);
|
|
|
|
atoms.push({
|
|
type: type,
|
|
offset: offset,
|
|
size: size,
|
|
});
|
|
|
|
offset += size;
|
|
}
|
|
|
|
return atoms;
|
|
}
|
|
|
|
export function createMp4MetadataAtoms(track) {
|
|
// MP4 metadata atoms are more complex than FLAC
|
|
// We'll create basic iTunes-style metadata
|
|
|
|
/**
|
|
* Array of arrays: [namespace, name, value]
|
|
*/
|
|
const userTags = [];
|
|
const tags = {
|
|
'©nam': getTrackTitle(track) || DEFAULT_TITLE,
|
|
'©ART': getFullArtistString(track) || DEFAULT_ARTIST,
|
|
'©alb': track.album?.title || DEFAULT_ALBUM,
|
|
aART: track.album?.artist?.name || track.artist?.name || DEFAULT_ARTIST,
|
|
};
|
|
|
|
if (track.isrc) {
|
|
tags['ISRC'] = track.isrc;
|
|
tags['xid '] = ':isrc:' + track.isrc;
|
|
}
|
|
|
|
if (track.copyright) {
|
|
tags['cprt'] = track.copyright;
|
|
}
|
|
|
|
if (track.trackNumber) {
|
|
tags['trkn'] = {
|
|
current: track.trackNumber,
|
|
total: track.album?.numberOfTracks,
|
|
};
|
|
}
|
|
if (track.explicit) {
|
|
tags['rtng'] = 1; // 1 = Explicit, 2 = Clean, 0 = Unknown
|
|
}
|
|
|
|
const discNumber = track.volumeNumber ?? track.discNumber;
|
|
if (discNumber) {
|
|
tags['disk'] = {
|
|
current: discNumber,
|
|
total: 0,
|
|
};
|
|
}
|
|
|
|
if (track.bpm) {
|
|
tags['tmpo'] = Math.round(track.bpm);
|
|
}
|
|
|
|
const releaseDateStr =
|
|
track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
|
|
if (releaseDateStr) {
|
|
try {
|
|
const year = new Date(releaseDateStr).getFullYear();
|
|
if (!isNaN(year)) {
|
|
tags['©day'] = String(year);
|
|
}
|
|
} catch {
|
|
// Invalid date, skip
|
|
}
|
|
}
|
|
|
|
if (track.replayGain) {
|
|
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain;
|
|
let trackPeakAmplitudeString = String(trackPeakAmplitude);
|
|
let albumPeakAmplitudeString = String(albumPeakAmplitude);
|
|
|
|
if (trackPeakAmplitudeString.indexOf('.') === -1) {
|
|
trackPeakAmplitudeString += '.000000';
|
|
}
|
|
if (albumPeakAmplitudeString.indexOf('.') === -1) {
|
|
albumPeakAmplitudeString += '.000000';
|
|
}
|
|
|
|
if (trackPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_track_peak', trackPeakAmplitudeString]);
|
|
if (trackReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_track_gain', `${trackReplayGain} dB`]);
|
|
if (albumPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_album_peak', albumPeakAmplitudeString]);
|
|
if (albumReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_album_gain', `${albumReplayGain} dB`]);
|
|
}
|
|
|
|
return { tags, userTags };
|
|
}
|
|
|
|
export function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
|
|
const originalArray = new Uint8Array(dataView.buffer);
|
|
|
|
// Find moov atom
|
|
const moovAtom = atoms.find((a) => a.type === 'moov');
|
|
if (!moovAtom) {
|
|
console.warn('No moov atom found in M4A file');
|
|
return originalArray;
|
|
}
|
|
|
|
// Construct the new metadata block (udta -> meta -> ilst)
|
|
const newMetadataBytes = createMetadataBlock(metadataAtoms);
|
|
|
|
// We need to insert this into the moov atom.
|
|
// If udta exists, we merge/replace. For simplicity, we'll append/create.
|
|
// Ideally, we should parse moov children to find udta.
|
|
|
|
// 1. Calculate new sizes
|
|
// New file size = Original size + Metadata block size
|
|
// Note: If we are replacing existing metadata, this calculation would be different,
|
|
// but here we are assuming we are adding fresh or appending.
|
|
// A robust implementation would parse moov children, remove existing udta, and add new one.
|
|
|
|
// Let's try to do it right: parse moov children
|
|
const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8));
|
|
|
|
// Filter out existing udta to replace it
|
|
const filteredMoovChildren = moovChildren.filter((a) => a.type !== 'udta');
|
|
|
|
// Calculate new moov size
|
|
// Header (8) + Sum of other children sizes + New Metadata Block Size
|
|
let newMoovSize = 8;
|
|
for (const child of filteredMoovChildren) {
|
|
newMoovSize += child.size;
|
|
}
|
|
newMoovSize += newMetadataBytes.length;
|
|
|
|
const sizeDiff = newMoovSize - moovAtom.size;
|
|
const newFileSize = originalArray.length + sizeDiff;
|
|
|
|
const newFile = new Uint8Array(newFileSize);
|
|
let offset = 0;
|
|
let originalOffset = 0;
|
|
|
|
// Copy atoms before moov
|
|
const atomsBeforeMoov = atoms.filter((a) => a.offset < moovAtom.offset);
|
|
for (const atom of atomsBeforeMoov) {
|
|
newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset);
|
|
offset += atom.size;
|
|
originalOffset += atom.size;
|
|
}
|
|
|
|
// Write new moov atom
|
|
// Size
|
|
newFile[offset++] = (newMoovSize >> 24) & 0xff;
|
|
newFile[offset++] = (newMoovSize >> 16) & 0xff;
|
|
newFile[offset++] = (newMoovSize >> 8) & 0xff;
|
|
newFile[offset++] = newMoovSize & 0xff;
|
|
|
|
// Type 'moov'
|
|
newFile[offset++] = 0x6d;
|
|
newFile[offset++] = 0x6f;
|
|
newFile[offset++] = 0x6f;
|
|
newFile[offset++] = 0x76;
|
|
|
|
// Write preserved children of moov
|
|
for (const child of filteredMoovChildren) {
|
|
const absoluteChildStart = moovAtom.offset + 8 + child.offset;
|
|
newFile.set(originalArray.subarray(absoluteChildStart, absoluteChildStart + child.size), offset);
|
|
offset += child.size;
|
|
}
|
|
|
|
// Write new metadata block (udta)
|
|
newFile.set(newMetadataBytes, offset);
|
|
offset += newMetadataBytes.length;
|
|
|
|
// Update originalOffset to skip old moov
|
|
originalOffset = moovAtom.offset + moovAtom.size;
|
|
|
|
// Copy atoms after moov
|
|
// Adjust offsets in stco/co64 atoms if necessary?
|
|
// Changing the size of moov (or atoms before mdat) shifts the mdat offsets.
|
|
// If moov comes before mdat, we MUST update the Chunk Offset Atom (stco or co64).
|
|
// This is complex.
|
|
|
|
// Safe strategy: If moov is AFTER mdat, we don't need to update offsets.
|
|
// If moov is BEFORE mdat, we need to shift offsets.
|
|
// Most streaming optimized files have moov before mdat.
|
|
|
|
const mdatAtom = atoms.find((a) => a.type === 'mdat');
|
|
const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset;
|
|
|
|
if (moovBeforeMdat) {
|
|
// We need to update stco/co64 atoms inside the copied moov children content in newFile.
|
|
// This is getting very complicated for a simple "add metadata" feature without a proper library.
|
|
// However, we can try to find 'stco' or 'co64' in the new buffer we just wrote and offset values.
|
|
|
|
// Let's assume we need to shift by sizeDiff.
|
|
updateChunkOffsets(newFile, offset - newMoovSize, newMoovSize, sizeDiff);
|
|
}
|
|
|
|
// Copy remaining data (mdat etc.)
|
|
if (originalOffset < originalArray.length) {
|
|
newFile.set(originalArray.subarray(originalOffset), offset);
|
|
}
|
|
|
|
return newFile;
|
|
}
|
|
|
|
export function createMetadataBlock(metadataAtoms) {
|
|
const { tags, userTags, cover } = metadataAtoms;
|
|
|
|
const ilstChildren = [];
|
|
|
|
// Text tags
|
|
for (const [key, value] of Object.entries(tags)) {
|
|
if (key === 'trkn' || key === 'disk') {
|
|
ilstChildren.push(createIntAtom(key, value));
|
|
} else if (key === 'rtng') {
|
|
ilstChildren.push(createUintAtom(key, value, 1));
|
|
} else if (key === 'tmpo') {
|
|
ilstChildren.push(createUintAtom(key, value, 2));
|
|
} else {
|
|
ilstChildren.push(createStringAtom(key, value));
|
|
}
|
|
}
|
|
|
|
// User tags
|
|
for (const [namespace, name, value] of userTags) {
|
|
ilstChildren.push(createUserAtom(namespace, name, value));
|
|
}
|
|
|
|
// Cover art
|
|
if (cover) {
|
|
ilstChildren.push(createCoverAtom(cover.data));
|
|
}
|
|
|
|
// Construct ilst atom
|
|
const ilstSize = 8 + ilstChildren.reduce((acc, buf) => acc + buf.length, 0);
|
|
const ilst = new Uint8Array(ilstSize);
|
|
let offset = 0;
|
|
|
|
writeAtomHeader(ilst, offset, ilstSize, 'ilst');
|
|
offset += 8;
|
|
|
|
for (const child of ilstChildren) {
|
|
ilst.set(child, offset);
|
|
offset += child.length;
|
|
}
|
|
|
|
// Construct meta atom (FullAtom, version+flags = 4 bytes)
|
|
const metaSize = 12 + ilstSize;
|
|
const meta = new Uint8Array(metaSize);
|
|
offset = 0;
|
|
|
|
writeAtomHeader(meta, offset, metaSize, 'meta');
|
|
offset += 8;
|
|
|
|
meta[offset++] = 0; // Version
|
|
meta[offset++] = 0; // Flags
|
|
meta[offset++] = 0;
|
|
meta[offset++] = 0;
|
|
|
|
meta.set(ilst, offset);
|
|
|
|
// Construct hdlr atom (required for meta)
|
|
// "mdir" subtype, "appl" manufacturer, 0 flags/masks, empty name
|
|
// hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string)
|
|
// Minimal valid hdlr for iTunes metadata:
|
|
const hdlrContent = new Uint8Array([
|
|
0,
|
|
0,
|
|
0,
|
|
0, // Version/Flags
|
|
0,
|
|
0,
|
|
0,
|
|
0, // Pre-defined
|
|
0x6d,
|
|
0x64,
|
|
0x69,
|
|
0x72, // 'mdir'
|
|
0x61,
|
|
0x70,
|
|
0x70,
|
|
0x6c, // 'appl'
|
|
0,
|
|
0,
|
|
0,
|
|
0, // Reserved
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0, // Name (empty null-term) check spec? usually simple 0 is enough
|
|
]);
|
|
const hdlrSize = 8 + hdlrContent.length;
|
|
const hdlr = new Uint8Array(hdlrSize);
|
|
writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr');
|
|
hdlr.set(hdlrContent, 8);
|
|
|
|
// Construct udta atom
|
|
// udta contains meta. meta usually should contain hdlr before ilst?
|
|
// Actually, QuickTime spec says meta contains hdlr then ilst.
|
|
|
|
const finalMetaSize = 12 + hdlrSize + ilstSize;
|
|
const finalMeta = new Uint8Array(finalMetaSize);
|
|
offset = 0;
|
|
writeAtomHeader(finalMeta, offset, finalMetaSize, 'meta');
|
|
offset += 8;
|
|
finalMeta[offset++] = 0; // Version
|
|
finalMeta[offset++] = 0; // Flags
|
|
finalMeta[offset++] = 0;
|
|
finalMeta[offset++] = 0;
|
|
|
|
finalMeta.set(hdlr, offset);
|
|
offset += hdlrSize;
|
|
finalMeta.set(ilst, offset);
|
|
|
|
const udtaSize = 8 + finalMetaSize;
|
|
const udta = new Uint8Array(udtaSize);
|
|
writeAtomHeader(udta, 0, udtaSize, 'udta');
|
|
udta.set(finalMeta, 8);
|
|
|
|
return udta;
|
|
}
|
|
|
|
export function createStringAtom(type, value, truncateType = true) {
|
|
const typeLength = truncateType ? 4 : type.length;
|
|
const textBytes = new TextEncoder().encode(value);
|
|
const dataSize = 16 + textBytes.length; // 8 (data atom header) + 8 (flags/null) + text
|
|
const atomSize = 4 + typeLength + dataSize;
|
|
|
|
const buf = new Uint8Array(atomSize);
|
|
let offset = 0;
|
|
|
|
// Wrapper atom (e.g., ©nam)
|
|
writeAtomHeader(buf, offset, atomSize, type, truncateType);
|
|
offset += 4 + typeLength;
|
|
|
|
// Data atom
|
|
writeAtomHeader(buf, offset, dataSize, 'data');
|
|
offset += 8;
|
|
|
|
// Data Type (1 = UTF-8 text) + Locale (0)
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 1; // Type 1
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
|
|
buf.set(textBytes, offset);
|
|
|
|
return buf;
|
|
}
|
|
|
|
export function createUserAtom(namespace, name, value) {
|
|
const encoder = new TextEncoder();
|
|
const _dashBytes = encoder.encode('----'); // User-defined atom type
|
|
const namespaceBytes = encoder.encode(namespace);
|
|
const _meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace
|
|
const nameBytes = encoder.encode(name);
|
|
const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value);
|
|
|
|
/**
|
|
* Atom structure:
|
|
* [----] (atom header)
|
|
* [mean] (namespace)
|
|
* [name] (name)
|
|
* [data] (value)
|
|
*/
|
|
const atomSize = 8 + 12 + namespaceBytes.length + 12 + nameBytes.length + 8 + valueBytes.length;
|
|
|
|
const buf = new Uint8Array(atomSize);
|
|
let offset = 0;
|
|
writeAtomHeader(buf, offset, atomSize, '----');
|
|
offset += 8; // Skip header
|
|
writeAtomHeader(buf, offset, namespaceBytes.length + 12, 'mean');
|
|
offset += 12;
|
|
buf.set(namespaceBytes, offset);
|
|
offset += namespaceBytes.length;
|
|
writeAtomHeader(buf, offset, nameBytes.length + 12, 'name');
|
|
offset += 12;
|
|
buf.set(nameBytes, offset);
|
|
offset += nameBytes.length;
|
|
writeAtomHeader(buf, offset, valueBytes.length + 8, 'data');
|
|
offset += 8;
|
|
buf.set(valueBytes, offset);
|
|
|
|
return buf;
|
|
}
|
|
|
|
/**
|
|
* Converts a number or BigInt value to a big-endian byte array.
|
|
* @param {number|BigInt|null} value - The value to convert to bytes. If null, returns null.
|
|
* @param {number|null} [byteLength=null] - Optional fixed byte length. If provided, the result will be padded or truncated to this length. If not provided, returns the minimal byte representation.
|
|
* @returns {Uint8Array} A Uint8Array representing the value in big-endian format, or null if value is null.
|
|
* @throws {Error} If the value is a negative number.
|
|
* @example
|
|
* // Variable length (minimal bytes)
|
|
* toBigEndianBytes(256); // Uint8Array [ 1, 0 ]
|
|
* toBigEndianBytes(0); // Uint8Array [ 0 ]
|
|
*
|
|
* // Fixed length with padding
|
|
* toBigEndianBytes(1, 4); // Uint8Array [ 0, 0, 0, 1 ]
|
|
*
|
|
* // With BigInt
|
|
* toBigEndianBytes(0xDEADBEEFn, 4); // Uint8Array [ 222, 173, 190, 239 ]
|
|
*/
|
|
export function toBigEndianBytes(value, byteLength = null) {
|
|
if (value == null) return new Uint8Array(0);
|
|
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new Error('Value must be a non-negative safe integer.');
|
|
}
|
|
|
|
// Fixed-length mode
|
|
if (byteLength != null) {
|
|
const bytes = new Uint8Array(byteLength);
|
|
for (let i = byteLength - 1; i >= 0; i--) {
|
|
bytes[i] = value & 0xff;
|
|
value = Math.floor(value / 256);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
// Variable (minimal) mode
|
|
if (value === 0) return new Uint8Array([0]);
|
|
|
|
const result = [];
|
|
while (value > 0) {
|
|
result.push(value & 0xff);
|
|
value = Math.floor(value / 256);
|
|
}
|
|
|
|
result.reverse();
|
|
|
|
return new Uint8Array(result);
|
|
}
|
|
|
|
export function createUintAtom(key, value, intByteLength = 1) {
|
|
const numberBytes = toBigEndianBytes(value, intByteLength);
|
|
const dataSize = 16 + intByteLength; // Atom header (8) + number bytes
|
|
const atomSize = 8 + dataSize;
|
|
|
|
const buf = new Uint8Array(atomSize);
|
|
let offset = 0;
|
|
|
|
// Wrapper atom (e.g., ©nam)
|
|
writeAtomHeader(buf, offset, atomSize, key);
|
|
offset += 8;
|
|
|
|
// Data atom
|
|
writeAtomHeader(buf, offset, dataSize, 'data');
|
|
offset += 8;
|
|
|
|
// Data Type ((Big Endian Unsigned Integer) + Locale (0))
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 21; // Type 21
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf.set(numberBytes, offset++);
|
|
|
|
return buf;
|
|
}
|
|
|
|
export function createIntAtom(type, value) {
|
|
// trkn/disk are special: data is 8 bytes.
|
|
// reserved(2) + track(2) + total(2) + reserved(2)
|
|
const dataSize = 16 + 8;
|
|
const atomSize = 8 + dataSize;
|
|
|
|
const buf = new Uint8Array(atomSize);
|
|
let offset = 0;
|
|
|
|
writeAtomHeader(buf, offset, atomSize, type);
|
|
offset += 8;
|
|
|
|
writeAtomHeader(buf, offset, dataSize, 'data');
|
|
offset += 8;
|
|
|
|
// Data Type (0 = implicit/int) + Locale
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0; // Type 0
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
|
|
const current = typeof value === 'object' ? value.current : value;
|
|
const total = typeof value === 'object' ? value.total : 0;
|
|
|
|
// Numbering payload (track/disc number + total)
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
const numberValue = parseInt(current, 10) || 0;
|
|
buf[offset++] = (numberValue >> 8) & 0xff;
|
|
buf[offset++] = numberValue & 0xff;
|
|
const totalValue = parseInt(total, 10) || 0;
|
|
buf[offset++] = (totalValue >> 8) & 0xff;
|
|
buf[offset++] = totalValue & 0xff;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
|
|
return buf;
|
|
}
|
|
|
|
export function createCoverAtom(imageBytes) {
|
|
const dataSize = 16 + imageBytes.length;
|
|
const atomSize = 8 + dataSize;
|
|
|
|
const buf = new Uint8Array(atomSize);
|
|
let offset = 0;
|
|
|
|
writeAtomHeader(buf, offset, atomSize, 'covr');
|
|
offset += 8;
|
|
|
|
writeAtomHeader(buf, offset, dataSize, 'data');
|
|
offset += 8;
|
|
|
|
// Data Type (13 = JPEG, 14 = PNG)
|
|
// We try to detect or default to JPEG (13)
|
|
let type = 13;
|
|
if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) {
|
|
// PNG signature
|
|
type = 14;
|
|
}
|
|
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = type;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
buf[offset++] = 0;
|
|
|
|
buf.set(imageBytes, offset);
|
|
|
|
return buf;
|
|
}
|
|
|
|
/**
|
|
* Creates an atom header for MP4 metadata.
|
|
* @param {number} size - The size of the atom in bytes.
|
|
* @param {string} type - The 4-character atom type identifier.
|
|
* @param {boolean} [truncate=false] - Whether to truncate the type to 4 characters or use full length.
|
|
* @returns {Uint8Array} A byte array containing the atom header with size and type information.
|
|
*/
|
|
export function getAtomHeader(size, type, truncate = false) {
|
|
const buf = new Uint8Array(4 + (truncate ? 4 : type.length));
|
|
buf[0] = (size >> 24) & 0xff;
|
|
buf[1] = (size >> 16) & 0xff;
|
|
buf[2] = (size >> 8) & 0xff;
|
|
buf[3] = size & 0xff;
|
|
|
|
for (let i = 0; i < (truncate ? 4 : type.length); i++) {
|
|
buf[4 + i] = type.charCodeAt(i);
|
|
}
|
|
|
|
return buf;
|
|
}
|
|
|
|
/**
|
|
* Writes an atom header to a buffer at the specified offset.
|
|
* @param {Uint8Array} buf - The buffer to write the atom header to.
|
|
* @param {number} offset - The offset in the buffer where the atom header should be written.
|
|
* @param {number} size - The size of the atom.
|
|
* @param {string} type - The type of the atom (typically a 4-character code).
|
|
* @param {boolean} [truncate=true] - Whether to truncate the atom header. Defaults to true.
|
|
* @returns {void}
|
|
*/
|
|
export function writeAtomHeader(buf, offset, size, type, truncate = true) {
|
|
buf.set(getAtomHeader(size, type, truncate), offset);
|
|
}
|
|
|
|
export function updateChunkOffsets(buffer, moovOffset, moovSize, shift) {
|
|
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
|
|
// Scan moov for stco/co64
|
|
// This is a naive recursive search restricted to the known moov range
|
|
|
|
// We parse atoms starting from moov content
|
|
let offset = moovOffset + 8; // Skip moov header
|
|
const end = moovOffset + moovSize;
|
|
|
|
findAndShiftOffsets(view, offset, end, shift);
|
|
}
|
|
|
|
export function findAndShiftOffsets(view, start, end, shift) {
|
|
let offset = start;
|
|
|
|
while (offset + 8 <= end) {
|
|
const size = view.getUint32(offset, false);
|
|
const type = String.fromCharCode(
|
|
view.getUint8(offset + 4),
|
|
view.getUint8(offset + 5),
|
|
view.getUint8(offset + 6),
|
|
view.getUint8(offset + 7)
|
|
);
|
|
|
|
if (size < 8) break;
|
|
|
|
if (type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') {
|
|
// Container atoms, recurse
|
|
findAndShiftOffsets(view, offset + 8, offset + size, shift);
|
|
} else if (type === 'stco') {
|
|
// Chunk Offset Atom (32-bit)
|
|
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4)
|
|
const count = view.getUint32(offset + 12, false);
|
|
for (let i = 0; i < count; i++) {
|
|
const entryOffset = offset + 16 + i * 4;
|
|
const oldVal = view.getUint32(entryOffset, false);
|
|
view.setUint32(entryOffset, oldVal + shift, false);
|
|
}
|
|
} else if (type === 'co64') {
|
|
// Chunk Offset Atom (64-bit)
|
|
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8)
|
|
const count = view.getUint32(offset + 12, false);
|
|
for (let i = 0; i < count; i++) {
|
|
const entryOffset = offset + 16 + i * 8;
|
|
// Read 64-bit int
|
|
const oldHigh = view.getUint32(entryOffset, false);
|
|
const oldLow = view.getUint32(entryOffset + 4, false);
|
|
|
|
// Add shift (assuming shift is small enough not to overflow low 32 in a way that affects high simply?)
|
|
// Shift is Javascript number (double), up to 9007199254740991.
|
|
// 32-bit uint max is 4294967295.
|
|
|
|
// Proper 64-bit addition
|
|
// Construct BigInt
|
|
// Note: BigInt might not be available in all older environments, but modern browsers support it.
|
|
// Fallback: simpler logic
|
|
|
|
let newLow = oldLow + shift;
|
|
let carry = 0;
|
|
if (newLow > 0xffffffff) {
|
|
carry = Math.floor(newLow / 0x100000000);
|
|
newLow = newLow >>> 0;
|
|
}
|
|
const newHigh = oldHigh + carry;
|
|
|
|
view.setUint32(entryOffset, newHigh, false);
|
|
view.setUint32(entryOffset + 4, newLow, false);
|
|
}
|
|
}
|
|
|
|
offset += size;
|
|
}
|
|
}
|