Compare commits

..
23 Commits
Author SHA1 Message Date
Vanilagy b285360563 Sussy CI 2026-04-24 19:45:39 +02:00
Vanilagy 5e491eb0f9 Bump minor 2026-04-24 19:43:11 +02:00
Vanilagy 97f6b35cda Add special TXXX handling for ID3v2 2026-04-24 19:42:15 +02:00
Vanilagy 9969b92d51 Specify AAC codec in conversion code in AAC extension readme (closes #348) 2026-04-10 16:48:29 +02:00
Vanilagy e183e5022e Add new sponsors 2026-04-01 17:20:18 +02:00
Vanilagy d702409dd3 Bump-patch 2026-03-24 11:08:35 +01:00
Vanilagy cf45c10ff2 Add sponsors! 2026-03-23 11:44:25 +01:00
Vanilagy e03bb21c8a Fixed missing default values in Matroska demuxer (fixes #335) 2026-03-23 11:02:08 +01:00
Vanilagy 0b330cb513 Bump minor 2026-03-19 22:38:06 +01:00
Vanilagy 92c7831cf4 Fix incorrect reading of PAT 2026-03-19 16:06:55 +01:00
Vanilagy 9e9240dfc3 Merge branch 'main' of https://github.com/Vanilagy/mediabunny 2026-03-19 15:08:30 +01:00
Vanilagy fd39084e88 Simplify/fix 3d matrix rotation extraction (#330) 2026-03-19 15:08:29 +01:00
31d86331ec FLAC: Add appendOnly option for FlaxMuxer, fix FlacDemuxer reading it (#325)
* implement in FlacOutputFormatOptions

* implement in flac-muxer.ts

* Fix demuxer: Handle minimumFrameSize = 0, maxiumFrameSize = 0

* add a test

* Update flac-demuxer.ts

* Update flac.test.ts

* clean up comments for minima and maxima

* Add FlacOutputFormatOptions.appendOnly to docs

---------

Co-authored-by: Vanilagy <[email protected]>
2026-03-19 13:43:28 +00:00
Vanilagy 0d50526ff6 Fix incorrectly-written video rotation metadata even when rotation is baked into the video (fixes #329) 2026-03-19 14:25:40 +01:00
Vanilagy e693765d23 Fix failing test 2026-03-19 12:33:27 +01:00
Vanilagy 1da96f6d69 Add displayAspectWidth/displayAspectHeight only when they are necessary 2026-03-19 12:25:19 +01:00
Vanilagy 92ea645df4 Bump patch 2026-03-13 11:03:57 +01:00
Vanilagy 35e16ca23e Fix incorrect GOP timestamp verification logic for first GOP (fixes #321) 2026-03-13 10:45:34 +01:00
Vanilagy 87ed3a1934 Fix faulty MPEG-2 detection (fixes #322) 2026-03-12 21:43:14 +01:00
Vanilagy 0bfba89161 Bump patch 2026-03-11 16:29:54 +01:00
Vanilagy 93e9c256ab Maybe this helps a little 2026-03-11 16:28:44 +01:00
Vanilagy c4bce00c4f Bump minor 2026-03-09 20:04:57 +01:00
Vanilagy 05ae21e5ea Add EncodedPacketSink.getFirstKeyPacket() (used to fix #314) 2026-03-09 10:19:01 +01:00
35 changed files with 414 additions and 130 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
registry-url: https://registry.npmjs.org
- name: Update npm
run: npm install -g npm@latest
run: npm install -g npm@11.11.0
- name: Get package.json version
id: package-json-version
+3 -1
View File
@@ -104,6 +104,8 @@
},
*/
video: () => ({
//width: 640,
//rotate: 90,
//forceTranscode: true,
//forceTranscode: true,
//width: 1280,
@@ -189,7 +191,7 @@
},
trim: {
//start: 0,
//end: 10
end: 2
},
});
console.log(conversion);
+5 -1
View File
@@ -21,7 +21,11 @@
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet.timestamp);
console.log(packet.type, packet.timestamp);
if (packet.timestamp >= 5) {
break;
}
}
/*
+1
View File
@@ -17,6 +17,7 @@ export default withMermaid({
sitemap: {
hostname: 'https://mediabunny.dev',
},
lastUpdated: true,
head: [
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
['link', { rel: 'icon', type: 'image/svg+xml', href: '/mediabunny-logo.svg' }],
+5 -1
View File
@@ -97,9 +97,13 @@ await sink.getKeyPacket(5); // => EncodedPacket | null
When retrieving a packet using a timestamp, the last packet (in [presentation order](#decode-vs-presentation-order)) with a timestamp less than or equal to the search timestamp will be returned. The methods return `null` if there exists no such packet.
There is a special method for retrieving the first packet (in [decode order](#decode-vs-presentation-order)):
There are special methods for retrieving the first packet (in [decode order](#decode-vs-presentation-order)):
```ts
await sink.getFirstPacket(); // => EncodedPacket | null
// The first packet is typically a key frame, but this is not required.
// This method returns the first key frame:
await sink.getFirstKeyPacket(); // => EncodedPacket | null
```
The last packet (in [presentation order](#decode-vs-presentation-order)) can be retrieved like so:
```ts
+7
View File
@@ -303,9 +303,16 @@ const output = new Output({
The following options are available:
```ts
type FlacOutputFormatOptions = {
appendOnly?: boolean;
onFrame?: (data: Uint8Array, position: number) => unknown;
};
```
- `appendOnly`\
Configures the output to only append new data at the end, useful for live-streaming the file as it's being created. When enabled, the STREAMINFO block will not be finalized with accurate min/max block sizes, frame sizes, or total sample count, so don't use this option when you want to write out a clean file for later use.
::: info
This option ensures [append-only writing](#append-only-writing).
:::
- `onFrame`\
Will be called for each FLAC frame that is written.
+6
View File
@@ -109,9 +109,11 @@ const sponsors = {
],
individual: [
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
{ image: 'https://avatars.githubusercontent.com/u/11537072', name: 'Threema', url: 'https://threema.com/en' },
{ image: 'https://avatars.githubusercontent.com/u/82552321', name: 'Polotno', url: 'https://github.com/polotno-project' },
{ image: 'https://avatars.githubusercontent.com/u/489051', name: 'Roman Rädle', url: 'https://github.com/raedle' },
{ image: 'https://avatars.githubusercontent.com/u/197597', name: 'Christopher Chedeau', url: 'https://github.com/vjeux' },
{ image: 'https://avatars.githubusercontent.com/u/18653821', name: 'ZerGo0', url: 'https://github.com/ZerGo0' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ image: 'https://avatars.githubusercontent.com/u/4714175', name: 'Phoomparin Mano', url: 'https://github.com/heypoom' },
@@ -119,6 +121,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/536008', name: 'Satish Goda', url: 'https://github.com/satishgoda' },
{ image: 'https://avatars.githubusercontent.com/u/139718', name: 'Anton Kosiakin', url: 'https://github.com/deil' },
{ image: 'https://avatars.githubusercontent.com/u/56988069', name: 'SyhabouthAlex', url: 'https://github.com/SyhabouthAlex' },
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
@@ -135,6 +138,9 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/31102694', name: 'Aiden Liu', url: 'https://github.com/aidenlx' },
{ image: 'https://avatars.githubusercontent.com/u/41021374', name: 'arthco', url: 'https://github.com/arthtyagi' },
{ image: 'https://avatars.githubusercontent.com/u/5907357', name: 'Harvey Zhao', url: 'https://github.com/zhw2590582' },
{ image: 'https://avatars.githubusercontent.com/u/13042781', name: 'Anshul Bansal', url: 'https://github.com/akbansa' },
{ image: 'https://avatars.githubusercontent.com/u/43105172', name: 'zhmou', url: 'https://github.com/zhmou' },
{ image: 'https://avatars.githubusercontent.com/u/695166', name: 'Takenori Nakagawa', url: 'https://github.com/ww24' },
],
};
</script>
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://mediabunny.dev/sitemap.xml
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.38.1",
"version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.38.1",
"version": "1.41.0",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7751,9 +7751,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.38.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.38.0.tgz",
"integrity": "sha512-FiZtTWLY4rmEw4jBx8r5Tk+sE+PcBb03UYUZNv9DujEy9wdmX7JaQOvHNUZLVTxPWT0h+LYmWFnWKRkl4KmzyA==",
"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.38.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.38.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.38.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.38.1",
"version": "1.41.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.38.1",
"version": "1.41.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+3
View File
@@ -81,6 +81,9 @@ const output = new Output({
const conversion = await Conversion.init({
input,
output,
audio: {
codec: 'aac',
},
});
await conversion.execute();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/aac-encoder",
"author": "Vanilagy",
"version": "1.38.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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/ac3",
"author": "Vanilagy",
"version": "1.38.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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/flac-encoder",
"author": "Vanilagy",
"version": "1.38.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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.38.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",
+5 -2
View File
@@ -515,6 +515,9 @@ export class Conversion {
/**
* Whether this conversion, as it has been configured, is valid and can be executed. If this field is `false`, check
* the `discardedTracks` field for reasons.
*
* Note: a conversion having discarded tracks does not automatically mean it is invalid; if the remaining, utilized
* tracks make for a valid output file, the conversion is still allowed.
*/
isValid = false;
/** The list of tracks that are included in the output file. */
@@ -1083,6 +1086,8 @@ export class Conversion {
}
if (needsRerender) {
outputTrackRotation = 0; // Since the rotation is baked into the output
this._trackPromises.push((async () => {
await this._started;
@@ -1098,8 +1103,6 @@ export class Conversion {
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
const frameRate = trackOptions.frameRate;
outputTrackRotation = 0; // Since the rotation is baked into the output
let lastCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
let lastCanvasTimestamp: number | null = null;
let lastCanvasEndTimestamp: number | null = null;
+29 -4
View File
@@ -283,13 +283,38 @@ export class FlacDemuxer extends Demuxer {
// --> 6 bytes
const minimumHeaderLength = 6;
// If we read everything in readFlacFrameHeader, we read 16 bytes
const maximumHeaderSize = 16;
const maximumHeaderLength = 16;
// The shortest valid FLAC frame per RFC 9639:
// 6 bytes header (see minimumHeaderLength above)
// 2 bytes subframe (constant subframe with minimum bit depth,
// padded to byte boundary)
// 2 bytes footer (CRC-16)
// --> 10 bytes
const minimumFrameLength = 10;
// The longest valid FLAC frame per RFC 9639:
// https://www.rfc-editor.org/rfc/rfc9639.html#name-prediction
// https://www.rfc-editor.org/rfc/rfc9639.html#name-frame-structure
// maximumBlockSize * numberOfChannels * 4 bytes (max 32 bps verbatim)
// + 16 bytes header (see maximumHeaderSize above)
// + 2 bytes footer (CRC-16)
const maximumFrameLength = this.audioInfo.maximumBlockSize
* this.audioInfo.numberOfChannels
* 4
+ maximumHeaderLength
+ 2;
// Per RFC 9639, a value of 0 means "unknown" for frame sizes.
const effectiveMinFrameSize = this.audioInfo.minimumFrameSize || minimumFrameLength;
const effectiveMaxFrameSize = this.audioInfo.maximumFrameSize || maximumFrameLength;
const maximumSliceLength
= this.audioInfo.maximumFrameSize + maximumHeaderSize;
= effectiveMaxFrameSize + maximumHeaderLength;
const slice = await this.reader.requestSliceRange(
startPos,
this.audioInfo.minimumFrameSize,
maximumHeaderLength,
maximumSliceLength,
);
@@ -312,7 +337,7 @@ export class FlacDemuxer extends Demuxer {
// The next sync word is expected at earliest when `minimumFrameSize` is reached,
// we can skip over anything before that
slice.filePos = startPos + this.audioInfo.minimumFrameSize;
slice.filePos = startPos + effectiveMinFrameSize;
while (true) {
// Reached end of the file, packet is over
+70 -36
View File
@@ -50,6 +50,10 @@ export class FlacMuxer extends Muxer {
this.writer = output._writer;
this.format = format;
if (this.format._options.appendOnly) {
this.writer.ensureMonotonicity = true;
}
}
async start() {
@@ -165,7 +169,9 @@ export class FlacMuxer extends Muxer {
}
writeVorbisCommentAndPictureBlock() {
this.writer.seek(STREAMINFO_SIZE + FLAC_HEADER.byteLength);
if (!this.format._options.appendOnly) {
this.writer.seek(STREAMINFO_SIZE + FLAC_HEADER.byteLength);
}
if (metadataTagsAreEmpty(this.output._metadataTags)) {
this.metadataWritten = true;
return;
@@ -233,6 +239,30 @@ export class FlacMuxer extends Muxer {
descriptionBitstream.skipBits(103 + 64);
const bitsPerSample = descriptionBitstream.readBits(5) + 1;
this.bitsPerSample = bitsPerSample;
if (this.format._options.appendOnly) {
// Write STREAMINFO immediately since we can't seek back later.
this.writeHeader({
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
// Per RFC 9639, min/max block sizes can be looser than
// actual values, so we use the full valid range (16–65535).
// "The actual max block size MAY be smaller than what's
// listed, and the actual min (excluding last block) MAY be
// larger. This is because the encoder has to write these
// fields before receiving any input audio data and cannot
// know beforehand what block sizes it will use."
minimumBlockSize: 16,
maximumBlockSize: 65535,
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
// "A value of 0 signifies that the value is not known."
minimumFrameSize: 0,
maximumFrameSize: 0,
sampleRate: this.sampleRate,
channels: this.channels,
bitsPerSample: this.bitsPerSample,
totalSamples: 0,
});
}
}
if (!this.metadataWritten) {
@@ -251,8 +281,10 @@ export class FlacMuxer extends Muxer {
readCodedNumber(slice); // num
const blockSize = readBlockSize(slice, blockSizeOrUncommon);
this.blockSizes.push(blockSize);
this.frameSizes.push(packet.data.length);
if (!this.format._options.appendOnly) {
this.blockSizes.push(blockSize);
this.frameSizes.push(packet.data.length);
}
const startPos = this.writer.getPos();
this.writer.write(packet.data);
@@ -274,43 +306,45 @@ export class FlacMuxer extends Muxer {
async finalize(): Promise<void> {
const release = await this.mutex.acquire();
let minimumBlockSize = Infinity;
let maximumBlockSize = 0;
let minimumFrameSize = Infinity;
let maximumFrameSize = 0;
let totalSamples = 0;
for (let i = 0; i < this.blockSizes.length; i++) {
minimumFrameSize = Math.min(minimumFrameSize, this.frameSizes[i]!);
maximumFrameSize = Math.max(maximumFrameSize, this.frameSizes[i]!);
maximumBlockSize = Math.max(maximumBlockSize, this.blockSizes[i]!);
totalSamples += this.blockSizes[i]!;
if (!this.format._options.appendOnly) {
let minimumBlockSize = Infinity;
let maximumBlockSize = 0;
let minimumFrameSize = Infinity;
let maximumFrameSize = 0;
let totalSamples = 0;
for (let i = 0; i < this.blockSizes.length; i++) {
minimumFrameSize = Math.min(minimumFrameSize, this.frameSizes[i]!);
maximumFrameSize = Math.max(maximumFrameSize, this.frameSizes[i]!);
maximumBlockSize = Math.max(maximumBlockSize, this.blockSizes[i]!);
totalSamples += this.blockSizes[i]!;
// Excluding the last frame from block size calculation
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
// "The minimum block size (in samples) used in the stream, excluding the last block."
const isLastFrame = i === this.blockSizes.length - 1;
if (isLastFrame) {
continue;
// Excluding the last frame from block size calculation
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
// "The minimum block size (in samples) used in the stream, excluding the last block."
const isLastFrame = i === this.blockSizes.length - 1;
if (isLastFrame) {
continue;
}
minimumBlockSize = Math.min(minimumBlockSize, this.blockSizes[i]!);
}
minimumBlockSize = Math.min(minimumBlockSize, this.blockSizes[i]!);
assert(this.sampleRate !== null);
assert(this.channels !== null);
assert(this.bitsPerSample !== null);
this.writer.seek(4);
this.writeHeader({
minimumBlockSize,
maximumBlockSize,
minimumFrameSize,
maximumFrameSize,
sampleRate: this.sampleRate,
channels: this.channels,
bitsPerSample: this.bitsPerSample,
totalSamples,
});
}
assert(this.sampleRate !== null);
assert(this.channels !== null);
assert(this.bitsPerSample !== null);
this.writer.seek(4);
this.writeHeader({
minimumBlockSize,
maximumBlockSize,
minimumFrameSize,
maximumFrameSize,
sampleRate: this.sampleRate,
channels: this.channels,
bitsPerSample: this.bitsPerSample,
totalSamples,
});
release();
}
}
+54 -7
View File
@@ -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<string, string>)[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);
}
+1 -1
View File
@@ -530,7 +530,7 @@ export class MpegTsInputFormat extends InputFormat {
} else if (bytes[0] === 0x47 && bytes[TS_PACKET_SIZE + 16] === 0x47) {
// MPEG-TS with Forward Error Correction
return true;
} else if (bytes[4] === 0x47 && bytes[4 + TS_PACKET_SIZE] === 0x47) {
} else if (bytes[4] === 0x47 && bytes[4 + TS_PACKET_SIZE + 4] === 0x47) {
// MPEG-2-TS (DVHS)
return true;
}
+15 -13
View File
@@ -2947,15 +2947,23 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo
this.internalTrack.info.av1CodecInfo = firstPacket && extractAv1CodecInfoFromPacket(firstPacket.data);
}
return {
const config: VideoDecoderConfig = {
codec: extractVideoCodecString(this.internalTrack.info),
codedWidth: this.internalTrack.info.width,
codedHeight: this.internalTrack.info.height,
displayAspectWidth: this.internalTrack.info.squarePixelWidth,
displayAspectHeight: this.internalTrack.info.squarePixelHeight,
description: this.internalTrack.info.codecDescription ?? undefined,
colorSpace: this.internalTrack.info.colorSpace ?? undefined,
};
if (
this.internalTrack.info.width !== this.internalTrack.info.squarePixelWidth
|| this.internalTrack.info.height !== this.internalTrack.info.squarePixelHeight
) {
config.displayAspectWidth = this.internalTrack.info.squarePixelWidth;
config.displayAspectHeight = this.internalTrack.info.squarePixelHeight;
}
return config;
})();
}
}
@@ -3169,22 +3177,16 @@ const offsetFragmentTrackDataByTimestamp = (trackData: FragmentTrackData, timest
/** Extracts the rotation component from a transformation matrix, in degrees. */
const extractRotationFromMatrix = (matrix: TransformationMatrix) => {
const [m11, , , m21] = matrix;
const [a, b] = matrix; // (1, 0) projects onto (a, b), so that's all we need
const scaleX = Math.hypot(m11, m21);
const radians = Math.atan2(b, a);
const cosTheta = m11 / scaleX;
const sinTheta = m21 / scaleX;
// Invert the rotation because matrices are post-multiplied in ISOBMFF
const result = -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
if (!Number.isFinite(result)) {
if (!Number.isFinite(radians)) {
// Can happen if the entire matrix is 0, for example
return 0;
}
return result;
return radians * (180 / Math.PI);
};
const sampleTableIsEmpty = (sampleTable: SampleTable) => {
+20 -12
View File
@@ -196,6 +196,7 @@ type InternalTrack = {
defaultDurationNs: number | null;
name: string | null;
languageCode: string;
hasLanguageBcp47: boolean;
decodingInstructions: DecodingInstruction[];
info:
@@ -1009,7 +1010,8 @@ export class MatroskaDemuxer extends Demuxer {
defaultDuration: null,
defaultDurationNs: null,
name: null,
languageCode: UNDETERMINED_LANGUAGE,
languageCode: 'eng', // The default in Matroska
hasLanguageBcp47: false,
decodingInstructions: [],
info: null,
@@ -1086,11 +1088,7 @@ export class MatroskaDemuxer extends Demuxer {
const inputTrack = new InputVideoTrack(this.input, new MatroskaVideoTrackBacking(videoTrack));
this.currentTrack.inputTrack = inputTrack;
this.currentSegment.tracks.push(this.currentTrack);
} else if (
this.currentTrack.info.type === 'audio'
&& this.currentTrack.info.numberOfChannels !== -1
&& this.currentTrack.info.sampleRate !== -1
) {
} else if (this.currentTrack.info.type === 'audio') {
if (codecIdWithoutSuffix === CODEC_STRING_MAP.aac) {
this.currentTrack.info.codec = 'aac';
this.currentTrack.info.aacCodecInfo = {
@@ -1183,8 +1181,8 @@ export class MatroskaDemuxer extends Demuxer {
} else if (type === 2) {
this.currentTrack.info = {
type: 'audio',
numberOfChannels: -1,
sampleRate: -1,
numberOfChannels: 1, // Default value
sampleRate: 8000, // Default value
bitDepth: -1,
codec: null,
codecDescription: null,
@@ -1263,7 +1261,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.Language: {
if (!this.currentTrack) break;
if (this.currentTrack.languageCode !== UNDETERMINED_LANGUAGE) {
if (this.currentTrack.hasLanguageBcp47) {
// LanguageBCP47 was present, which takes precedence
break;
}
@@ -1290,6 +1288,8 @@ export class MatroskaDemuxer extends Demuxer {
} else {
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
}
this.currentTrack.hasLanguageBcp47 = true;
}; break;
case EBMLId.Video: {
@@ -2430,7 +2430,7 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid
firstPacket = await this.getFirstPacket({});
}
return {
const config: VideoDecoderConfig = {
codec: extractVideoCodecString({
width: this.internalTrack.info.width,
height: this.internalTrack.info.height,
@@ -2453,11 +2453,19 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid
}),
codedWidth: this.internalTrack.info.width,
codedHeight: this.internalTrack.info.height,
displayAspectWidth: this.internalTrack.info.squarePixelWidth,
displayAspectHeight: this.internalTrack.info.squarePixelHeight,
description: this.internalTrack.info.codecDescription ?? undefined,
colorSpace: this.internalTrack.info.colorSpace ?? undefined,
};
if (
this.internalTrack.info.width !== this.internalTrack.info.squarePixelWidth
|| this.internalTrack.info.height !== this.internalTrack.info.squarePixelHeight
) {
config.displayAspectWidth = this.internalTrack.info.squarePixelWidth;
config.displayAspectHeight = this.internalTrack.info.squarePixelHeight;
}
return config;
})();
}
}
+18 -1
View File
@@ -144,6 +144,23 @@ export class EncodedPacketSink {
return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options);
}
/** Retrieves the track's first key packet (in decode order), or null if it has no key packets. */
async getFirstKeyPacket(options: PacketRetrievalOptions = {}) {
validatePacketRetrievalOptions(options);
const firstPacket = await this.getFirstPacket(options);
if (!firstPacket) {
return null;
}
if (firstPacket.type === 'key') {
// Great
return firstPacket;
}
return this.getNextKeyPacket(firstPacket, options);
}
/**
* Retrieves the packet corresponding to the given timestamp, in seconds. More specifically, returns the last packet
* (in presentation order) with a start timestamp less than or equal to the given timestamp. This method can be
@@ -476,7 +493,7 @@ export abstract class BaseMediaSampleSink<
const packetSink = this._createPacketSink();
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
?? await packetSink.getFirstPacket();
?? await packetSink.getFirstKeyPacket({ verifyKeyPackets: true });
let currentPacket: EncodedPacket | null = keyPacket;
+9 -4
View File
@@ -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<string, string>`.
* - 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<string, string | Uint8Array | RichImageData | AttachedFile | null>;
raw?: Record<string, string | Uint8Array | RichImageData | AttachedFile | Record<string, string> | 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<string, string>, or null.',
);
}
}
+7
View File
@@ -1009,3 +1009,10 @@ export const clearIntervalUnthrottled = (timer: UnthrottledTimerHandle) => {
timerId: timer.id,
} satisfies UnthrottledTimerMessage);
};
export const isRecordStringString = (value: unknown): value is Record<string, string> => {
return value !== null
&& typeof value === 'object'
&& Object.getPrototypeOf(value) === Object.prototype
&& Object.values(value).every(x => typeof x === 'string');
};
+12 -7
View File
@@ -161,10 +161,10 @@ export class MpegTsDemuxer extends Demuxer {
// MPEG-TS with Forward Error Correction
this.packetOffset = 0;
this.packetStride = TS_PACKET_SIZE + 16;
} else if (startingBytes[4] === 0x47 && startingBytes[4 + TS_PACKET_SIZE] === 0x47) {
} else if (startingBytes[4] === 0x47 && startingBytes[4 + TS_PACKET_SIZE + 4] === 0x47) {
// MPEG-2-TS (DVHS)
this.packetOffset = 4;
this.packetStride = TS_PACKET_SIZE;
this.packetStride = TS_PACKET_SIZE + 4;
} else {
throw new Error('Unreachable.');
}
@@ -236,12 +236,13 @@ export class MpegTsDemuxer extends Demuxer {
while (8 * (sectionLength + BYTES_BEFORE_SECTION_LENGTH) - bitstream.pos > BITS_IN_CRC_32) {
const programNumber = bitstream.readBits(16);
bitstream.skipBits(3); // Reserved
const id = bitstream.readBits(13);
if (programNumber !== 0) {
if (programMapPid !== null) {
throw new Error('Only files with a single program are supported.');
} else {
programMapPid = bitstream.readBits(13);
programMapPid = id;
}
}
}
@@ -1031,8 +1032,6 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking {
return null;
}
// result.packet.randomAccessIndicator = 1; // Assume the first packet is always a key packet
const packet = this.createEncodedPacket(result.packet, result.duration, options);
this.packetBuffers.set(packet, buffer);
this.packetSectionStarts.set(packet, result.packet.sectionStartPos);
@@ -1514,10 +1513,16 @@ class MpegTsVideoTrackBacking extends MpegTsTrackBacking implements InputVideoTr
}),
codedWidth: this.elementaryStream.info.width,
codedHeight: this.elementaryStream.info.height,
displayAspectWidth: this.elementaryStream.info.squarePixelWidth,
displayAspectHeight: this.elementaryStream.info.squarePixelHeight,
colorSpace: this.elementaryStream.info.colorSpace,
};
if (
this.elementaryStream.info.width !== this.elementaryStream.info.squarePixelWidth
|| this.elementaryStream.info.height !== this.elementaryStream.info.squarePixelHeight
) {
this.decoderConfig.displayAspectWidth = this.elementaryStream.info.squarePixelWidth;
this.decoderConfig.displayAspectHeight = this.elementaryStream.info.squarePixelHeight;
}
}
override getCodec(): VideoCodec {
+21 -18
View File
@@ -46,12 +46,16 @@ export abstract class Muxer {
private trackTimestampInfo = new WeakMap<OutputTrack, {
maxTimestamp: number;
maxTimestampBeforeLastKeyPacket: number;
maxTimestampBeforeLastKeyPacket: number | null;
}>();
protected validateAndNormalizeTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyPacket: boolean) {
timestampInSeconds += track.source._timestampOffset;
if (timestampInSeconds < 0) {
throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
}
let timestampInfo = this.trackTimestampInfo.get(track);
if (!timestampInfo) {
if (!isKeyPacket) {
@@ -60,29 +64,28 @@ export abstract class Muxer {
timestampInfo = {
maxTimestamp: timestampInSeconds,
maxTimestampBeforeLastKeyPacket: timestampInSeconds,
maxTimestampBeforeLastKeyPacket: null,
};
this.trackTimestampInfo.set(track, timestampInfo);
}
} else {
if (isKeyPacket) {
timestampInfo.maxTimestampBeforeLastKeyPacket = timestampInfo.maxTimestamp;
}
if (timestampInSeconds < 0) {
throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
}
if (
timestampInfo.maxTimestampBeforeLastKeyPacket !== null
&& timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyPacket
) {
throw new Error(
`Timestamps cannot be smaller than the largest timestamp of the previous GOP (a GOP begins with a`
+ ` key packet and ends right before the next key packet). Got ${timestampInSeconds}s, but largest`
+ ` timestamp is ${timestampInfo.maxTimestampBeforeLastKeyPacket}s.`,
);
}
if (isKeyPacket) {
timestampInfo.maxTimestampBeforeLastKeyPacket = timestampInfo.maxTimestamp;
timestampInfo.maxTimestamp = Math.max(timestampInfo.maxTimestamp, timestampInSeconds);
}
if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyPacket) {
throw new Error(
`Timestamps cannot be smaller than the largest timestamp of the previous GOP (a GOP begins with a key`
+ ` packet and ends right before the next key packet). Got ${timestampInSeconds}s, but largest`
+ ` timestamp is ${timestampInfo.maxTimestampBeforeLastKeyPacket}s.`,
);
}
timestampInfo.maxTimestamp = Math.max(timestampInfo.maxTimestamp, timestampInSeconds);
return timestampInSeconds;
}
}
+10
View File
@@ -928,6 +928,13 @@ export class AdtsOutputFormat extends OutputFormat {
* @public
*/
export type FlacOutputFormatOptions = {
/**
* Configures the output to only append new data at the end, useful for live-streaming the file as it's being
* created. When enabled, the STREAMINFO block will not be finalized with accurate min/max block sizes, frame sizes,
* or total sample count, so don't use this option when you want to write out a clean file for later use.
*/
appendOnly?: boolean;
/**
* Will be called for each FLAC frame that is written.
*
@@ -951,6 +958,9 @@ export class FlacOutputFormat extends OutputFormat {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.appendOnly !== undefined && typeof options.appendOnly !== 'boolean') {
throw new TypeError('options.appendOnly, when provided, must be a boolean.');
}
super();
+49
View File
@@ -0,0 +1,49 @@
import { ALL_FORMATS } from '../../src/input-format.js';
import { Input } from '../../src/input.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
import { Output } from '../../src/output.js';
import { BufferSource, UrlSource } from '../../src/source.js';
import { expect, test } from 'vitest';
import { BufferTarget } from '../../src/target.js';
import { Conversion } from '../../src/conversion.js';
import { assert } from '../../src/misc.js';
test('Rotation is baked-in when rerendering', async () => {
using input = new Input({
source: new UrlSource('/rotate-buck-bunny.mp4'),
formats: ALL_FORMATS,
});
const ogTrack = await input.getPrimaryVideoTrack();
assert(ogTrack);
expect(ogTrack.rotation).toBe(90);
expect(ogTrack.codedWidth).toBe(1920);
expect(ogTrack.codedHeight).toBe(1080);
expect(ogTrack.displayWidth).toBe(1080);
expect(ogTrack.displayHeight).toBe(1920);
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({ input, output, video: {
width: 320,
} });
await conversion.execute();
using newInput = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const track = await newInput.getPrimaryVideoTrack();
assert(track);
expect(track.codedWidth).toBe(320);
expect(track.codedHeight).toBe(570);
expect(track.displayWidth).toBe(320);
expect(track.displayHeight).toBe(570);
expect(track.rotation).toBe(0);
});
+2 -2
View File
@@ -87,8 +87,8 @@ test('Pixel aspect ratio transcode conversion', async () => {
expect(mp4.snapshot.squarePixelHeight).toBe(mp4.snapshot.codedHeight);
expect(mp4.snapshot.displayWidth).toBe(mp4.snapshot.codedWidth);
expect(mp4.snapshot.displayHeight).toBe(mp4.snapshot.codedHeight);
expect(mp4.snapshot.decoderDisplayAspectWidth).toBe(mp4.snapshot.codedWidth);
expect(mp4.snapshot.decoderDisplayAspectHeight).toBe(mp4.snapshot.codedHeight);
expect(mp4.snapshot.decoderDisplayAspectWidth).toBeUndefined();
expect(mp4.snapshot.decoderDisplayAspectHeight).toBeUndefined();
expect(mp4.snapshot.codedWidth).toBe(source.squarePixelWidth);
expect(mp4.snapshot.codedHeight).toBe(source.squarePixelHeight);
+30
View File
@@ -254,3 +254,33 @@ test('can re-mux a .flac', async () => {
expect(otherInputDecoderConfig).toEqual(otherOutputDecoderConfig);
});
test('appendOnly writes correct STREAMINFO header', async () => {
const filePath = path.join(__dirname, '..', 'public/sample.flac');
using input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
const target = new BufferTarget();
const output = new Output({
format: new FlacOutputFormat({ appendOnly: true }),
target,
});
const conversion = await Conversion.init({ input, output });
await conversion.execute();
assert(target.buffer);
const bytes = new Uint8Array(target.buffer);
// STREAMINFO: min_block=16, max_block=65535, min_frame=0, max_frame=0
expect(bytes.slice(8, 18)).toEqual(new Uint8Array([
// minimum_block_size=16
0x00, 0x10,
// maximum_block_size=65535
0xFF, 0xFF,
// minimum_frame_size=0
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]));
});
+12
View File
@@ -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 () => {
-4
View File
@@ -46,8 +46,6 @@ test('MPEG-TS metadata reading', async () => {
codec: 'avc1.640020',
codedWidth: 720,
codedHeight: 720,
displayAspectWidth: 720,
displayAspectHeight: 720,
colorSpace: {
primaries: 'bt2020',
transfer: 'hlg',
@@ -561,8 +559,6 @@ test('MPEG-TS with HEVC video', async () => {
codec: 'hev1.1.6.L120.90',
codedWidth: 1920,
codedHeight: 1080,
displayAspectWidth: 1920,
displayAspectHeight: 1080,
colorSpace: {
primaries: 'bt709',
transfer: 'bt709',
Binary file not shown.
+1 -1
View File
@@ -6,7 +6,7 @@
"composite": true,
"noEmit": false,
"paths": {
"mediabunny": ["./src/index.ts"],
//"mediabunny": ["./src/index.ts"], So that the direct source imports are preferred
"@mediabunny/ac3": ["./packages/ac3/src/index.ts"],
"@mediabunny/aac-encoder": ["./packages/aac-encoder/src/index.ts"],
"@mediabunny/flac-encoder": ["./packages/flac-encoder/src/index.ts"],