diff --git a/dev/mux.html b/dev/mux.html
index e0582af..f360f67 100644
--- a/dev/mux.html
+++ b/dev/mux.html
@@ -47,7 +47,7 @@
format = new Mediabunny.MkvOutputFormat();
format = new Mediabunny.MovOutputFormat();
format = new Mediabunny.Mp4OutputFormat({ fastStart: 'reserve' });
- format = new Mediabunny.Mp4OutputFormat({ });
+ format = new Mediabunny.MkvOutputFormat({ });
let target = new Mediabunny.BufferTarget();
/*
@@ -197,7 +197,7 @@ Testing... <00:17.350>One... <00:18.125>Two...
const p = document.createElement('p');
document.body.append(p);
- for (let i = 0; i < 1; i++) {
+ for (let i = 0; i < 100; i++) {
context.fillStyle = ['red', 'green', 'blue', 'yellow'][i % 4];
context.fillRect(canvas.width * Math.random(), canvas.height * Math.random(), canvas.width * Math.random(), canvas.height * Math.random());
diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts
index 01b2dde..3134d11 100644
--- a/src/matroska/matroska-muxer.ts
+++ b/src/matroska/matroska-muxer.ts
@@ -69,6 +69,7 @@ import { Writer } from '../writer';
import { EncodedPacket } from '../packet';
import { parseOpusIdentificationHeader } from '../codec-data';
import { AttachedFile } from '../metadata';
+import { Logging } from '../logging';
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
@@ -157,6 +158,7 @@ export class MatroskaMuxer extends Muxer {
private startTimestamp = Infinity;
private endTimestamp = -Infinity;
+ private warnedAboutTooNegativeTimestamp = false;
constructor(output: Output, format: MkvOutputFormat) {
super(output);
@@ -1191,6 +1193,15 @@ export class MatroskaMuxer extends Muxer {
const relativeTimestamp = msTimestamp - this.currentClusterStartMsTimestamp!;
if (relativeTimestamp < MIN_CLUSTER_TIMESTAMP_MS) {
// The block lies too far in the past, it's not representable within this cluster
+ if (!this.warnedAboutTooNegativeTimestamp) {
+ const formatName = this.format instanceof WebMOutputFormat ? 'WebM' : 'Matroska';
+ Logging._warn(
+ `Packets had to be discarded because their timestamp is too negative to represent in`
+ + ` ${formatName}.`,
+ );
+ this.warnedAboutTooNegativeTimestamp = true;
+ }
+
return;
}
@@ -1253,6 +1264,8 @@ export class MatroskaMuxer extends Muxer {
/** Creates a new Cluster element to contain media chunks. */
private createNewCluster(msTimestamp: number) {
+ msTimestamp = Math.max(0, msTimestamp); // Cluster timestamps cannot be negative
+
if (this.currentCluster) {
this.finalizeCurrentCluster();
}
@@ -1313,7 +1326,7 @@ export class MatroskaMuxer extends Muxer {
for (const [msTimestamp, trackDatas] of groupedAndSortedByTimestamp) {
assert(this.cues);
(this.cues.data as EBML[]).push({ id: EBMLId.CuePoint, data: [
- { id: EBMLId.CueTime, data: msTimestamp },
+ { id: EBMLId.CueTime, data: Math.max(0, msTimestamp) }, // CueTime is unsigned
// Create CueTrackPositions for each track that starts at this timestamp
...trackDatas.map((trackData) => {
return { id: EBMLId.CueTrackPositions, data: [
diff --git a/test/node/isobmff-muxer.test.ts b/test/node/isobmff-muxer.test.ts
index 03c68b3..ea7aa37 100644
--- a/test/node/isobmff-muxer.test.ts
+++ b/test/node/isobmff-muxer.test.ts
@@ -221,11 +221,11 @@ test('Non-zero start timestamp, fragmented MP4', async () => {
});
test('Negative start timestamps, regular MP4', async () => {
- await testNegativeTimestampRoundTrip([-1, 0, 1, 2, 3], 1, false);
+ await testNegativeTimestampRoundTrip(Array.from({ length: 50 }, (_, index) => (index - 10) / 10), 0.1, false);
});
test('Negative start timestamps, fragmented MP4', async () => {
- await testNegativeTimestampRoundTrip([-1, 0, 1, 2, 3], 1, true);
+ await testNegativeTimestampRoundTrip(Array.from({ length: 50 }, (_, index) => (index - 10) / 10), 0.1, true);
});
test('Wholly negative timestamps, regular MP4', async () => {
@@ -247,7 +247,7 @@ const testNegativeTimestampRoundTrip = async (
});
const source = new EncodedVideoPacketSource('vp8');
- output.addVideoTrack(source);
+ output.addVideoTrack(source, { frameRate: 10 });
await output.start();
@@ -272,23 +272,33 @@ const testNegativeTimestampRoundTrip = async (
const track = await input.getPrimaryVideoTrack();
assert(track);
+ const sink = new EncodedPacketSink(track);
const outputPackets: EncodedPacket[] = [];
- for await (const packet of new EncodedPacketSink(track).packets()) {
+ for await (const packet of sink.packets()) {
outputPackets.push(packet);
}
expect(outputPackets.map(packet => ({
- data: packet.data,
- type: packet.type,
timestamp: packet.timestamp,
duration: packet.duration,
}))).toEqual(inputPackets.map(packet => ({
- data: packet.data,
- type: packet.type,
timestamp: packet.timestamp,
duration: packet.duration,
})));
+
+ for (const inputPacket of inputPackets) {
+ const outputPacket = await sink.getPacket(inputPacket.timestamp);
+ assert(outputPacket);
+
+ expect({
+ timestamp: outputPacket.timestamp,
+ duration: outputPacket.duration,
+ }).toEqual({
+ timestamp: inputPacket.timestamp,
+ duration: inputPacket.duration,
+ });
+ }
};
test('PCM audio', async () => {
diff --git a/test/node/matroska-muxer.test.ts b/test/node/matroska-muxer.test.ts
index 3b94e85..1c23e71 100644
--- a/test/node/matroska-muxer.test.ts
+++ b/test/node/matroska-muxer.test.ts
@@ -9,6 +9,8 @@ import { BufferTarget } from '../../src/target.js';
import { MkvOutputFormat } from '../../src/output-format.js';
import { Conversion } from '../../src/conversion.js';
import { assert } from '../../src/misc.js';
+import { EncodedVideoPacketSource } from '../../src/media-source.js';
+import { EncodedPacket } from '../../src/packet.js';
const __dirname = new URL('.', import.meta.url).pathname;
@@ -61,3 +63,76 @@ test('Matroska muxer internally converts ADTS to AAC', async () => {
expect(count).toBe(4557);
});
+
+test('Negative start timestamps', async () => {
+ await testNegativeTimestampRoundTrip(
+ Array.from({ length: 50 }, (_, index) => (index - 10) / 10),
+ 0.1,
+ 10,
+ );
+});
+
+test('Wholly negative timestamps', async () => {
+ await testNegativeTimestampRoundTrip([-1, -0.9, -0.8, -0.7, -0.6], 0.1, 10);
+});
+
+const testNegativeTimestampRoundTrip = async (timestamps: number[], duration: number, frameRate: number) => {
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target: new BufferTarget(),
+ });
+
+ const source = new EncodedVideoPacketSource('vp8');
+ output.addVideoTrack(source, { frameRate });
+
+ await output.start();
+
+ const meta = { decoderConfig: { codec: 'vp8', codedWidth: 1280, codedHeight: 720 } };
+ const inputPackets = timestamps.map((timestamp, index) => new EncodedPacket(
+ new Uint8Array(1024).fill(index),
+ 'key',
+ timestamp,
+ duration,
+ ));
+
+ for (let i = 0; i < inputPackets.length; i++) {
+ await source.add(inputPackets[i]!, i === 0 ? meta : undefined);
+ }
+
+ await output.finalize();
+
+ using input = new Input({
+ source: new BufferSource(output.target.buffer!),
+ formats: ALL_FORMATS,
+ });
+
+ const track = await input.getPrimaryVideoTrack();
+ assert(track);
+ const sink = new EncodedPacketSink(track);
+
+ const outputPackets: EncodedPacket[] = [];
+ for await (const packet of sink.packets()) {
+ outputPackets.push(packet);
+ }
+
+ expect(outputPackets.map(packet => ({
+ timestamp: packet.timestamp,
+ duration: packet.duration,
+ }))).toEqual(inputPackets.map(packet => ({
+ timestamp: packet.timestamp,
+ duration: packet.duration,
+ })));
+
+ for (const inputPacket of inputPackets) {
+ const outputPacket = await sink.getPacket(inputPacket.timestamp);
+ assert(outputPacket);
+
+ expect({
+ timestamp: outputPacket.timestamp,
+ duration: outputPacket.duration,
+ }).toEqual({
+ timestamp: inputPacket.timestamp,
+ duration: inputPacket.duration,
+ });
+ }
+};