Compare commits

..
15 Commits
Author SHA1 Message Date
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
28 changed files with 313 additions and 109 deletions
+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.
+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.40.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.38.1",
"version": "1.40.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.39.2",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz",
"integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12077,7 +12077,7 @@
},
"packages/aac-encoder": {
"name": "@mediabunny/aac-encoder",
"version": "1.38.1",
"version": "1.40.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.40.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.40.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.40.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.40.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/aac-encoder",
"author": "Vanilagy",
"version": "1.38.1",
"version": "1.40.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.40.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.40.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.40.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();
}
}
+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) => {
+11 -3
View File
@@ -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;
+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,
]));
});
-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"],