diff --git a/dev/mux.html b/dev/mux.html
index 1c39112..e0582af 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.Mp4OutputFormat({ });
let target = new Mediabunny.BufferTarget();
/*
@@ -191,17 +191,17 @@ Testing... <00:17.350>One... <00:18.125>Two...
9. justify (bottom, right).
`;
- subtitleSource.add(simpleWebvttFile);
- subtitleSource.close();
+ //subtitleSource.add(simpleWebvttFile);
+ //subtitleSource.close();
const p = document.createElement('p');
document.body.append(p);
- for (let i = 0; i < 100; i++) {
+ for (let i = 0; i < 1; 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());
- await videoSource.add(i / 10, 1 / 10);
+ await videoSource.add(i / 10 - 1, 1 / 10);
p.textContent = i;
}
@@ -211,7 +211,7 @@ Testing... <00:17.350>One... <00:18.125>Two...
let length = 10;
let slicedAudioBuffer = sliceAudioBuffer(audioBuffer, length, audioContext);
- await audioSource.add(slicedAudioBuffer);
+ //await audioSource.add(slicedAudioBuffer);
await output.finalize();
diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts
index 1572163..7882ae2 100644
--- a/examples/media-player/media-player.ts
+++ b/examples/media-player/media-player.ts
@@ -128,6 +128,9 @@ const initMediaPlayer = async (resource: File | string) => {
isRelativeToUnixEpoch = (await Promise.all(tracks.map(t => t.isRelativeToUnixEpoch()))).some(Boolean);
playbackTimeAtStart = firstTimestamp;
+ // For degenerate cases where the end timestamp is less than 0
+ endTimestamp = Math.max(firstTimestamp, endTimestamp);
+
// Configure the time display elements accordingly
const timestampFontSize = isRelativeToUnixEpoch ? '12px' : '';
const timestampWhiteSpace = isRelativeToUnixEpoch ? 'pre' : '';
diff --git a/src/index.ts b/src/index.ts
index ebb36a5..46f07b0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,6 +73,7 @@ export {
AudioSource,
SubtitleSource,
AudioBufferSource,
+ type AudioBufferSourceOptions,
AudioSampleSource,
CanvasSource,
EncodedAudioPacketSource,
diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts
index ae07a7c..8f33de4 100644
--- a/src/isobmff/isobmff-boxes.ts
+++ b/src/isobmff/isobmff-boxes.ts
@@ -9,6 +9,7 @@
import {
toUint8Array,
assert,
+ isI32,
isU32,
last,
TransformationMatrix,
@@ -356,7 +357,8 @@ export const styp = () => box('styp', [
/** Segment Index Box */
export const sidx = (muxer: IsobmffMuxer, referencedSize: number) => {
- let duration = muxer.maxWrittenEndTimestamp - muxer.minWrittenTimestamp;
+ const earliestPresentationTime = Math.max(0, muxer.minWrittenTimestamp);
+ let duration = Math.max(0, muxer.maxWrittenEndTimestamp - earliestPresentationTime);
if (!Number.isFinite(duration)) {
duration = 0;
}
@@ -364,7 +366,7 @@ export const sidx = (muxer: IsobmffMuxer, referencedSize: number) => {
return fullBox('sidx', 1, 0, [
u32(1), // Reference ID
u32(GLOBAL_TIMESCALE), // Timescale
- u64(intoTimescale(muxer.minWrittenTimestamp, GLOBAL_TIMESCALE)), // Earliest presentation time
+ u64(intoTimescale(earliestPresentationTime, GLOBAL_TIMESCALE)), // Earliest presentation time
u64(0), // First offset
u16(0), // Reserved
u16(1), // Reference count
@@ -402,8 +404,12 @@ export const mvhd = (
0,
...trackDatas
.map(trackData => (
- intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE)
- + intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE)
+ // Round separately to match the edit list
+ Math.max(
+ 0,
+ intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE)
+ + intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE),
+ )
)),
);
const nextTrackId = Math.max(0, ...trackDatas.map(x => x.track.id)) + 1;
@@ -459,11 +465,11 @@ const presentationSpan = (trackData: IsobmffTrackData) => {
*/
export const trak = (trackData: IsobmffTrackData, creationTime: number) => {
const trackMetadata = getTrackMetadata(trackData);
- const needsEditList = trackData.startTimestampOffset !== null && trackData.startTimestampOffset > 0;
+ const needsEditList = trackData.startTimestampOffset !== null && trackData.startTimestampOffset !== 0;
return box('trak', undefined, [
tkhd(trackData, creationTime),
- needsEditList ? edts(trackData, trackData.startTimestampOffset!) : null,
+ needsEditList ? edts(trackData) : null,
mdia(trackData, creationTime),
trackMetadata.name !== undefined
? box('udta', undefined, [
@@ -480,8 +486,12 @@ export const tkhd = (
trackData: IsobmffTrackData,
creationTime: number,
) => {
- const durationInGlobalTimescale = intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE)
- + intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE);
+ // Round separately to match the edit list
+ const durationInGlobalTimescale = Math.max(
+ 0,
+ intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE)
+ + intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE),
+ );
const needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale);
const u32OrU64 = needsU64 ? u64 : u32;
@@ -528,29 +538,63 @@ export const tkhd = (
};
/** Edit Box: Specifies edits to the track's media. */
-export const edts = (trackData: IsobmffTrackData, offset: number) => {
- const startOffset = intoTimescale(offset, GLOBAL_TIMESCALE);
- const mediaDuration = intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE);
+export const edts = (trackData: IsobmffTrackData) => {
+ const offset = trackData.startTimestampOffset;
+ assert(offset !== null);
- const needs64Bits = !isU32(startOffset) || !isU32(mediaDuration);
- const u32OrU64 = needs64Bits ? u64 : u32;
- const i32OrI64 = needs64Bits ? i64 : i32;
+ if (offset > 0) {
+ // Positive offset: empty segment at the start, then the full media afterwards
- return box('edts', undefined, [
- fullBox('elst', needs64Bits ? 1 : 0, 0, [
- u32(2), // Entry count
+ const startOffset = intoTimescale(offset, GLOBAL_TIMESCALE);
+ const mediaDuration = intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE);
- // #1
- u32OrU64(startOffset), // Segment duration
- i32OrI64(-1), // Media time
- fixed_16_16(1), // Media rate
+ const needs64Bits = !isU32(startOffset) || !isU32(mediaDuration);
+ const u32OrU64 = needs64Bits ? u64 : u32;
+ const i32OrI64 = needs64Bits ? i64 : i32;
- // #2
- u32OrU64(mediaDuration), // Segment duration
- i32OrI64(0), // Media time
- fixed_16_16(1), // Media rate
- ]),
- ]);
+ return box('edts', undefined, [
+ fullBox('elst', needs64Bits ? 1 : 0, 0, [
+ u32(2), // Entry count
+
+ // #1
+ u32OrU64(startOffset), // Segment duration
+ i32OrI64(-1), // Media time
+ fixed_16_16(1), // Media rate
+
+ // #2
+ u32OrU64(mediaDuration), // Segment duration
+ i32OrI64(0), // Media time
+ fixed_16_16(1), // Media rate
+ ]),
+ ]);
+ } else {
+ // Negative offset: the negative section of the media is trimmed off
+
+ const mediaTime = intoTimescale(-offset, trackData.timescale);
+ // Not the entire media is visible.
+ // For fragmented files, this value is zero, which simply means "unknown duration" in this case. Spec:
+ // "the segment_duration of this edit may be zero"
+ const mediaDuration = Math.max(
+ 0,
+ intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE)
+ + intoTimescale(offset, GLOBAL_TIMESCALE),
+ );
+
+ const needs64Bits = !isI32(mediaTime) || !isU32(mediaDuration);
+ const u32OrU64 = needs64Bits ? u64 : u32;
+ const i32OrI64 = needs64Bits ? i64 : i32;
+
+ return box('edts', undefined, [
+ fullBox('elst', needs64Bits ? 1 : 0, 0, [
+ u32(1), // Entry count
+
+ // #1
+ u32OrU64(mediaDuration), // Segment duration
+ i32OrI64(mediaTime), // Media time
+ fixed_16_16(1), // Media rate
+ ]),
+ ]);
+ }
};
/** Media Box: Describes and define a track's media type and sample data. */
@@ -565,7 +609,7 @@ export const mdhd = (
trackData: IsobmffTrackData,
creationTime: number,
) => {
- // Since the duration represents the raw media duration, edit list offsets are not taken into account here
+ // Since _this_ duration represents the raw media duration, edit list offsets are not taken into account here
const localDuration = intoTimescale(
presentationSpan(trackData),
trackData.timescale,
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index 5fa279a..347461b 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -953,11 +953,6 @@ export class IsobmffDemuxer extends Demuxer {
: readI32Be(slice);
const mediaRate = readFixed_16_16(slice);
- if (segmentDuration === 0) {
- // Don't care
- continue;
- }
-
if (relevantEntryFound) {
Logging._warn(
'Unsupported edit list: multiple edits are not currently supported. Only using first edit.',
diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts
index 1e3006c..7b21fa3 100644
--- a/src/isobmff/isobmff-muxer.ts
+++ b/src/isobmff/isobmff-muxer.ts
@@ -139,7 +139,7 @@ export type IsobmffTrackData = {
info: {
config: SubtitleConfig;
};
- lastCueEndTimestamp: number;
+ lastCueEndTimestamp: number | null;
cueQueue: SubtitleCue[];
nextSourceId: number;
cueToSourceId: WeakMap;
@@ -613,7 +613,7 @@ export class IsobmffMuxer extends Muxer {
compactlyCodedChunkTable: [],
closed: false,
- lastCueEndTimestamp: 0,
+ lastCueEndTimestamp: null,
cueQueue: [],
nextSourceId: 0,
cueToSourceId: new WeakMap(),
@@ -790,6 +790,8 @@ export class IsobmffMuxer extends Muxer {
// overlapping samples require special logic. The algorithm produces the format specified in ISO 14496-30.
while (trackData.cueQueue.length > 0) {
+ trackData.lastCueEndTimestamp ??= Math.min(0, trackData.cueQueue[0]!.timestamp);
+
const timestamps = new Set([]);
for (const cue of trackData.cueQueue) {
assert(cue.timestamp <= until);
@@ -904,10 +906,10 @@ export class IsobmffMuxer extends Muxer {
}
if (trackData.type === 'audio' && trackData.info.requiresPcmTransformation) {
- if (!this.isFragmented) {
- // The first timestamp is the lowest
- trackData.startTimestampOffset ??= trackData.timestampProcessingQueue[0]!.timestamp;
- }
+ assert(!this.isFragmented);
+
+ // The first timestamp is the lowest
+ trackData.startTimestampOffset ??= trackData.timestampProcessingQueue[0]!.timestamp;
let totalDuration = 0;
@@ -936,7 +938,9 @@ export class IsobmffMuxer extends Muxer {
const sortedTimestamps = trackData.timestampProcessingQueue.map(x => x.timestamp).sort((a, b) => a - b);
- if (!this.isFragmented) {
+ if (this.isFragmented) {
+ trackData.startTimestampOffset ??= Math.min(sortedTimestamps[0]!, 0);
+ } else {
trackData.startTimestampOffset ??= sortedTimestamps[0]!;
}
@@ -1292,16 +1296,22 @@ export class IsobmffMuxer extends Muxer {
let fragmentStartTimestamp = Infinity;
for (let i = 0; i < tracksInFragment.length; i++) {
const trackData = tracksInFragment[i]!;
+ assert(trackData.currentChunk);
+ assert(trackData.startTimestampOffset !== null);
- trackData.currentChunk!.offset = currentPos;
- trackData.currentChunk!.moofOffset = moofOffset;
- trackData.currentChunk!.trafIndex = i;
+ trackData.currentChunk.offset = currentPos;
+ trackData.currentChunk.moofOffset = moofOffset;
+ trackData.currentChunk.trafIndex = i;
+ trackData.currentChunk.startTimestamp -= trackData.startTimestampOffset;
- for (const sample of trackData.currentChunk!.samples) {
+ for (const sample of trackData.currentChunk.samples) {
currentPos += sample.size;
+
+ sample.timestamp -= trackData.startTimestampOffset;
+ sample.decodeTimestamp -= trackData.startTimestampOffset;
}
- fragmentStartTimestamp = Math.min(fragmentStartTimestamp, trackData.currentChunk!.startTimestamp);
+ fragmentStartTimestamp = Math.min(fragmentStartTimestamp, trackData.currentChunk.startTimestamp);
}
const mdatSize = currentPos - mdatStartPos;
diff --git a/src/media-source.ts b/src/media-source.ts
index 5260e12..90c0790 100644
--- a/src/media-source.ts
+++ b/src/media-source.ts
@@ -1340,8 +1340,8 @@ export class CanvasSource extends VideoSource {
* to respect writer and encoder backpressure.
*/
add(timestamp: number, duration = 0, encodeOptions?: VideoEncoderEncodeOptions) {
- if (!Number.isFinite(timestamp) || timestamp < 0) {
- throw new TypeError('timestamp must be a non-negative number.');
+ if (!Number.isFinite(timestamp)) {
+ throw new TypeError('timestamp must be a finite number.');
}
if (!Number.isFinite(duration) || duration < 0) {
throw new TypeError('duration must be a non-negative number.');
@@ -2486,6 +2486,19 @@ export class AudioSampleSource extends AudioSource {
}
}
+/**
+ * Options for {@link AudioBufferSource}.
+ * @group Media sources
+ * @public
+ */
+export type AudioBufferSourceOptions = {
+ /**
+ * The timestamp of the first `AudioBuffer`, in seconds. Subsequent buffers are placed directly after the previous
+ * one. Defaults to 0.
+ */
+ startTimestamp?: number;
+};
+
/**
* This source can be used to add audio data from an AudioBuffer to the output track. This is useful when working with
* the Web Audio API.
@@ -2496,23 +2509,30 @@ export class AudioBufferSource extends AudioSource {
/** @internal */
private _encoder: AudioEncoderWrapper;
/** @internal */
- private _accumulatedTime = 0;
+ private _accumulatedTime: number;
/**
* Creates a new {@link AudioBufferSource} whose `AudioBuffer` instances are encoded according to the specified
- * {@link AudioEncodingConfig}.
+ * {@link AudioEncodingConfig} and {@link AudioBufferSourceOptions}.
*/
- constructor(encodingConfig: AudioEncodingConfig) {
+ constructor(encodingConfig: AudioEncodingConfig, options: AudioBufferSourceOptions = {}) {
validateAudioEncodingConfig(encodingConfig);
+ if (typeof options !== 'object' || !options) {
+ throw new TypeError('options must be an object.');
+ }
+ if (options.startTimestamp !== undefined && !Number.isFinite(options.startTimestamp)) {
+ throw new TypeError('options.startTimestamp, when provided, must be a finite number.');
+ }
super(encodingConfig.codec);
this._encoder = new AudioEncoderWrapper(this, encodingConfig);
+ this._accumulatedTime = options.startTimestamp ?? 0;
}
/**
- * Converts an AudioBuffer to audio samples, encodes them and adds them to the output. The first AudioBuffer will
- * be played at timestamp 0, and any subsequent AudioBuffer will have a timestamp equal to the total duration of
- * all previous AudioBuffers.
+ * Converts an AudioBuffer to audio samples, encodes them and adds them to the output. The first `AudioBuffer` will
+ * be played at the configured start timestamp (the default is 0), and each subsequent `AudioBuffer` will be placed
+ * directly after the previous one.
*
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
* to respect writer and encoder backpressure.
diff --git a/src/misc.ts b/src/misc.ts
index ac89a48..fc3ac1d 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -42,6 +42,10 @@ export const isU32 = (value: number) => {
return value >= 0 && value < 2 ** 32;
};
+export const isI32 = (value: number) => {
+ return value >= -(2 ** 31) && value < 2 ** 31;
+};
+
/** Reads an exponential-Golomb universal code from a Bitstream. */
export const readExpGolomb = (bitstream: Bitstream) => {
let leadingZeroBits = 0;
diff --git a/src/muxer.ts b/src/muxer.ts
index b945770..894dab6 100644
--- a/src/muxer.ts
+++ b/src/muxer.ts
@@ -43,10 +43,6 @@ export abstract class Muxer {
}>();
protected validateTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyPacket: boolean) {
- if (timestampInSeconds < 0) {
- throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
- }
-
let timestampInfo = this.trackTimestampInfo.get(track);
if (!timestampInfo) {
if (!isKeyPacket) {
diff --git a/test/node/isobmff-muxer.test.ts b/test/node/isobmff-muxer.test.ts
index e6594da..03c68b3 100644
--- a/test/node/isobmff-muxer.test.ts
+++ b/test/node/isobmff-muxer.test.ts
@@ -220,6 +220,77 @@ test('Non-zero start timestamp, fragmented MP4', async () => {
expect(durations).toEqual([0.1, 0.1, 0.1, 0.1]);
});
+test('Negative start timestamps, regular MP4', async () => {
+ await testNegativeTimestampRoundTrip([-1, 0, 1, 2, 3], 1, false);
+});
+
+test('Negative start timestamps, fragmented MP4', async () => {
+ await testNegativeTimestampRoundTrip([-1, 0, 1, 2, 3], 1, true);
+});
+
+test('Wholly negative timestamps, regular MP4', async () => {
+ await testNegativeTimestampRoundTrip([-1, -0.9, -0.8, -0.7, -0.6], 0.1, false);
+});
+
+test('Wholly negative timestamps, fragmented MP4', async () => {
+ await testNegativeTimestampRoundTrip([-1, -0.9, -0.8, -0.7, -0.6], 0.1, true);
+});
+
+const testNegativeTimestampRoundTrip = async (
+ timestamps: number[],
+ duration: number,
+ fragmented: boolean,
+) => {
+ const output = new Output({
+ format: new Mp4OutputFormat({ fastStart: fragmented ? 'fragmented' : false }),
+ target: new BufferTarget(),
+ });
+
+ const source = new EncodedVideoPacketSource('vp8');
+ output.addVideoTrack(source);
+
+ 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 outputPackets: EncodedPacket[] = [];
+ for await (const packet of new EncodedPacketSink(track).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,
+ })));
+};
+
test('PCM audio', async () => {
const output = new Output({
format: new Mp4OutputFormat(),