mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-30 04:13:51 +02:00
FLAC container support (#95)
* add a test * recognize as input format * scaffold flac demuxer * implement getting metadata * Implement mime type * read all metadata + deduplicate stubs * Read first packet * copyright headers * read the first packet * Get entire first packet, work on advancing * iterate over all samples * testable with bun * stub out metadata support * parse descriptive metadata * All in 1 file seems more appropriate to the philosophy * some parameters are not needed anymore all within 1 class * skip over bytes we are sure are not the syncword * run prettier * timestamp is determined based on passed blocks, not maximumBlockSize * no binary search needed! * simplifications * Finish demuxer reading sequentially * more tests + add a file with a seektable * don't throw if (this.audioInfo.minimumBlockSize !== this.audioInfo.maximumBlockSize * Add docs * Update format compatibility table * Simplification * confirm conversion is working * Finish * Resolve TODO comment * Support images (read-only) * Returning description as Uint8Array * Cleanup of demuxer * Misc renames * Object on same line * Resolve first batch of comments * Throw errors on corrupt blocks, correctly use requestSlice() * Fix description field * Explain why last frame is a bit shorter * Add FLAC to README * Put track backings below demuxer * convert to methods * Reorder container checking * getBlockSize() -> readBlockSize() * bitStream -> bitstream * better naming for bytes * use .skip() * Don't return blockSize twice in readFlacFrameHeader * Use enum + switch to distinguish Flac block types * Use else-if * Use else-if * Compressed switch statement * readCodedNumber * Update flac-misc.ts * We don't need the bits variable at all * reorder functions in flac-demuxer * Handle gracefully not being able to load another sample * Update flac-demuxer.ts * blockingbit null * use async instead of promise.resolve * use binary search * Replace recursion with while loop * Add mutex to getPacket() * Load more data not in getPacketAtIndex, but outside * Apply suggestion from @Vanilagy Co-authored-by: David P. <[email protected]> * computeDuration() reads last packet * Update flac-demuxer.ts * share vorbis comment reading logic * reuse vorbis comment writing logic, set vendor always to "Mediabunny" * flush after writign * Use FileSlice.tempFromBytes * assert !== null * fixing nitpicks * apply suggestions * fix ogg * We are now muxing images * apply suggestion * Update src/flac/flac-muxer.ts Co-authored-by: David P. <[email protected]> * apply suggestion * readSampleRate() * seek outside writeHeader() * mention vorbis metadata + add to metadatatags comment * should be able to -> can * Add test in metadata tags * Add test for packets being byte identical after remuxing * compare to null * no casting to uint8array * make test pass * Update flac-muxer.ts * Call validateAudioChunkMetadata() and validateAndNormalizeTimestamp() * `onFrame` option * emit frames using onFrame * Run prettier over files * Fix FLAC PICTURE block logic, small other changes * Update docs * Remove .only modifier * fix remuxing and add test * Don't throw error if parsing fails in header, since hitting a syncword might just be coincidential * Fix remaining type errors --------- Co-authored-by: David P. <[email protected]>
This commit is contained in:
@@ -11,15 +11,21 @@ import { InputVideoTrack } from './input-track';
|
||||
import {
|
||||
assert,
|
||||
assertNever,
|
||||
base64ToBytes,
|
||||
Bitstream,
|
||||
bytesToBase64,
|
||||
keyValueIterator,
|
||||
getUint24,
|
||||
last,
|
||||
readExpGolomb,
|
||||
readSignedExpGolomb,
|
||||
textDecoder,
|
||||
textEncoder,
|
||||
toDataView,
|
||||
toUint8Array,
|
||||
} from './misc';
|
||||
import { EncodedPacket, PacketType } from './packet';
|
||||
import { MetadataTags } from './tags';
|
||||
|
||||
// References for AVC/HEVC code:
|
||||
// ISO 14496-15
|
||||
@@ -1572,3 +1578,342 @@ export const determineVideoPacketType = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export enum FlacBlockType {
|
||||
STREAMINFO = 0,
|
||||
VORBIS_COMMENT = 4,
|
||||
PICTURE = 6,
|
||||
}
|
||||
|
||||
export const readVorbisComments = (bytes: Uint8Array, metadataTags: MetadataTags) => {
|
||||
// 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
|
||||
metadataTags.raw ??= {};
|
||||
metadataTags.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);
|
||||
|
||||
metadataTags.raw ??= {};
|
||||
metadataTags.raw[key] ??= value;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE': {
|
||||
metadataTags.title ??= value;
|
||||
}; break;
|
||||
|
||||
case 'DESCRIPTION': {
|
||||
metadataTags.description ??= value;
|
||||
}; break;
|
||||
|
||||
case 'ARTIST': {
|
||||
metadataTags.artist ??= value;
|
||||
}; break;
|
||||
|
||||
case 'ALBUM': {
|
||||
metadataTags.album ??= value;
|
||||
}; break;
|
||||
|
||||
case 'ALBUMARTIST': {
|
||||
metadataTags.albumArtist ??= value;
|
||||
}; break;
|
||||
|
||||
case 'COMMENT': {
|
||||
metadataTags.comment ??= value;
|
||||
}; break;
|
||||
|
||||
case 'LYRICS': {
|
||||
metadataTags.lyrics ??= value;
|
||||
}; break;
|
||||
|
||||
case 'TRACKNUMBER': {
|
||||
const parts = value.split('/');
|
||||
const trackNum = Number.parseInt(parts[0]!, 10);
|
||||
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
|
||||
|
||||
if (Number.isInteger(trackNum) && trackNum > 0) {
|
||||
metadataTags.trackNumber ??= trackNum;
|
||||
}
|
||||
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
||||
metadataTags.tracksTotal ??= tracksTotal;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'TRACKTOTAL': {
|
||||
const tracksTotal = Number.parseInt(value, 10);
|
||||
if (Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
||||
metadataTags.tracksTotal ??= tracksTotal;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'DISCNUMBER': {
|
||||
const parts = value.split('/');
|
||||
const discNum = Number.parseInt(parts[0]!, 10);
|
||||
const discsTotal = parts[1] && Number.parseInt(parts[1], 10);
|
||||
|
||||
if (Number.isInteger(discNum) && discNum > 0) {
|
||||
metadataTags.discNumber ??= discNum;
|
||||
}
|
||||
if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {
|
||||
metadataTags.discsTotal ??= discsTotal;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'DISCTOTAL': {
|
||||
const discsTotal = Number.parseInt(value, 10);
|
||||
if (Number.isInteger(discsTotal) && discsTotal > 0) {
|
||||
metadataTags.discsTotal ??= discsTotal;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'DATE': {
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
metadataTags.date ??= date;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'GENRE': {
|
||||
metadataTags.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,
|
||||
);
|
||||
|
||||
metadataTags.images ??= [];
|
||||
metadataTags.images.push({
|
||||
data,
|
||||
mimeType: mediaType,
|
||||
kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown',
|
||||
name: undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
}; break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags, writeImages: boolean) => {
|
||||
// https://datatracker.ietf.org/doc/html/rfc7845#section-5.2
|
||||
|
||||
const commentHeaderParts: Uint8Array[] = [
|
||||
headerBytes,
|
||||
];
|
||||
|
||||
const vendorString = 'Mediabunny';
|
||||
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<string>();
|
||||
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(tags)) {
|
||||
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': {
|
||||
const rawVersion = tags.raw?.['DATE'] ?? tags.raw?.['date'];
|
||||
if (rawVersion && typeof rawVersion === 'string') {
|
||||
addCommentTag('DATE', rawVersion);
|
||||
} else {
|
||||
addCommentTag('DATE', value.toISOString().slice(0, 10));
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'comment': {
|
||||
addCommentTag('COMMENT', value);
|
||||
}; break;
|
||||
|
||||
case 'lyrics': {
|
||||
addCommentTag('LYRICS', value);
|
||||
}; break;
|
||||
|
||||
case 'trackNumber': {
|
||||
addCommentTag('TRACKNUMBER', value.toString());
|
||||
}; break;
|
||||
|
||||
case 'tracksTotal': {
|
||||
addCommentTag('TRACKTOTAL', value.toString());
|
||||
}; break;
|
||||
|
||||
case 'discNumber': {
|
||||
addCommentTag('DISCNUMBER', value.toString());
|
||||
}; break;
|
||||
|
||||
case 'discsTotal': {
|
||||
addCommentTag('DISCTOTAL', value.toString());
|
||||
}; break;
|
||||
|
||||
case 'images': {
|
||||
// For example, in .flac, we put the pictures in a different section,
|
||||
// not in the Vorbis comment header.
|
||||
if (!writeImages) {
|
||||
break;
|
||||
}
|
||||
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 (tags.raw) {
|
||||
for (const key in tags.raw) {
|
||||
const value = tags.raw[key] ?? tags.raw[key.toLowerCase()];
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user