diff --git a/dev/convert.html b/dev/convert.html
index 2b0e378..53d7903 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -24,7 +24,7 @@
chunked: true,
chunkSize: 2**20
});
- const outputFormat = new Mediabunny.MkvOutputFormat({});
+ const outputFormat = new Mediabunny.OggOutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -61,7 +61,7 @@
discNumberMax: 8,
lyrics: "There's no way\nThat it's not going there",
raw: {
- //'TBRU': 'this is a BRUH metadatum',
+ 'ENCODER': 'Mediabunny epic own encoder'
}
});
diff --git a/src/input-track.ts b/src/input-track.ts
index b093b0d..7f3e56e 100644
--- a/src/input-track.ts
+++ b/src/input-track.ts
@@ -258,7 +258,8 @@ export class InputVideoTrack extends InputTrack {
/**
* Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#video-decoder-config) for decoding the
- * track's packets using a VideoDecoder. Returns null if the track's codec is unknown.
+ * track's packets using a [`VideoDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder). Returns
+ * null if the track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
@@ -354,7 +355,8 @@ export class InputAudioTrack extends InputTrack {
/**
* Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for decoding the
- * track's packets using an AudioDecoder. Returns null if the track's codec is unknown.
+ * track's packets using an [`AudioDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/AudioDecoder). Returns
+ * null if the track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts
index f42e92e..823ceda 100644
--- a/src/isobmff/isobmff-boxes.ts
+++ b/src/isobmff/isobmff-boxes.ts
@@ -1322,7 +1322,7 @@ const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => {
}; break;
case 'date': {
- boxes.push(metadataStringBoxShort('©day', value.toISOString().split('T')[0]!));
+ boxes.push(metadataStringBoxShort('©day', value.toISOString().slice(0, 10)));
}; break;
case 'comment': {
@@ -1423,7 +1423,7 @@ const meta = (metadata: MediaMetadata) => {
}; break;
case 'date': {
- boxes.push(metadataStringBoxLong('©day', value.toISOString().split('T')[0]!));
+ boxes.push(metadataStringBoxLong('©day', value.toISOString().slice(0, 10)));
}; break;
case 'images': {
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index aa6e5ba..431bd30 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -2060,9 +2060,9 @@ export class IsobmffDemuxer extends Demuxer {
this.metadata.raw ??= {};
if (UDTA_STRING_KEYS.has(boxInfo.name)) {
- this.metadata.raw[boxInfo.name] = readMetadataStringShort(slice);
+ this.metadata.raw[boxInfo.name] ??= readMetadataStringShort(slice);
} else {
- this.metadata.raw[boxInfo.name] = readBytes(slice, boxInfo.contentSize);
+ this.metadata.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize);
}
slice.filePos = startPos;
@@ -2079,37 +2079,37 @@ export class IsobmffDemuxer extends Demuxer {
if (this.currentTrack) {
this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize));
} else {
- this.metadata.title = readMetadataStringShort(slice);
+ this.metadata.title ??= readMetadataStringShort(slice);
}
}; break;
case '©des': {
if (!this.currentTrack) {
- this.metadata.description = readMetadataStringShort(slice);
+ this.metadata.description ??= readMetadataStringShort(slice);
}
}; break;
case '©ART': {
if (!this.currentTrack) {
- this.metadata.artist = readMetadataStringShort(slice);
+ this.metadata.artist ??= readMetadataStringShort(slice);
}
}; break;
case '©alb': {
if (!this.currentTrack) {
- this.metadata.album = readMetadataStringShort(slice);
+ this.metadata.album ??= readMetadataStringShort(slice);
}
}; break;
case 'albr': {
if (!this.currentTrack) {
- this.metadata.albumArtist = readMetadataStringShort(slice);
+ this.metadata.albumArtist ??= readMetadataStringShort(slice);
}
}; break;
case '©gen': {
if (!this.currentTrack) {
- this.metadata.genre = readMetadataStringShort(slice);
+ this.metadata.genre ??= readMetadataStringShort(slice);
}
}; break;
@@ -2117,20 +2117,20 @@ export class IsobmffDemuxer extends Demuxer {
if (!this.currentTrack) {
const date = new Date(readMetadataStringShort(slice));
if (!Number.isNaN(date.getTime())) {
- this.metadata.date = date;
+ this.metadata.date ??= date;
}
}
}; break;
case '©cmt': {
if (!this.currentTrack) {
- this.metadata.comment = readMetadataStringShort(slice);
+ this.metadata.comment ??= readMetadataStringShort(slice);
}
}; break;
case '©lyr': {
if (!this.currentTrack) {
- this.metadata.lyrics = readMetadataStringShort(slice);
+ this.metadata.lyrics ??= readMetadataStringShort(slice);
}
}; break;
}
@@ -2200,7 +2200,7 @@ export class IsobmffDemuxer extends Demuxer {
const data = readDataBox(slice);
this.metadata.raw ??= {};
- this.metadata.raw[metadataKey] = data;
+ this.metadata.raw[metadataKey] ??= data;
switch (metadataKey) {
case '©nam':
@@ -2208,7 +2208,7 @@ export class IsobmffDemuxer extends Demuxer {
case 'com.apple.quicktime.title':
case 'title': {
if (typeof data === 'string') {
- this.metadata.title = data;
+ this.metadata.title ??= data;
}
}; break;
@@ -2218,7 +2218,7 @@ export class IsobmffDemuxer extends Demuxer {
case 'com.apple.quicktime.description':
case 'description': {
if (typeof data === 'string') {
- this.metadata.description = data;
+ this.metadata.description ??= data;
}
}; break;
@@ -2226,7 +2226,7 @@ export class IsobmffDemuxer extends Demuxer {
case 'com.apple.quicktime.artist':
case 'artist': {
if (typeof data === 'string') {
- this.metadata.artist = data;
+ this.metadata.artist ??= data;
}
}; break;
@@ -2235,14 +2235,14 @@ export class IsobmffDemuxer extends Demuxer {
case 'com.apple.quicktime.album':
case 'album': {
if (typeof data === 'string') {
- this.metadata.album = data;
+ this.metadata.album ??= data;
}
}; break;
case 'aART':
case 'album_artist': {
if (typeof data === 'string') {
- this.metadata.albumArtist = data;
+ this.metadata.albumArtist ??= data;
}
}; break;
@@ -2250,7 +2250,7 @@ export class IsobmffDemuxer extends Demuxer {
case 'com.apple.quicktime.comment':
case 'comment': {
if (typeof data === 'string') {
- this.metadata.comment = data;
+ this.metadata.comment ??= data;
}
}; break;
@@ -2259,14 +2259,14 @@ export class IsobmffDemuxer extends Demuxer {
case 'com.apple.quicktime.genre':
case 'genre': {
if (typeof data === 'string') {
- this.metadata.genre = data;
+ this.metadata.genre ??= data;
}
}; break;
case '©lyr':
case 'lyrics': {
if (typeof data === 'string') {
- this.metadata.lyrics = data;
+ this.metadata.lyrics ??= data;
}
}; break;
@@ -2277,7 +2277,7 @@ export class IsobmffDemuxer extends Demuxer {
if (typeof data === 'string') {
const date = new Date(data);
if (!Number.isNaN(date.getTime())) {
- this.metadata.date = date;
+ this.metadata.date ??= date;
}
}
}; break;
@@ -2309,10 +2309,10 @@ export class IsobmffDemuxer extends Demuxer {
const trackNumberMax = view.getUint16(4, false);
if (trackNumber > 0) {
- this.metadata.trackNumber = trackNumber;
+ this.metadata.trackNumber ??= trackNumber;
}
if (trackNumberMax > 0) {
- this.metadata.trackNumberMax = trackNumberMax;
+ this.metadata.trackNumberMax ??= trackNumberMax;
}
}
}; break;
@@ -2326,10 +2326,10 @@ export class IsobmffDemuxer extends Demuxer {
const discNumberMax = view.getUint16(4, false);
if (discNumber > 0) {
- this.metadata.discNumber = discNumber;
+ this.metadata.discNumber ??= discNumber;
}
if (discNumberMax > 0) {
- this.metadata.discNumberMax = discNumberMax;
+ this.metadata.discNumberMax ??= discNumberMax;
}
}
}; break;
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index 4dfab32..9db5834 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -1473,7 +1473,7 @@ export class MatroskaDemuxer extends Demuxer {
const metadata = this.currentSegment.metadata;
metadata.raw ??= {};
- metadata.raw[name] = value;
+ metadata.raw[name] ??= value;
if (typeof value === 'string') {
switch (name.toLowerCase()) {
diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts
index f6fa690..60220f2 100644
--- a/src/matroska/matroska-muxer.ts
+++ b/src/matroska/matroska-muxer.ts
@@ -65,7 +65,7 @@ import { parseOpusIdentificationHeader } from '../codec-data';
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
-const APP_NAME = 'https://github.com/Vanilagy/mediabunny';
+const APP_NAME = 'Mediabunny';
const SEGMENT_SIZE_BYTES = 6;
const CLUSTER_SIZE_BYTES = 5;
@@ -470,7 +470,7 @@ export class MatroskaMuxer extends Muxer {
}; break;
case 'date': {
- addSimpleTag('DATE', value.toISOString().split('T')[0]!);
+ addSimpleTag('DATE', value.toISOString().slice(0, 10));
writtenTags.add('DATE');
}; break;
diff --git a/src/misc.ts b/src/misc.ts
index fcdd4ca..3126f03 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -702,3 +702,24 @@ export const imageMimeTypeToExtension = (mimeType: string) => {
return null;
}
};
+
+export const base64ToBytes = (base64: string) => {
+ const decoded = atob(base64);
+ const bytes = new Uint8Array(decoded.length);
+
+ for (let i = 0; i < decoded.length; i++) {
+ bytes[i] = decoded.charCodeAt(i);
+ }
+
+ return bytes;
+};
+
+export const bytesToBase64 = (bytes: Uint8Array) => {
+ let string = '';
+
+ for (let i = 0; i < bytes.length; i++) {
+ string += String.fromCharCode(bytes[i]!);
+ }
+
+ return btoa(string);
+};
diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts
index 03870a9..5589a49 100644
--- a/src/mp3/mp3-muxer.ts
+++ b/src/mp3/mp3-muxer.ts
@@ -183,7 +183,7 @@ export class Mp3Muxer extends Muxer {
}; break;
case 'date': {
- this.mp3Writer.writeId3V2TextFrame('TDRC', value.toISOString().split('.')[0]!);
+ this.mp3Writer.writeId3V2TextFrame('TDRC', value.toISOString().slice(0, 10));
writtenTags.add('TDRC');
}; break;
diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts
index 5d581f3..9a6757b 100644
--- a/src/mp3/mp3-reader.ts
+++ b/src/mp3/mp3-reader.ts
@@ -92,7 +92,7 @@ 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
+ 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);
@@ -242,10 +242,10 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M
metadata.raw ??= {};
if (frame.id[0] === 'T') {
// It's a text frame, let's decode as text
- metadata.raw[frame.id] = reader.readId3V2EncodingAndText(frameEndPos);
+ 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);
+ metadata.raw[frame.id] ??= reader.readBytes(frame.size);
}
reader.pos = frameStartPos;
diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index 2632e49..4fb4c54 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -12,13 +12,16 @@ import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
+import { MediaMetadata } from '../metadata';
import {
assert,
AsyncMutex,
+ base64ToBytes,
binarySearchLessOrEqual,
findLast,
last,
roundToPrecision,
+ textDecoder,
toDataView,
UNDETERMINED_LANGUAGE,
} from '../misc';
@@ -57,6 +60,7 @@ export class OggDemuxer extends Demuxer {
metadataPromise: Promise | null = null;
bitstreams: LogicalBitstream[] = [];
tracks: InputAudioTrack[] = [];
+ metadata: MediaMetadata = {};
constructor(input: Input) {
super(input);
@@ -218,6 +222,8 @@ export class OggDemuxer extends Demuxer {
],
modeBlockflags: parseModesFromVorbisSetupPacket(thirdPacket.data).modeBlockflags,
};
+
+ this.readVorbisComments(secondPacket.data.subarray(7)); // Skip header type and 'vorbis'
}
async readOpusMetadata(firstPacket: Packet, bitstream: LogicalBitstream) {
@@ -237,8 +243,6 @@ export class OggDemuxer extends Demuxer {
return;
}
- // We don't make use of the comment header's data
-
bitstream.codecInfo.codec = 'opus';
bitstream.description = firstPacket.data;
bitstream.lastMetadataPacket = secondPacket;
@@ -250,6 +254,164 @@ export class OggDemuxer extends Demuxer {
bitstream.codecInfo.opusInfo = {
preSkip: header.preSkip,
};
+
+ this.readVorbisComments(secondPacket.data.subarray(8)); // Skip 'OpusTags'
+ }
+
+ readVorbisComments(bytes: Uint8Array) {
+ // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2
+
+ const commentView = toDataView(bytes);
+ let commentPos = 0;
+
+ const vendorStringLength = commentView.getUint32(commentPos, true);
+ commentPos += 4;
+
+ const vendorString = textDecoder.decode(
+ bytes.subarray(commentPos, commentPos + vendorStringLength),
+ );
+ commentPos += vendorStringLength;
+
+ if (vendorStringLength > 0) {
+ // Expose the vendor string in the raw metadata
+ this.metadata.raw ??= {};
+ this.metadata.raw['vendor'] ??= vendorString;
+ }
+
+ const listLength = commentView.getUint32(commentPos, true);
+ commentPos += 4;
+
+ // Loop over all metadata tags
+ for (let i = 0; i < listLength; i++) {
+ const stringLength = commentView.getUint32(commentPos, true);
+ commentPos += 4;
+
+ const string = textDecoder.decode(
+ bytes.subarray(commentPos, commentPos + stringLength),
+ );
+ commentPos += stringLength;
+
+ const separatorIndex = string.indexOf('=');
+ if (separatorIndex === -1) {
+ continue;
+ }
+
+ const key = string.slice(0, separatorIndex).toUpperCase();
+ const value = string.slice(separatorIndex + 1);
+
+ this.metadata.raw ??= {};
+ this.metadata.raw[key] ??= value;
+
+ switch (key) {
+ case 'TITLE': {
+ this.metadata.title ??= value;
+ }; break;
+
+ case 'DESCRIPTION': {
+ this.metadata.description ??= value;
+ }; break;
+
+ case 'ARTIST': {
+ this.metadata.artist ??= value;
+ }; break;
+
+ case 'ALBUM': {
+ this.metadata.album ??= value;
+ }; break;
+
+ case 'ALBUMARTIST': {
+ this.metadata.albumArtist ??= value;
+ }; break;
+
+ case 'COMMENT': {
+ this.metadata.comment ??= value;
+ }; break;
+
+ case 'LYRICS': {
+ this.metadata.lyrics ??= value;
+ }; break;
+
+ case 'TRACKNUMBER': {
+ const parts = value.split('/');
+ const trackNum = Number.parseInt(parts[0]!, 10);
+ const trackNumMax = parts[1] && Number.parseInt(parts[1], 10);
+
+ if (Number.isInteger(trackNum) && trackNum > 0) {
+ this.metadata.trackNumber ??= trackNum;
+ }
+ if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) {
+ this.metadata.trackNumberMax ??= trackNumMax;
+ }
+ }; break;
+
+ case 'TRACKTOTAL': {
+ const trackNumMax = Number.parseInt(value, 10);
+ if (Number.isInteger(trackNumMax) && trackNumMax > 0) {
+ this.metadata.trackNumberMax ??= trackNumMax;
+ }
+ }; break;
+
+ case 'DISCNUMBER': {
+ const parts = value.split('/');
+ const discNum = Number.parseInt(parts[0]!, 10);
+ const discNumMax = parts[1] && Number.parseInt(parts[1], 10);
+
+ if (Number.isInteger(discNum) && discNum > 0) {
+ this.metadata.discNumber ??= discNum;
+ }
+ if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) {
+ this.metadata.discNumberMax ??= discNumMax;
+ }
+ }; break;
+
+ case 'DISCTOTAL': {
+ const trackNumMax = Number.parseInt(value, 10);
+ if (Number.isInteger(trackNumMax) && trackNumMax > 0) {
+ this.metadata.trackNumberMax ??= trackNumMax;
+ }
+ }; break;
+
+ case 'DATE': {
+ const date = new Date(value);
+ if (!Number.isNaN(date.getTime())) {
+ this.metadata.date ??= date;
+ }
+ }; break;
+
+ case 'GENRE': {
+ this.metadata.genre ??= value;
+ }; break;
+
+ case 'METADATA_BLOCK_PICTURE': {
+ // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8
+ const decoded = base64ToBytes(value);
+
+ const view = toDataView(decoded);
+ const pictureType = view.getUint32(0, false);
+ const mediaTypeLength = view.getUint32(4, false);
+ const mediaType = String.fromCharCode(...decoded.subarray(8, 8 + mediaTypeLength)); // ASCII
+ const descriptionLength = view.getUint32(8 + mediaTypeLength, false);
+ const description = textDecoder.decode(decoded.subarray(
+ 12 + mediaTypeLength,
+ 12 + mediaTypeLength + descriptionLength,
+ ));
+ const dataLength = view.getUint32(mediaTypeLength + descriptionLength + 28);
+ const data = decoded.subarray(
+ mediaTypeLength + descriptionLength + 32,
+ mediaTypeLength + descriptionLength + 32 + dataLength,
+ );
+
+ this.metadata.images ??= [];
+ this.metadata.images.push({
+ data,
+ mimeType: mediaType,
+ kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown',
+ name: undefined,
+ description: description || undefined,
+ });
+ }; break;
+ }
+ }
}
async readPacket(startPage: Page, startSegmentIndex: number): Promise {
@@ -388,6 +550,11 @@ export class OggDemuxer extends Demuxer {
const trackDurations = await Promise.all(tracks.map(x => x.computeDuration()));
return Math.max(0, ...trackDurations);
}
+
+ async getMetadata() {
+ await this.readMetadata();
+ return this.metadata;
+ }
}
type EncodedPacketMetadata = {
diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts
index 1b18333..12a8a06 100644
--- a/src/ogg/ogg-muxer.ts
+++ b/src/ogg/ogg-muxer.ts
@@ -8,9 +8,20 @@
import { OPUS_INTERNAL_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec';
import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data';
-import { assert, promiseWithResolvers, setInt64, toDataView, toUint8Array } from '../misc';
+import {
+ assert,
+ assertNever,
+ bytesToBase64,
+ keyValueIterator,
+ promiseWithResolvers,
+ setInt64,
+ textEncoder,
+ toDataView,
+ toUint8Array,
+} from '../misc';
import { Muxer } from '../muxer';
-import { Output, OutputAudioTrack } from '../output';
+import { Output,
+ OutputAudioTrack } from '../output';
import { OggOutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
@@ -176,9 +187,20 @@ export class OggMuxer extends Muxer {
}
const identificationHeader = bytes.subarray(pos, pos += identificationHeaderLength);
- const commentHeader = bytes.subarray(pos, pos += commentHeaderLength);
+ pos += commentHeaderLength; // Skip the comment header, we'll build our own
const setupHeader = bytes.subarray(pos);
+ const commentHeaderHeader = new Uint8Array(7);
+ commentHeaderHeader[0] = 3; // Packet type
+ commentHeaderHeader[1] = 0x76; // 'v'
+ commentHeaderHeader[2] = 0x6f; // 'o'
+ commentHeaderHeader[3] = 0x72; // 'r'
+ commentHeaderHeader[4] = 0x62; // 'b'
+ commentHeaderHeader[5] = 0x69; // 'i'
+ commentHeaderHeader[6] = 0x73; // 's'
+
+ const commentHeader = this.createVorbisComments(commentHeaderHeader);
+
trackData.packetQueue.push({
data: identificationHeader,
endGranulePosition: 0,
@@ -213,12 +235,11 @@ export class OggMuxer extends Muxer {
const identificationHeader = toUint8Array(meta.decoderConfig.description);
- const commentHeader = new Uint8Array(8 + 4 + 4);
- const view = new DataView(commentHeader.buffer);
- view.setUint32(0, 0x4f707573, false); // 'Opus'
- view.setUint32(4, 0x54616773, false); // 'Tags'
- view.setUint32(8, 0, true); // Vendor String Length
- view.setUint32(12, 0, true); // User Comment List Length
+ const commentHeaderHeader = new Uint8Array(8);
+ const commentHeaderHeaderView = toDataView(commentHeaderHeader);
+ commentHeaderHeaderView.setUint32(0, 0x4f707573, false); // 'Opus'
+ commentHeaderHeaderView.setUint32(4, 0x54616773, false); // 'Tags'
+ const commentHeader = this.createVorbisComments(commentHeaderHeader);
trackData.packetQueue.push({
data: identificationHeader,
@@ -238,6 +259,177 @@ export class OggMuxer extends Muxer {
}
}
+ createVorbisComments(headerBytes: Uint8Array) {
+ // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2
+
+ const metadata = this.output._metadata;
+ const commentHeaderParts = [
+ headerBytes,
+ ];
+
+ let vendorString = '';
+ if (typeof metadata.raw?.['vendor'] === 'string') {
+ vendorString = metadata.raw?.['vendor'];
+ }
+ const encodedVendorString = textEncoder.encode(vendorString);
+
+ let currentBuffer = new Uint8Array(4 + encodedVendorString.length);
+ let currentView = new DataView(currentBuffer.buffer);
+ currentView.setUint32(0, encodedVendorString.length, true);
+ currentBuffer.set(encodedVendorString, 4);
+
+ commentHeaderParts.push(currentBuffer);
+
+ const writtenTags = new Set();
+ const addCommentTag = (key: string, value: string) => {
+ const joined = `${key}=${value}`;
+ const encoded = textEncoder.encode(joined);
+
+ currentBuffer = new Uint8Array(4 + encoded.length);
+ currentView = new DataView(currentBuffer.buffer);
+
+ currentView.setUint32(0, encoded.length, true);
+ currentBuffer.set(encoded, 4);
+
+ commentHeaderParts.push(currentBuffer);
+ writtenTags.add(key);
+ };
+
+ for (const { key, value } of keyValueIterator(metadata)) {
+ switch (key) {
+ case 'title': {
+ addCommentTag('TITLE', value);
+ }; break;
+
+ case 'description': {
+ addCommentTag('DESCRIPTION', value);
+ }; break;
+
+ case 'artist': {
+ addCommentTag('ARTIST', value);
+ }; break;
+
+ case 'album': {
+ addCommentTag('ALBUM', value);
+ }; break;
+
+ case 'albumArtist': {
+ addCommentTag('ALBUMARTIST', value);
+ }; break;
+
+ case 'genre': {
+ addCommentTag('GENRE', value);
+ }; break;
+
+ case 'date': {
+ addCommentTag('DATE', value.toISOString().slice(0, 10));
+ }; break;
+
+ case 'comment': {
+ addCommentTag('COMMENT', value);
+ }; break;
+
+ case 'lyrics': {
+ addCommentTag('LYRICS', value);
+ }; break;
+
+ case 'discNumber': {
+ addCommentTag('TRACKNUMBER', value.toString());
+ }; break;
+
+ case 'discNumberMax': {
+ addCommentTag('TRACKTOTAL', value.toString());
+ }; break;
+
+ case 'trackNumber': {
+ addCommentTag('DISCNUMBER', value.toString());
+ }; break;
+
+ case 'trackNumberMax': {
+ addCommentTag('DISCTOTAL', value.toString());
+ }; break;
+
+ case 'images': {
+ for (const image of value) {
+ // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8
+ const pictureType = image.kind === 'coverFront' ? 3 : image.kind === 'coverBack' ? 4 : 0;
+ const encodedMediaType = new Uint8Array(image.mimeType.length);
+
+ for (let i = 0; i < image.mimeType.length; i++) {
+ encodedMediaType[i] = image.mimeType.charCodeAt(i);
+ }
+
+ const encodedDescription = textEncoder.encode(image.description ?? '');
+
+ const buffer = new Uint8Array(
+ 4 // Picture type
+ + 4 // MIME type length
+ + encodedMediaType.length // MIME type
+ + 4 // Description length
+ + encodedDescription.length // Description
+ + 16 // Width, height, color depth, number of colors
+ + 4 // Picture data length
+ + image.data.length, // Picture data
+ );
+ const view = toDataView(buffer);
+
+ view.setUint32(0, pictureType, false);
+ view.setUint32(4, encodedMediaType.length, false);
+ buffer.set(encodedMediaType, 8);
+ view.setUint32(8 + encodedMediaType.length, encodedDescription.length, false);
+ buffer.set(encodedDescription, 12 + encodedMediaType.length);
+ // Skip a bunch of fields (width, height, color depth, number of colors)
+ view.setUint32(
+ 28 + encodedMediaType.length + encodedDescription.length, image.data.length, false,
+ );
+ buffer.set(
+ image.data,
+ 32 + encodedMediaType.length + encodedDescription.length,
+ );
+
+ const encoded = bytesToBase64(buffer);
+ addCommentTag('METADATA_BLOCK_PICTURE', encoded);
+ }
+ }; break;
+
+ case 'raw': {
+ // Handled later
+ }; break;
+
+ default: assertNever(key);
+ }
+ }
+
+ if (metadata.raw) {
+ for (const key in metadata.raw) {
+ const value = metadata.raw[key];
+ if (key === 'vendor' || value == null || writtenTags.has(key)) {
+ continue;
+ }
+
+ if (typeof value === 'string') {
+ addCommentTag(key, value);
+ }
+ }
+ }
+
+ const listLengthBuffer = new Uint8Array(4);
+ toDataView(listLengthBuffer).setUint32(0, writtenTags.size, true);
+ commentHeaderParts.splice(2, 0, listLengthBuffer); // Insert after the header and vendor section
+
+ // Merge all comment header parts into a single buffer
+ const commentHeaderLength = commentHeaderParts.reduce((a, b) => a + b.length, 0);
+ const commentHeader = new Uint8Array(commentHeaderLength);
+
+ let pos = 0;
+ for (const part of commentHeaderParts) {
+ commentHeader.set(part, pos);
+ pos += part.length;
+ }
+
+ return commentHeader;
+ }
+
async addEncodedAudioPacket(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) {
const release = await this.mutex.acquire();