From 155a6b6c90978bb4548c3ee3a559c5f3d9d02ab3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 22:56:57 +0200 Subject: [PATCH] Keep initPTS across the chunks of a progressive multi-moof segment In progressive mode every chunk of a segment is remuxed with the segment's timeOffset, so only the first chunk's decode time lines up with it. A second moof starting further into the segment was treated as a new initPTS and appended over the chunks before it, stalling playback. Fixes #5697 --- src/remux/passthrough-remuxer.ts | 11 ++++- tests/unit/remux/passthrough-remuxer.ts | 56 ++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index 511fede..c2f7a72 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -49,6 +49,7 @@ class PassThroughRemuxer extends Logger implements Remuxer { private initPTS: TimestampOffset | null = null; private initTracks?: TrackSet; private lastEndTime: number | null = null; + private lastChunkKey: string | null = null; private isVideoContiguous: boolean = false; private videoOnlyRemux: boolean = false; private decryptdata: DecryptData | null = null; @@ -74,6 +75,7 @@ class PassThroughRemuxer extends Logger implements Remuxer { public resetTimeStamp(defaultInitPTS: TimestampOffset | null) { this.lastEndTime = null; + this.lastChunkKey = null; const initPTS = this.initPTS; if (initPTS && defaultInitPTS) { if ( @@ -89,6 +91,7 @@ class PassThroughRemuxer extends Logger implements Remuxer { public resetNextTimestamp() { this.isVideoContiguous = false; this.lastEndTime = null; + this.lastChunkKey = null; } public resetInitSegment( @@ -441,8 +444,14 @@ class PassThroughRemuxer extends Logger implements Remuxer { decodeTime = baseOffsetSamples.start / timescale; + // Every chunk of a progressive segment is remuxed with the segment's + // timeOffset, so only the first chunk's decode time lines up with it + const chunkKey = `${chunkMeta.level}_${chunkMeta.sn}_${chunkMeta.part}`; + const segmentStart = chunkKey !== this.lastChunkKey; + this.lastChunkKey = chunkKey; + if ( - (accurateTimeOffset || !initPTS) && + ((accurateTimeOffset && segmentStart) || !initPTS) && (isInvalidInitPts(initPTS, decodeTime, timeOffset, duration) || timescale !== initPTS.timescale) ) { diff --git a/tests/unit/remux/passthrough-remuxer.ts b/tests/unit/remux/passthrough-remuxer.ts index 7847266..5f2a45b 100644 --- a/tests/unit/remux/passthrough-remuxer.ts +++ b/tests/unit/remux/passthrough-remuxer.ts @@ -104,6 +104,24 @@ describe('passthrough-remuxer', function () { ); } + function remuxProgressiveChunk( + fragmentData: Uint8Array, + timeOffset: number, + flush: boolean, + ) { + return remuxer.remux( + audioTrack(), + passthroughTrack(fragmentData), + metadataTrack(), + userdataTrack(), + timeOffset, + true, + flush, + PlaylistLevelType.MAIN, + new ChunkMetadata(0, 0, 0, fragmentData.byteLength, -1, true, 4, false), + ); + } + it('remuxes moof+mdat to stretch a single-keyframe iframe to the EXTINF duration', function () { const extinfDuration = 4; const fragmentData = mp4Fragment([sample(3003, 4, 0)]); @@ -410,6 +428,35 @@ describe('passthrough-remuxer', function () { extinfDuration, ); }); + + it('keeps initPTS across the chunks of a progressively loaded multi-moof segment', function () { + remuxer.resetInitSegment( + MP4.initSegment([videoInitTrack()]), + undefined, + 'avc1.42001e', + null, + ); + + // One 4s segment delivered as two 2s moof+mdat chunks + const firstChunk = mp4FragmentAt(0, [sample(180000, 4, 0)]); + const secondChunk = mp4FragmentAt(180000, [sample(180000, 4, 0)]); + + const first = remuxProgressiveChunk(firstChunk, 0, false); + const second = remuxProgressiveChunk(secondChunk, 0, true); + + expect(first.video, 'first chunk video track').to.exist; + expect(first.video!.startDTS, 'first chunk startDTS').to.equal(0); + expect(first.video!.endDTS, 'first chunk endDTS').to.equal(2); + + // The second moof starts 2s into the segment. Its decode time must not be + // mistaken for a new initPTS, or the chunk is appended over the first one. + expect(second.video, 'second chunk video track').to.exist; + expect(second.video!.startDTS, 'second chunk startDTS').to.equal(2); + expect(second.video!.endDTS, 'second chunk endDTS').to.equal(4); + expect(second.initSegment?.initPTS, 'second chunk initPTS').to.not.equal( + 180000, + ); + }); }); function markVideoInitSegmentEncrypted( @@ -573,11 +620,18 @@ function withSecondVideoTraf( } function mp4Fragment(samples: TrackFragmentSample[]): Uint8Array { + return mp4FragmentAt(0, samples); +} + +function mp4FragmentAt( + baseMediaDecodeTime: number, + samples: TrackFragmentSample[], +): Uint8Array { const mdatPayload = new Uint8Array( samples.reduce((total, sample) => total + sample.size, 0), ); return appendUint8Array( - MP4.moof(0, 0, { type: 'video', id: 1, samples }), + MP4.moof(0, baseMediaDecodeTime, { type: 'video', id: 1, samples }), MP4.mdat(mdatPayload), ); }