mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 05:43:50 +02:00
Merge main into release for tag v1.18.0
This commit is contained in:
@@ -14,6 +14,9 @@
|
|||||||
source: new Mediabunny.BlobSource(file),
|
source: new Mediabunny.BlobSource(file),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(await input.getMetadataTags());
|
||||||
|
|
||||||
|
/*
|
||||||
console.log(await input.computeDuration());
|
console.log(await input.computeDuration());
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("Done")
|
console.log("Done")
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const sponsors = {
|
|||||||
{ image: 'https://avatars.githubusercontent.com/u/61233224', name: 'Allwhy', url: 'https://github.com/Allwhy' },
|
{ image: 'https://avatars.githubusercontent.com/u/61233224', name: 'Allwhy', url: 'https://github.com/Allwhy' },
|
||||||
{ image: 'https://avatars.githubusercontent.com/u/97225946', name: '808vita', url: 'https://github.com/808vita' },
|
{ image: 'https://avatars.githubusercontent.com/u/97225946', name: '808vita', url: 'https://github.com/808vita' },
|
||||||
{ image: 'https://avatars.githubusercontent.com/u/3709646', name: 'Rodrigo Belfiore', url: 'https://github.com/roprgm' },
|
{ image: 'https://avatars.githubusercontent.com/u/3709646', name: 'Rodrigo Belfiore', url: 'https://github.com/roprgm' },
|
||||||
|
{ image: 'https://avatars.githubusercontent.com/u/31102694', name: 'Aiden Liu', url: 'https://github.com/aidenlx' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"version": "1.17.3",
|
"version": "1.18.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"version": "1.17.3",
|
"version": "1.18.0",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -7749,9 +7749,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mediabunny": {
|
"node_modules/mediabunny": {
|
||||||
"version": "1.17.2",
|
"version": "1.17.3",
|
||||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.17.2.tgz",
|
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.17.3.tgz",
|
||||||
"integrity": "sha512-I7l/WDv7howETh0Vhc9J8Y0XZ1Z1qUpUEcud05Eqf/MeaaOT8FGb744sTXN6U4Rk5ZxFYRz7g3JHcA1XvsFkWg==",
|
"integrity": "sha512-q17acLnaDMhZidQde9ZBGTO28pXJR4xtMo4ElU5JKafxV08bvN0gZiD3D2r7YUV49qaxeYsWJE2afYc19Rarfg==",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -12242,7 +12242,7 @@
|
|||||||
},
|
},
|
||||||
"packages/mp3-encoder": {
|
"packages/mp3-encoder": {
|
||||||
"name": "@mediabunny/mp3-encoder",
|
"name": "@mediabunny/mp3-encoder",
|
||||||
"version": "1.17.3",
|
"version": "1.18.0",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/emscripten": "^1.40.1"
|
"@types/emscripten": "^1.40.1"
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"author": "Vanilagy",
|
"author": "Vanilagy",
|
||||||
"version": "1.17.3",
|
"version": "1.18.0",
|
||||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@mediabunny/mp3-encoder",
|
"name": "@mediabunny/mp3-encoder",
|
||||||
"author": "Vanilagy",
|
"author": "Vanilagy",
|
||||||
"version": "1.17.3",
|
"version": "1.18.0",
|
||||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||||
|
|||||||
+3
-3
@@ -52,11 +52,11 @@ export const computeMp3FrameSize = (
|
|||||||
if (layer === 0) {
|
if (layer === 0) {
|
||||||
return 0; // Not expected that this is hit
|
return 0; // Not expected that this is hit
|
||||||
} else if (layer === 1) {
|
} else if (layer === 1) {
|
||||||
return Math.round(144 * bitrate / (sampleRate << lowSamplingFrequency)) + padding;
|
return Math.floor(144 * bitrate / (sampleRate << lowSamplingFrequency)) + padding;
|
||||||
} else if (layer === 2) {
|
} else if (layer === 2) {
|
||||||
return Math.round(144 * bitrate / sampleRate) + padding;
|
return Math.floor(144 * bitrate / sampleRate) + padding;
|
||||||
} else { // layer === 3
|
} else { // layer === 3
|
||||||
return (Math.round(12 * bitrate / sampleRate) + padding) * 4;
|
return (Math.floor(12 * bitrate / sampleRate) + padding) * 4;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export {
|
|||||||
MetadataTags,
|
MetadataTags,
|
||||||
AttachedImage,
|
AttachedImage,
|
||||||
RichImageData,
|
RichImageData,
|
||||||
|
AttachedFile,
|
||||||
} from './tags';
|
} from './tags';
|
||||||
|
|
||||||
// 🐡🦔
|
// 🐡🦔
|
||||||
|
|||||||
+1
-1
@@ -289,7 +289,7 @@ export class Mp3InputFormat extends InputFormat {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPos = firstResult.startPos += firstResult.header.totalSize;
|
currentPos = firstResult.startPos + firstResult.header.totalSize;
|
||||||
|
|
||||||
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
|
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
|
||||||
// another header right after it:
|
// another header right after it:
|
||||||
|
|||||||
+54
-4
@@ -16,6 +16,7 @@ export interface EBMLElement {
|
|||||||
size?: number;
|
size?: number;
|
||||||
data:
|
data:
|
||||||
| number
|
| number
|
||||||
|
| bigint
|
||||||
| string
|
| string
|
||||||
| Uint8Array
|
| Uint8Array
|
||||||
| EBMLFloat32
|
| EBMLFloat32
|
||||||
@@ -198,6 +199,26 @@ export const measureUnsignedInt = (value: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const measureUnsignedBigInt = (value: bigint) => {
|
||||||
|
if (value < (1n << 8n)) {
|
||||||
|
return 1;
|
||||||
|
} else if (value < (1n << 16n)) {
|
||||||
|
return 2;
|
||||||
|
} else if (value < (1n << 24n)) {
|
||||||
|
return 3;
|
||||||
|
} else if (value < (1n << 32n)) {
|
||||||
|
return 4;
|
||||||
|
} else if (value < (1n << 40n)) {
|
||||||
|
return 5;
|
||||||
|
} else if (value < (1n << 48n)) {
|
||||||
|
return 6;
|
||||||
|
} else if (value < (1n << 56n)) {
|
||||||
|
return 7;
|
||||||
|
} else {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const measureSignedInt = (value: number) => {
|
export const measureSignedInt = (value: number) => {
|
||||||
if (value >= -(1 << 6) && value < (1 << 6)) {
|
if (value >= -(1 << 6) && value < (1 << 6)) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -276,16 +297,16 @@ export class EBMLWriter {
|
|||||||
// eslint-disable-next-line no-fallthrough
|
// eslint-disable-next-line no-fallthrough
|
||||||
case 5:
|
case 5:
|
||||||
this.helperView.setUint8(pos++, (value / 2 ** 32) | 0);
|
this.helperView.setUint8(pos++, (value / 2 ** 32) | 0);
|
||||||
// eslint-disable-next-line no-fallthrough
|
// eslint-disable-next-line no-fallthrough
|
||||||
case 4:
|
case 4:
|
||||||
this.helperView.setUint8(pos++, value >> 24);
|
this.helperView.setUint8(pos++, value >> 24);
|
||||||
// eslint-disable-next-line no-fallthrough
|
// eslint-disable-next-line no-fallthrough
|
||||||
case 3:
|
case 3:
|
||||||
this.helperView.setUint8(pos++, value >> 16);
|
this.helperView.setUint8(pos++, value >> 16);
|
||||||
// eslint-disable-next-line no-fallthrough
|
// eslint-disable-next-line no-fallthrough
|
||||||
case 2:
|
case 2:
|
||||||
this.helperView.setUint8(pos++, value >> 8);
|
this.helperView.setUint8(pos++, value >> 8);
|
||||||
// eslint-disable-next-line no-fallthrough
|
// eslint-disable-next-line no-fallthrough
|
||||||
case 1:
|
case 1:
|
||||||
this.helperView.setUint8(pos++, value);
|
this.helperView.setUint8(pos++, value);
|
||||||
break;
|
break;
|
||||||
@@ -296,6 +317,16 @@ export class EBMLWriter {
|
|||||||
this.writer.write(this.helper.subarray(0, pos));
|
this.writer.write(this.helper.subarray(0, pos));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeUnsignedBigInt(value: bigint, width = measureUnsignedBigInt(value)) {
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
for (let i = width - 1; i >= 0; i--) {
|
||||||
|
this.helperView.setUint8(pos++, Number((value >> BigInt(i * 8)) & 0xffn));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writer.write(this.helper.subarray(0, pos));
|
||||||
|
}
|
||||||
|
|
||||||
writeSignedInt(value: number, width = measureSignedInt(value)) {
|
writeSignedInt(value: number, width = measureSignedInt(value)) {
|
||||||
if (value < 0) {
|
if (value < 0) {
|
||||||
// Two's complement stuff
|
// Two's complement stuff
|
||||||
@@ -398,6 +429,10 @@ export class EBMLWriter {
|
|||||||
const size = data.size ?? measureUnsignedInt(data.data);
|
const size = data.size ?? measureUnsignedInt(data.data);
|
||||||
this.writeVarInt(size);
|
this.writeVarInt(size);
|
||||||
this.writeUnsignedInt(data.data, size);
|
this.writeUnsignedInt(data.data, size);
|
||||||
|
} else if (typeof data.data === 'bigint') {
|
||||||
|
const size = data.size ?? measureUnsignedBigInt(data.data);
|
||||||
|
this.writeVarInt(size);
|
||||||
|
this.writeUnsignedBigInt(data.data, size);
|
||||||
} else if (typeof data.data === 'string') {
|
} else if (typeof data.data === 'string') {
|
||||||
this.writeVarInt(data.data.length);
|
this.writeVarInt(data.data.length);
|
||||||
this.writeAsciiString(data.data);
|
this.writeAsciiString(data.data);
|
||||||
@@ -491,6 +526,21 @@ export const readUnsignedInt = (slice: FileSlice, width: number) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const readUnsignedBigInt = (slice: FileSlice, width: number) => {
|
||||||
|
if (width < 1) {
|
||||||
|
throw new Error('Bad unsigned int size ' + width);
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = 0n;
|
||||||
|
|
||||||
|
for (let i = 0; i < width; i++) {
|
||||||
|
value <<= 8n;
|
||||||
|
value += BigInt(readU8(slice));
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
export const readSignedInt = (slice: FileSlice, width: number) => {
|
export const readSignedInt = (slice: FileSlice, width: number) => {
|
||||||
let value = readUnsignedInt(slice, width);
|
let value = readUnsignedInt(slice, width);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
InputVideoTrack,
|
InputVideoTrack,
|
||||||
InputVideoTrackBacking,
|
InputVideoTrackBacking,
|
||||||
} from '../input-track';
|
} from '../input-track';
|
||||||
import { MetadataTags } from '../tags';
|
import { AttachedFile, MetadataTags } from '../tags';
|
||||||
import { PacketRetrievalOptions } from '../media-sink';
|
import { PacketRetrievalOptions } from '../media-sink';
|
||||||
import {
|
import {
|
||||||
assert,
|
assert,
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
readVarInt,
|
readVarInt,
|
||||||
resync,
|
resync,
|
||||||
searchForNextElementId,
|
searchForNextElementId,
|
||||||
|
readUnsignedBigInt,
|
||||||
} from './ebml';
|
} from './ebml';
|
||||||
import { buildMatroskaMimeType } from './matroska-misc';
|
import { buildMatroskaMimeType } from './matroska-misc';
|
||||||
import { FileSlice, readBytes, Reader, readI16Be, readU8 } from '../reader';
|
import { FileSlice, readBytes, Reader, readI16Be, readU8 } from '../reader';
|
||||||
@@ -240,6 +241,7 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
currentTagTargetIsMovie: boolean = true;
|
currentTagTargetIsMovie: boolean = true;
|
||||||
currentSimpleTagName: string | null = null;
|
currentSimpleTagName: string | null = null;
|
||||||
currentAttachedFile: {
|
currentAttachedFile: {
|
||||||
|
fileUid: bigint | null;
|
||||||
fileName: string | null;
|
fileName: string | null;
|
||||||
fileMediaType: string | null;
|
fileMediaType: string | null;
|
||||||
fileData: Uint8Array | null;
|
fileData: Uint8Array | null;
|
||||||
@@ -1478,6 +1480,7 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
if (!this.currentSegment) break;
|
if (!this.currentSegment) break;
|
||||||
|
|
||||||
this.currentAttachedFile = {
|
this.currentAttachedFile = {
|
||||||
|
fileUid: null,
|
||||||
fileName: null,
|
fileName: null,
|
||||||
fileMediaType: null,
|
fileMediaType: null,
|
||||||
fileData: null,
|
fileData: null,
|
||||||
@@ -1486,6 +1489,19 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
|
|
||||||
this.readContiguousElements(slice.slice(dataStartPos, size));
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
||||||
|
|
||||||
|
const tags = this.currentSegment.metadataTags;
|
||||||
|
|
||||||
|
if (this.currentAttachedFile.fileUid && this.currentAttachedFile.fileData) {
|
||||||
|
// All attached files get surfaced in the `raw` metadata tags
|
||||||
|
tags.raw ??= {};
|
||||||
|
tags.raw[this.currentAttachedFile.fileUid.toString()] = new AttachedFile(
|
||||||
|
this.currentAttachedFile.fileData,
|
||||||
|
this.currentAttachedFile.fileMediaType ?? undefined,
|
||||||
|
this.currentAttachedFile.fileName ?? undefined,
|
||||||
|
this.currentAttachedFile.fileDescription ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Only process image attachments
|
// Only process image attachments
|
||||||
if (this.currentAttachedFile.fileMediaType?.startsWith('image/') && this.currentAttachedFile.fileData) {
|
if (this.currentAttachedFile.fileMediaType?.startsWith('image/') && this.currentAttachedFile.fileData) {
|
||||||
const fileName = this.currentAttachedFile.fileName;
|
const fileName = this.currentAttachedFile.fileName;
|
||||||
@@ -1500,8 +1516,8 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentSegment.metadataTags.images ??= [];
|
tags.images ??= [];
|
||||||
this.currentSegment.metadataTags.images.push({
|
tags.images.push({
|
||||||
data: this.currentAttachedFile.fileData,
|
data: this.currentAttachedFile.fileData,
|
||||||
mimeType: this.currentAttachedFile.fileMediaType,
|
mimeType: this.currentAttachedFile.fileMediaType,
|
||||||
kind,
|
kind,
|
||||||
@@ -1513,6 +1529,12 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
this.currentAttachedFile = null;
|
this.currentAttachedFile = null;
|
||||||
}; break;
|
}; break;
|
||||||
|
|
||||||
|
case EBMLId.FileUID: {
|
||||||
|
if (!this.currentAttachedFile) break;
|
||||||
|
|
||||||
|
this.currentAttachedFile.fileUid = readUnsignedBigInt(slice, size);
|
||||||
|
}; break;
|
||||||
|
|
||||||
case EBMLId.FileName: {
|
case EBMLId.FileName: {
|
||||||
if (!this.currentAttachedFile) break;
|
if (!this.currentAttachedFile) break;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
roundToMultiple,
|
roundToMultiple,
|
||||||
textEncoder,
|
textEncoder,
|
||||||
toUint8Array,
|
toUint8Array,
|
||||||
|
uint8ArraysAreEqual,
|
||||||
writeBits,
|
writeBits,
|
||||||
} from '../misc';
|
} from '../misc';
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +63,7 @@ import { Muxer } from '../muxer';
|
|||||||
import { Writer } from '../writer';
|
import { Writer } from '../writer';
|
||||||
import { EncodedPacket } from '../packet';
|
import { EncodedPacket } from '../packet';
|
||||||
import { parseOpusIdentificationHeader } from '../codec-data';
|
import { parseOpusIdentificationHeader } from '../codec-data';
|
||||||
|
import { AttachedFile } from '../tags';
|
||||||
|
|
||||||
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
|
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
|
||||||
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
|
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
|
||||||
@@ -537,31 +539,35 @@ export class MatroskaMuxer extends Muxer {
|
|||||||
|
|
||||||
private maybeCreateAttachments() {
|
private maybeCreateAttachments() {
|
||||||
const metadataTags = this.output._metadataTags;
|
const metadataTags = this.output._metadataTags;
|
||||||
if (!metadataTags.images || metadataTags.images.length === 0) {
|
const elements: EBMLElement[] = [];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingFileUids = new Set<number>();
|
const existingFileUids = new Set<bigint>();
|
||||||
|
const images = metadataTags.images ?? [];
|
||||||
|
|
||||||
this.attachmentsElement = { id: EBMLId.Attachments, data: metadataTags.images.map((image): EBMLElement => {
|
for (const image of images) {
|
||||||
let imageName = image.name;
|
let imageName = image.name;
|
||||||
if (imageName === undefined) {
|
if (imageName === undefined) {
|
||||||
const baseName = image.kind === 'coverFront' ? 'cover' : image.kind === 'coverBack' ? 'back' : 'image';
|
const baseName = image.kind === 'coverFront' ? 'cover' : image.kind === 'coverBack' ? 'back' : 'image';
|
||||||
imageName = baseName + (imageMimeTypeToExtension(image.mimeType) ?? '');
|
imageName = baseName + (imageMimeTypeToExtension(image.mimeType) ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileUid: number;
|
let fileUid: bigint;
|
||||||
while (true) {
|
while (true) {
|
||||||
fileUid = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
// Generate a random 64-bit unsigned integer
|
||||||
|
fileUid = 0n;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
fileUid <<= 8n;
|
||||||
|
fileUid |= BigInt(Math.floor(Math.random() * 256));
|
||||||
|
}
|
||||||
|
|
||||||
if (fileUid !== 0 && !existingFileUids.has(fileUid)) {
|
if (fileUid !== 0n && !existingFileUids.has(fileUid)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
existingFileUids.add(fileUid);
|
existingFileUids.add(fileUid);
|
||||||
|
|
||||||
return {
|
elements.push({
|
||||||
id: EBMLId.AttachedFile,
|
id: EBMLId.AttachedFile,
|
||||||
data: [
|
data: [
|
||||||
image.description !== undefined
|
image.description !== undefined
|
||||||
@@ -572,8 +578,45 @@ export class MatroskaMuxer extends Muxer {
|
|||||||
{ id: EBMLId.FileData, data: image.data },
|
{ id: EBMLId.FileData, data: image.data },
|
||||||
{ id: EBMLId.FileUID, data: fileUid },
|
{ id: EBMLId.FileUID, data: fileUid },
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
}) };
|
}
|
||||||
|
|
||||||
|
// Add all AttachedFiles from the raw metadata
|
||||||
|
for (const [key, value] of Object.entries(metadataTags.raw ?? {})) {
|
||||||
|
if (!(value instanceof AttachedFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyIsNumeric = /^\d+$/.test(key);
|
||||||
|
if (!keyIsNumeric) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images.find(x => x.mimeType === value.mimeType && uint8ArraysAreEqual(x.data, value.data))) {
|
||||||
|
// This attached file has very likely already been added as an image above
|
||||||
|
// (happens when remuxing Matroska)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
id: EBMLId.AttachedFile,
|
||||||
|
data: [
|
||||||
|
value.description !== undefined
|
||||||
|
? { id: EBMLId.FileDescription, data: new EBMLUnicodeString(value.description) }
|
||||||
|
: null,
|
||||||
|
{ id: EBMLId.FileName, data: new EBMLUnicodeString(value.name ?? '') },
|
||||||
|
{ id: EBMLId.FileMediaType, data: value.mimeType ?? '' },
|
||||||
|
{ id: EBMLId.FileData, data: value.data },
|
||||||
|
{ id: EBMLId.FileUID, data: BigInt(key) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attachmentsElement = { id: EBMLId.Attachments, data: elements };
|
||||||
}
|
}
|
||||||
|
|
||||||
private createSegment() {
|
private createSegment() {
|
||||||
|
|||||||
+64
-17
@@ -519,26 +519,59 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
|
|||||||
*/
|
*/
|
||||||
export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||||
|
|
||||||
export const mergeObjectsDeeply = <T extends object, S extends object>(a: T, b: S): T & S => {
|
/**
|
||||||
const result = { ...a } as T & S;
|
* Merges two RequestInit objects with special handling for headers.
|
||||||
|
* Headers are merged case-insensitively, but original casing is preserved.
|
||||||
|
* init2 headers take precedence and will override case-insensitive matches from init1.
|
||||||
|
*/
|
||||||
|
export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): RequestInit => {
|
||||||
|
const merged: RequestInit = { ...init1, ...init2 };
|
||||||
|
|
||||||
for (const key in b) {
|
// Special handling for headers
|
||||||
if (
|
if (init1.headers || init2.headers) {
|
||||||
typeof a[key as unknown as keyof T] === 'object'
|
const headers1 = init1.headers ? normalizeHeaders(init1.headers) : {};
|
||||||
&& a[key as unknown as keyof T] !== null
|
const headers2 = init2.headers ? normalizeHeaders(init2.headers) : {};
|
||||||
&& typeof b[key] === 'object'
|
|
||||||
&& b[key] !== null
|
const mergedHeaders = { ...headers1 };
|
||||||
) {
|
|
||||||
result[key] = mergeObjectsDeeply(
|
// For each header in headers2, check if a case-insensitive match exists in mergedHeaders
|
||||||
a[key as unknown as keyof T] as object,
|
Object.entries(headers2).forEach(([key2, value2]) => {
|
||||||
b[key],
|
const existingKey = Object.keys(mergedHeaders).find(
|
||||||
) as (T & S)[Extract<keyof S, string>];
|
key1 => key1.toLowerCase() === key2.toLowerCase(),
|
||||||
} else {
|
);
|
||||||
result[key] = b[key] as (T & S)[Extract<keyof S, string>];
|
|
||||||
}
|
if (existingKey) {
|
||||||
|
delete mergedHeaders[existingKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedHeaders[key2] = value2;
|
||||||
|
});
|
||||||
|
|
||||||
|
merged.headers = mergedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalizes HeadersInit to a Record<string, string> format. */
|
||||||
|
const normalizeHeaders = (headers: HeadersInit): Record<string, string> => {
|
||||||
|
if (headers instanceof Headers) {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
result[key] = value;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
headers.forEach(([key, value]) => {
|
||||||
|
result[key] = value;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const retriedFetch = async (
|
export const retriedFetch = async (
|
||||||
@@ -732,3 +765,17 @@ export const bytesToBase64 = (bytes: Uint8Array) => {
|
|||||||
|
|
||||||
return btoa(string);
|
return btoa(string);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const uint8ArraysAreEqual = (a: Uint8Array, b: Uint8Array) => {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@ import {
|
|||||||
binarySearchLessOrEqual,
|
binarySearchLessOrEqual,
|
||||||
closedIntervalsOverlap,
|
closedIntervalsOverlap,
|
||||||
MaybePromise,
|
MaybePromise,
|
||||||
mergeObjectsDeeply,
|
mergeRequestInit,
|
||||||
promiseWithResolvers,
|
promiseWithResolvers,
|
||||||
retriedFetch,
|
retriedFetch,
|
||||||
toDataView,
|
toDataView,
|
||||||
@@ -326,7 +326,7 @@ export class UrlSource extends Source {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const response = await retriedFetch(
|
const response = await retriedFetch(
|
||||||
this._url,
|
this._url,
|
||||||
mergeObjectsDeeply(this._options.requestInit ?? {}, {
|
mergeRequestInit(this._options.requestInit ?? {}, {
|
||||||
headers: {
|
headers: {
|
||||||
// We could also send a non-range request to request the same bytes (all of them), but doing it like
|
// We could also send a non-range request to request the same bytes (all of them), but doing it like
|
||||||
// this is an easy way to check if the server supports range requests in the first place
|
// this is an easy way to check if the server supports range requests in the first place
|
||||||
@@ -393,7 +393,7 @@ export class UrlSource extends Source {
|
|||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
response = await retriedFetch(
|
response = await retriedFetch(
|
||||||
this._url,
|
this._url,
|
||||||
mergeObjectsDeeply(this._options.requestInit ?? {}, {
|
mergeRequestInit(this._options.requestInit ?? {}, {
|
||||||
headers: {
|
headers: {
|
||||||
Range: `bytes=${worker.currentPos}-`,
|
Range: `bytes=${worker.currentPos}-`,
|
||||||
},
|
},
|
||||||
|
|||||||
+48
-5
@@ -16,9 +16,9 @@
|
|||||||
* - For MP3 files, the metadata refers to the ID3v2 or ID3v1 tags.
|
* - For MP3 files, the metadata refers to the ID3v2 or ID3v1 tags.
|
||||||
* - For Ogg files, there is no global metadata so instead, the metadata refers to the combined metadata of all tracks,
|
* - For Ogg files, there is no global metadata so instead, the metadata refers to the combined metadata of all tracks,
|
||||||
* in Vorbis-style comment headers.
|
* in Vorbis-style comment headers.
|
||||||
* - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block.
|
|
||||||
* - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk.
|
* - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk.
|
||||||
* - For ADTS files, there is no metadata.
|
* - For ADTS files, there is no metadata.
|
||||||
|
* - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block.
|
||||||
*
|
*
|
||||||
* @group Metadata tags
|
* @group Metadata tags
|
||||||
* @public
|
* @public
|
||||||
@@ -66,6 +66,8 @@ export type MetadataTags = {
|
|||||||
* Additionally, any atoms within the `'udta'` atom are dumped into here, however with unknown internal format
|
* Additionally, any atoms within the `'udta'` atom are dumped into here, however with unknown internal format
|
||||||
* (`Uint8Array`).
|
* (`Uint8Array`).
|
||||||
* - Matroska: `SimpleTag` elements whose target is 50 (MOVIE), either containing string or `Uint8Array` values.
|
* - 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.
|
* - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag.
|
||||||
* - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2).
|
* - 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.
|
* Additionally, the `'vendor'` key refers to the vendor string within this header.
|
||||||
@@ -73,7 +75,7 @@ export type MetadataTags = {
|
|||||||
* - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3).
|
* - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3).
|
||||||
* Additionally, the `'vendor'` key refers to the vendor string within this header.
|
* Additionally, the `'vendor'` key refers to the vendor string within this header.
|
||||||
*/
|
*/
|
||||||
raw?: Record<string, string | Uint8Array | RichImageData | null>;
|
raw?: Record<string, string | Uint8Array | RichImageData | AttachedFile | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +93,7 @@ export type AttachedImage = {
|
|||||||
kind: 'coverFront' | 'coverBack' | 'unknown';
|
kind: 'coverFront' | 'coverBack' | 'unknown';
|
||||||
/** The name of the image file. */
|
/** The name of the image file. */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** A short description of the image. */
|
/** A description of the image. */
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,9 +110,49 @@ export class RichImageData {
|
|||||||
public data: Uint8Array,
|
public data: Uint8Array,
|
||||||
/** An RFC 6838 MIME type (e.g. image/jpeg, image/png, etc.) */
|
/** An RFC 6838 MIME type (e.g. image/jpeg, image/png, etc.) */
|
||||||
public mimeType: string,
|
public mimeType: string,
|
||||||
) {}
|
) {
|
||||||
|
if (!(data instanceof Uint8Array)) {
|
||||||
|
throw new TypeError('data must be a Uint8Array.');
|
||||||
|
}
|
||||||
|
if (typeof mimeType !== 'string') {
|
||||||
|
throw new TypeError('mimeType must be a string.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file attached to a media file.
|
||||||
|
*
|
||||||
|
* @group Metadata tags
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export class AttachedFile {
|
||||||
|
/** Creates a new {@link AttachedFile}. */
|
||||||
|
constructor(
|
||||||
|
/** The raw file data. */
|
||||||
|
public data: Uint8Array,
|
||||||
|
/** An RFC 6838 MIME type (e.g. image/jpeg, image/png, font/ttf, etc.) */
|
||||||
|
public mimeType?: string,
|
||||||
|
/** The name of the file. */
|
||||||
|
public name?: string,
|
||||||
|
/** A description of the file. */
|
||||||
|
public description?: string,
|
||||||
|
) {
|
||||||
|
if (!(data instanceof Uint8Array)) {
|
||||||
|
throw new TypeError('data must be a Uint8Array.');
|
||||||
|
}
|
||||||
|
if (mimeType !== undefined && typeof mimeType !== 'string') {
|
||||||
|
throw new TypeError('mimeType, when provided, must be a string.');
|
||||||
|
}
|
||||||
|
if (name !== undefined && typeof name !== 'string') {
|
||||||
|
throw new TypeError('name, when provided, must be a string.');
|
||||||
|
}
|
||||||
|
if (description !== undefined && typeof description !== 'string') {
|
||||||
|
throw new TypeError('description, when provided, must be a string.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const validateMetadataTags = (tags: MetadataTags) => {
|
export const validateMetadataTags = (tags: MetadataTags) => {
|
||||||
if (!tags || typeof tags !== 'object') {
|
if (!tags || typeof tags !== 'object') {
|
||||||
throw new TypeError('tags must be an object.');
|
throw new TypeError('tags must be an object.');
|
||||||
@@ -190,9 +232,10 @@ export const validateMetadataTags = (tags: MetadataTags) => {
|
|||||||
&& typeof value !== 'string'
|
&& typeof value !== 'string'
|
||||||
&& !(value instanceof Uint8Array)
|
&& !(value instanceof Uint8Array)
|
||||||
&& !(value instanceof RichImageData)
|
&& !(value instanceof RichImageData)
|
||||||
|
&& !(value instanceof AttachedFile)
|
||||||
) {
|
) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
'Each value in tags.raw must be a string, Uint8Array, RichImageData, or null.',
|
'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, or null.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { EncodedPacket } from '../../src/packet.js';
|
|||||||
import { Input } from '../../src/input.js';
|
import { Input } from '../../src/input.js';
|
||||||
import { BufferSource, FilePathSource } from '../../src/source.js';
|
import { BufferSource, FilePathSource } from '../../src/source.js';
|
||||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||||
import { MetadataTags } from '../../src/tags.js';
|
import { AttachedFile, MetadataTags } from '../../src/tags.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { AudioCodec, buildAudioCodecString } from '../../src/codec.js';
|
import { AudioCodec, buildAudioCodecString } from '../../src/codec.js';
|
||||||
import { Conversion } from '../../src/conversion.js';
|
import { Conversion } from '../../src/conversion.js';
|
||||||
@@ -207,7 +207,21 @@ test('Read and write metadata, Matroska', async () => {
|
|||||||
output.setMetadataTags({
|
output.setMetadataTags({
|
||||||
...songMetadata,
|
...songMetadata,
|
||||||
raw: {
|
raw: {
|
||||||
CUSTOM: 'Levels',
|
'CUSTOM': 'Levels',
|
||||||
|
// This number cannot be represented by f64, so this tests correct BigInt usage
|
||||||
|
'9007199254740993': new AttachedFile(
|
||||||
|
new Uint8Array(20),
|
||||||
|
'font/ttf',
|
||||||
|
'Heya',
|
||||||
|
'A font file',
|
||||||
|
),
|
||||||
|
// And this one we don't expect to get attached again, since it mirrors the image we're also attaching
|
||||||
|
'987654': new AttachedFile(
|
||||||
|
songMetadata.images![0]!.data,
|
||||||
|
songMetadata.images![0]!.mimeType,
|
||||||
|
songMetadata.images![0]!.name,
|
||||||
|
songMetadata.images![0]!.description,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,6 +260,19 @@ test('Read and write metadata, Matroska', async () => {
|
|||||||
expect(readTags.raw!['TITLE']).toBe(songMetadata.title);
|
expect(readTags.raw!['TITLE']).toBe(songMetadata.title);
|
||||||
expect(readTags.raw!['PART_NUMBER']).toBe('13/14');
|
expect(readTags.raw!['PART_NUMBER']).toBe('13/14');
|
||||||
expect(readTags.raw!['CUSTOM']).toBe('Levels');
|
expect(readTags.raw!['CUSTOM']).toBe('Levels');
|
||||||
|
|
||||||
|
const files = Object.entries(readTags.raw!).filter(x => x[1] instanceof AttachedFile);
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
|
||||||
|
const imageFile = files.find(x => (x[1] as AttachedFile).mimeType === 'image/jpeg');
|
||||||
|
expect((imageFile![1] as AttachedFile).data).toEqual(songMetadata.images![0]!.data);
|
||||||
|
|
||||||
|
const secondFile = files.find(x => (x[1] as AttachedFile).mimeType === 'font/ttf');
|
||||||
|
expect(secondFile![0]).toBe('9007199254740993');
|
||||||
|
expect((secondFile![1] as AttachedFile).data).toHaveLength(20);
|
||||||
|
expect((secondFile![1] as AttachedFile).mimeType).toBe('font/ttf');
|
||||||
|
expect((secondFile![1] as AttachedFile).name).toBe('Heya');
|
||||||
|
expect((secondFile![1] as AttachedFile).description).toBe('A font file');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Read and write metadata, MP3', async () => {
|
test('Read and write metadata, MP3', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user