diff --git a/package-lock.json b/package-lock.json index 1479b74..50d90b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.40.1", + "version": "1.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.40.1", + "version": "1.41.0", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7751,9 +7751,9 @@ } }, "node_modules/mediabunny": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.40.0.tgz", - "integrity": "sha512-UztWnjkA15yYxqq7AC8MSsU4U6FIFIBHvHV94pouvjxQa0y2pGvi1HPmrFcaYrRZAuSKTrdw45VXUIe3gEEpsA==", + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.40.1.tgz", + "integrity": "sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12077,7 +12077,7 @@ }, "packages/aac-encoder": { "name": "@mediabunny/aac-encoder", - "version": "1.40.1", + "version": "1.41.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12092,7 +12092,7 @@ }, "packages/ac3": { "name": "@mediabunny/ac3", - "version": "1.40.1", + "version": "1.41.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12107,7 +12107,7 @@ }, "packages/flac-encoder": { "name": "@mediabunny/flac-encoder", - "version": "1.40.1", + "version": "1.41.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12122,7 +12122,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.40.1", + "version": "1.41.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index 027388e..fbc9d23 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.40.1", + "version": "1.41.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/aac-encoder/README.md b/packages/aac-encoder/README.md index 69062c2..9efa6c7 100644 --- a/packages/aac-encoder/README.md +++ b/packages/aac-encoder/README.md @@ -81,6 +81,9 @@ const output = new Output({ const conversion = await Conversion.init({ input, output, + audio: { + codec: 'aac', + }, }); await conversion.execute(); diff --git a/packages/aac-encoder/package.json b/packages/aac-encoder/package.json index d86abd2..8033663 100644 --- a/packages/aac-encoder/package.json +++ b/packages/aac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/aac-encoder", "author": "Vanilagy", - "version": "1.40.1", + "version": "1.41.0", "description": "AAC encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-aac-encoder.mjs", "module": "./dist/bundles/mediabunny-aac-encoder.mjs", diff --git a/packages/ac3/package.json b/packages/ac3/package.json index c7e957b..e2bc798 100644 --- a/packages/ac3/package.json +++ b/packages/ac3/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/ac3", "author": "Vanilagy", - "version": "1.40.1", + "version": "1.41.0", "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-ac3.mjs", "module": "./dist/bundles/mediabunny-ac3.mjs", diff --git a/packages/flac-encoder/package.json b/packages/flac-encoder/package.json index 8e7dc28..3f1ca36 100644 --- a/packages/flac-encoder/package.json +++ b/packages/flac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/flac-encoder", "author": "Vanilagy", - "version": "1.40.1", + "version": "1.41.0", "description": "FLAC encoder extension for Mediabunny, based on libFLAC.", "main": "./dist/bundles/mediabunny-flac-encoder.mjs", "module": "./dist/bundles/mediabunny-flac-encoder.mjs", diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index 029ede2..3ef1e8a 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.40.1", + "version": "1.41.0", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", diff --git a/src/id3.ts b/src/id3.ts index 254fc6f..2972185 100644 --- a/src/id3.ts +++ b/src/id3.ts @@ -16,6 +16,7 @@ import { assertNever, keyValueIterator, toDataView, + isRecordStringString, } from './misc'; import { FileSlice, readAscii, readBytes, readU32Be, readU8 } from './reader'; import { Writer } from './writer'; @@ -225,7 +226,14 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad } tags.raw ??= {}; - if (frame.id[0] === 'T') { + if (frame.id === 'TXXX') { + const txxx = tags.raw['TXXX'] ??= {}; + const encoding = reader.readId3V2TextEncoding(); + const description = reader.readId3V2Text(encoding, frameEndPos); + const value = reader.readId3V2Text(encoding, frameEndPos); + + (txxx as Record)[description] ??= value; + } else if (frame.id[0] === 'T') { // It's a text frame, let's decode as text tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); } else { @@ -761,13 +769,51 @@ export class Id3V2Writer { 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 + const useIso88591 = isIso88591Compatible(value); + if (useIso88591) { + bytes = new Uint8Array(value.length + 2); + bytes[0] = Id3V2TextEncoding.ISO_8859_1; + for (let i = 0; i < value.length; i++) { + bytes[i + 1] = value.charCodeAt(i); + } + // Last byte is the null terminator + } else { + 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 if (key === 'TXXX' && isRecordStringString(value)) { + for (const description in value) { + const frameValue = value[description]!; + const useIso88591 = isIso88591Compatible(description) && isIso88591Compatible(frameValue); + const descriptionDataLength = useIso88591 + ? description.length + : textEncoder.encode(description).byteLength; + + const valueData = useIso88591 ? null : textEncoder.encode(frameValue); + const valueDataLength = useIso88591 ? frameValue.length : valueData!.byteLength; + const frameSize = 1 + descriptionDataLength + 1 + valueDataLength; + + this.writeAscii('TXXX'); + this.writeSynchsafeU32(frameSize); + this.writeU16(0x0000); + + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); + if (useIso88591) { + this.writeIsoString(description); + for (let i = 0; i < frameValue.length; i++) { + this.writeU8(frameValue.charCodeAt(i)); + } + } else { + this.writeUtf8String(description); + this.writer.write(valueData!); + } + } + continue; } else { continue; } @@ -821,7 +867,8 @@ export class Id3V2Writer { for (let i = 0; i < text.length; i++) { bytes[i] = text.charCodeAt(i); } - bytes[text.length] = 0x00; + // Last byte is the null terminator + this.writer.write(bytes); } diff --git a/src/metadata.ts b/src/metadata.ts index 5fe983e..b839eb4 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -6,6 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { isRecordStringString } from './misc'; + /** * Represents descriptive (non-technical) metadata about a media file, such as title, author, date, cover art, or other * attached files. Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to @@ -69,8 +71,9 @@ export type MetadataTags = { * - WebM/Matroska: `SimpleTag` elements whose target is 50 (MOVIE), either containing string or `Uint8Array` * values. Additionally, all attached files (such as font files) are included here, where the key corresponds to * the FileUID and the value is an {@link AttachedFile}. - * - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. - * - ADTS: The ID3v2 tags. + * - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. The ID3v2 `'TXXX'` + * user-defined text frames are exposed as a `Record`. + * - ADTS: The ID3v2 tags, just like in MP3. * - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2). * Additionally, the `'vendor'` key refers to the vendor string within this header. * - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings. @@ -78,7 +81,7 @@ export type MetadataTags = { * Additionally, the `'vendor'` key refers to the vendor string within this header. * - MPEG-TS: Not supported. */ - raw?: Record; + raw?: Record | null>; }; /** @@ -236,9 +239,11 @@ export const validateMetadataTags = (tags: MetadataTags) => { && !(value instanceof Uint8Array) && !(value instanceof RichImageData) && !(value instanceof AttachedFile) + && !isRecordStringString(value) ) { throw new TypeError( - 'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, or null.', + 'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, ' + + 'Record, or null.', ); } } diff --git a/src/misc.ts b/src/misc.ts index 982949b..d467a3d 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -1009,3 +1009,10 @@ export const clearIntervalUnthrottled = (timer: UnthrottledTimerHandle) => { timerId: timer.id, } satisfies UnthrottledTimerMessage); }; + +export const isRecordStringString = (value: unknown): value is Record => { + return value !== null + && typeof value === 'object' + && Object.getPrototypeOf(value) === Object.prototype + && Object.values(value).every(x => typeof x === 'string'); +}; diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index af68b7b..cb231a1 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -338,6 +338,12 @@ test('Read and write metadata, MP3', async () => { ...songMetadata, raw: { TXXY: 'ID3v2 goated', + TXXZ: '鱼', + TXXX: { + foo: 'bar', + baz: 'qux', + 你: '好', + }, }, }); @@ -377,6 +383,12 @@ test('Read and write metadata, MP3', async () => { expect(readTags.raw!['TIT2']).toBe(songMetadata.title); expect(readTags.raw!['APIC']).instanceOf(Uint8Array); expect(readTags.raw!['TXXY']).toBe('ID3v2 goated'); + expect(readTags.raw!['TXXZ']).toBe('鱼'); + expect(readTags.raw!['TXXX']).toEqual({ + foo: 'bar', + baz: 'qux', + 你: '好', + }); }); test('Read and write metadata, Ogg', async () => {