Files
video-dev-hls.js/tests/unit/remux/passthrough-remuxer.ts
T
dualfroz 155a6b6c90 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
2026-09-13 15:31:49 +02:00

713 lines
22 KiB
TypeScript

import { expect } from 'chai';
import EventEmitter from 'eventemitter3';
import { hlsDefaultConfig } from '../../../src/config';
import MP4 from '../../../src/remux/mp4-generator';
import PassThroughRemuxer from '../../../src/remux/passthrough-remuxer';
import { PlaylistLevelType } from '../../../src/types/loader';
import { ChunkMetadata } from '../../../src/types/transmuxer';
import { logger } from '../../../src/utils/logger';
import {
appendUint8Array,
findBox,
parseInitSegment,
types,
writeUint32,
} from '../../../src/utils/mp4-tools';
import type { HlsEventEmitter } from '../../../src/events';
import type { TrackFragmentSample } from '../../../src/remux/mp4-generator';
import type {
DemuxedAudioTrack,
DemuxedMetadataTrack,
DemuxedUserdataTrack,
PassthroughTrack,
} from '../../../src/types/demuxer';
import type { TypeSupported } from '../../../src/utils/codecs';
describe('passthrough-remuxer', function () {
let remuxer: PassThroughRemuxer;
beforeEach(function () {
const observer: HlsEventEmitter = new EventEmitter() as HlsEventEmitter;
const typeSupported: TypeSupported = {
ac3: true,
mpeg: true,
mp3: true,
};
remuxer = new PassThroughRemuxer(
observer,
{ ...hlsDefaultConfig },
typeSupported,
logger,
);
});
afterEach(function () {
remuxer.destroy();
});
function remuxIFrameFragment(
fragmentData: Uint8Array<ArrayBuffer>,
extinfDuration: number,
encrypted = false,
) {
const initSegment = MP4.initSegment([videoInitTrack()]);
if (encrypted) {
markVideoInitSegmentEncrypted(initSegment);
}
remuxer.resetInitSegment(initSegment, undefined, 'avc1.42001e', null);
return remuxer.remux(
audioTrack(),
passthroughTrack(fragmentData),
metadataTrack(),
userdataTrack(),
0,
true,
true,
PlaylistLevelType.MAIN,
new ChunkMetadata(
0,
0,
0,
fragmentData.byteLength,
-1,
false,
extinfDuration,
true,
),
);
}
function remuxIFrame(
fragmentData: Uint8Array<ArrayBuffer>,
extinfDuration: number,
) {
return remuxer.remux(
audioTrack(),
passthroughTrack(fragmentData),
metadataTrack(),
userdataTrack(),
0,
true,
true,
PlaylistLevelType.MAIN,
new ChunkMetadata(
0,
0,
0,
fragmentData.byteLength,
-1,
false,
extinfDuration,
true,
),
);
}
function remuxProgressiveChunk(
fragmentData: Uint8Array<ArrayBuffer>,
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)]);
const result = remuxIFrameFragment(fragmentData, extinfDuration);
expect(result.video, 'video track').to.exist;
// Rewritten in place: the moof+mdat stays in data1, no separate mdat
expect(result.video!.data1, 'data1').to.equal(fragmentData);
expect(result.video!.data2, 'data2').to.not.exist;
// The single sample is stretched to the EXTINF duration
expect(trunSampleDurations(result.video!.data1)).to.deep.equal([
extinfDuration * 90000,
]);
expect(result.video!.endDTS - result.video!.startDTS).to.equal(
extinfDuration,
);
// The reported PTS range must cover the stretched sample so that
// fragment start/end updates match the appended media.
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
extinfDuration,
);
expect(result.video!.nb).to.equal(1);
});
it('does not remux a byte-range iframe segment whose sample duration already matches EXTINF', function () {
// Byte-range addressed iframe (single sample spanning the whole playback segment)
const extinfDuration = 4;
const fragmentData = mp4Fragment([sample(extinfDuration * 90000, 4, 0)]);
const result = remuxIFrameFragment(fragmentData, extinfDuration);
expect(result.video, 'video track').to.exist;
expect(result.video!.data2, 'data2').to.not.exist;
expect(result.video!.data1).to.equal(fragmentData);
});
it('remuxes a multi-sample iframe fragment in place, keeping every sample', function () {
const extinfDuration = 4;
const fragmentData = mp4Fragment([
sample(3003, 4, 0),
sample(3003, 4, 0),
sample(3003, 4, 0),
]);
const result = remuxIFrameFragment(fragmentData, extinfDuration);
expect(result.video, 'video track').to.exist;
expect(result.video!.data1, 'data1').to.equal(fragmentData);
expect(result.video!.data2, 'data2').to.not.exist;
// all three parsed samples are reported, not collapsed to one
expect(result.video!.nb).to.equal(3);
expect(result.video!.endDTS - result.video!.startDTS).to.equal(
extinfDuration,
);
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
extinfDuration,
);
// Each sample keeps its own duration; only the last is stretched to
// balance EXTINF.
expect(trunSampleDurations(result.video!.data1)).to.deep.equal([
3003,
3003,
extinfDuration * 90000 - 6006,
]);
});
it('reports the presentation end of stretched samples with composition offsets', function () {
const extinfDuration = 4;
const fragmentData = mp4Fragment([
sample(3003, 4, 9000),
sample(3003, 4, 0),
]);
const result = remuxIFrameFragment(fragmentData, extinfDuration);
expect(result.video, 'video track').to.exist;
expect(result.video!.startPTS).to.equal(3003 / 90000);
expect(result.video!.endPTS).to.equal(extinfDuration);
});
it('keeps native sample durations when EXTINF is shorter than the preceding samples', function () {
// Stretching would make the last sample duration negative (writing an
// unsigned wrap-around value into the trun).
const extinfDuration = 1;
const fragmentData = mp4Fragment([
sample(45045, 4, 0),
sample(45045, 4, 0),
sample(45045, 4, 0),
]);
const result = remuxIFrameFragment(fragmentData, extinfDuration);
expect(result.video, 'video track').to.exist;
expect(trunSampleDurations(result.video!.data1)).to.deep.equal([
45045, 45045, 45045,
]);
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
(3 * 45045) / 90000,
);
});
it('rewrites the sample duration of encrypted iframe fragments in place', function () {
const extinfDuration = 4;
const fragmentData = mp4Fragment([sample(3003, 4, 9000)]);
const result = remuxIFrameFragment(fragmentData, extinfDuration, true);
expect(result.video, 'video track').to.exist;
// The original moof is retained so encryption boxes survive intact
expect(result.video!.data1).to.equal(fragmentData);
expect(result.video!.data2, 'data2').to.not.exist;
expect(result.video!.encrypted).to.equal(true);
expect(trunSampleDurations(fragmentData)).to.deep.equal([
extinfDuration * 90000,
]);
expect(result.video!.endPTS - result.video!.startPTS).to.be.closeTo(
extinfDuration,
1 / 90000,
);
});
it('converts muxed input to video-only for iframe fragments', function () {
const extinfDuration = 4;
const initSegment = MP4.initSegment([
videoInitTrack(),
audioInitTrack(),
] as any);
remuxer.resetInitSegment(initSegment, 'mp4a.40.2', 'avc1.42001e', null);
// Video sample duration matches EXTINF: the muxed moof must still be
// regenerated so the audio track fragment is not appended
const fragmentData = muxedMp4Fragment(
[sample(extinfDuration * 90000, 24, 0)],
[50, 60],
);
const result = remuxer.remux(
audioTrack(),
passthroughTrack(fragmentData),
metadataTrack(),
userdataTrack(),
0,
true,
true,
PlaylistLevelType.MAIN,
new ChunkMetadata(
0,
0,
0,
fragmentData.byteLength,
-1,
false,
extinfDuration,
true,
),
);
// The emitted init segment is converted to video-only
const videoInitTrackInfo = result.initSegment?.tracks?.video;
expect(videoInitTrackInfo, 'video init track').to.exist;
expect(result.initSegment?.tracks?.audiovideo, 'audiovideo init track').to
.not.exist;
const parsedInit = parseInitSegment(videoInitTrackInfo!.initSegment!);
expect(parsedInit.video, 'filtered init video').to.exist;
expect(parsedInit.audio, 'filtered init audio').to.not.exist;
// Video-only output: the audio traf is spliced out (audio sample bytes are
// left in the mdat as unreferenced data)
expect(result.video, 'video track').to.exist;
expect(result.video!.type).to.equal('video');
expect(result.video!.hasAudio).to.equal(false);
expect(result.video!.data1, 'data1').to.not.equal(fragmentData);
expect(result.video!.data2, 'data2').to.not.exist;
const trafs = findBox(result.video!.data1, ['moof', 'traf']);
expect(trafs, 'trafs').to.have.lengthOf(1);
const tfhd = findBox(trafs[0], ['tfhd'])[0];
expect(readUint32(tfhd, 4), 'video track id').to.equal(1);
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
extinfDuration,
);
});
it('remuxes muxed iframe fragments with the audio run first in the mdat', function () {
const extinfDuration = 4;
const initSegment = MP4.initSegment([
videoInitTrack(),
audioInitTrack(),
] as any);
remuxer.resetInitSegment(initSegment, 'mp4a.40.2', 'avc1.42001e', null);
const fragmentData = muxedMp4Fragment(
[sample(3003, 24, 0)],
[50, 60],
true,
);
const result = remuxIFrame(fragmentData, extinfDuration);
expect(result.video, 'video track').to.exist;
expect(result.video!.type).to.equal('video');
expect(result.video!.data2, 'data2').to.not.exist;
const trafs = findBox(result.video!.data1, ['moof', 'traf']);
expect(trafs, 'trafs').to.have.lengthOf(1);
expect(readUint32(findBox(trafs[0], ['tfhd'])[0], 4), 'video id').to.equal(
1,
);
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
extinfDuration,
);
});
it('remuxes iframe fragments with multiple video track fragment runs', function () {
const extinfDuration = 4;
const initSegment = MP4.initSegment([
videoInitTrack(),
audioInitTrack(),
] as any);
remuxer.resetInitSegment(initSegment, 'mp4a.40.2', 'avc1.42001e', null);
const fragmentData = withSecondVideoTraf(
muxedMp4Fragment([sample(3003, 24, 0)], [50]),
);
const result = remuxIFrame(fragmentData, extinfDuration);
expect(result.video, 'video track').to.exist;
expect(result.video!.data2, 'data2').to.not.exist;
// The audio traf is spliced out; both video trafs are retained
const trafs = findBox(result.video!.data1, ['moof', 'traf']);
expect(trafs, 'video trafs').to.have.lengthOf(2);
trafs.forEach((traf) => {
expect(readUint32(findBox(traf, ['tfhd'])[0], 4), 'video id').to.equal(1);
});
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
extinfDuration,
);
});
it('drops muxed iframe fragments with no video sample in range', function () {
const extinfDuration = 4;
const initSegment = MP4.initSegment([
videoInitTrack(),
audioInitTrack(),
] as any);
remuxer.resetInitSegment(initSegment, 'mp4a.40.2', 'avc1.42001e', null);
// Audio-first layout sliced before the video sample: nothing can be
// appended to the video-only SourceBuffer
const fragmentData = muxedMp4Fragment([sample(3003, 24, 0)], [50], true);
const moofSize = findBox(fragmentData, ['moof'])[0].byteLength + 8;
const partialFragment = fragmentData.subarray(0, moofSize + 8 + 30);
const result = remuxIFrame(partialFragment, extinfDuration);
expect(result.initSegment?.tracks?.video, 'video init track').to.exist;
expect(result.video, 'video track').to.equal(undefined);
});
it('truncates encrypted partial-mdat iframe fragments on the mdat box boundary', function () {
const extinfDuration = 4;
const samples = [sample(3003, 10, 0), sample(3003, 20, 0)];
const fragmentData = mp4Fragment(samples);
// Slice mid-way through the second sample's data, as a byte-range
// addressed I-Frame request would.
const mdatPayloadOffset = fragmentData.byteLength - 30;
const partialFragment = fragmentData.subarray(0, mdatPayloadOffset + 13);
const result = remuxIFrameFragment(partialFragment, extinfDuration, true);
expect(result.video, 'video track').to.exist;
expect(result.video!.encrypted).to.equal(true);
// Appended data ends at the rewritten mdat boundary, dropping the
// partially loaded sample's bytes.
expect(result.video!.data1.byteLength).to.equal(mdatPayloadOffset + 10);
expect(readUint32(result.video!.data1, mdatPayloadOffset - 8)).to.equal(18);
const trun = findBox(result.video!.data1, ['moof', 'traf', 'trun'])[0];
expect(readUint32(trun, 4), 'trun sample_count').to.equal(1);
expect(trunSampleDurations(result.video!.data1)).to.deep.equal([
extinfDuration * 90000,
]);
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
extinfDuration,
);
});
it('stretches a partial-mdat iframe whose declared duration already matches EXTINF', function () {
// The moof declares two samples that together already span EXTINF, so
// needsDurationAdjustment is false — but only the first sample's data is
// present in the byte-range slice (as with a muxed I-Frame byte range).
const extinfDuration = 4;
const fragmentData = mp4Fragment([
sample(extinfDuration * 90000 - 3003, 10, 0),
sample(3003, 20, 0),
]);
const mdatPayloadOffset = fragmentData.byteLength - 30;
const partialFragment = fragmentData.subarray(0, mdatPayloadOffset + 13);
const result = remuxIFrameFragment(partialFragment, extinfDuration);
expect(result.video, 'video track').to.exist;
// The single kept keyframe is stretched to the full declared duration even
// though the moof timing did not disagree with EXTINF
expect(trunSampleDurations(result.video!.data1)).to.deep.equal([
extinfDuration * 90000,
]);
expect(result.video!.endPTS - result.video!.startPTS).to.equal(
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(
initSegment: Uint8Array<ArrayBuffer>,
): void {
const stsd = findBox(initSegment, [
'moov',
'trak',
'mdia',
'minf',
'stbl',
'stsd',
])[0];
const sampleEntry = findBox(stsd.subarray(8), ['avc1'])[0];
const typeOffset = sampleEntry.byteOffset - initSegment.byteOffset - 4;
initSegment.set([0x65, 0x6e, 0x63, 0x76], typeOffset); // 'encv'
}
function readUint32(buffer: Uint8Array, offset: number): number {
return (
((buffer[offset] << 24) |
(buffer[offset + 1] << 16) |
(buffer[offset + 2] << 8) |
buffer[offset + 3]) >>>
0
);
}
function trunSampleDurations(data: Uint8Array): number[] {
const trun = findBox(data, ['moof', 'traf', 'trun'])[0];
const sampleCount = readUint32(trun, 4);
const durations: number[] = [];
// full box header (4) + sample_count (4) + data_offset (4);
// entries are duration, size, flags, cts (16 bytes each)
for (let i = 0; i < sampleCount; i++) {
durations.push(readUint32(trun, 12 + i * 16));
}
return durations;
}
function videoInitTrack(): any {
return {
codec: 'avc1.42001e',
dropped: 0,
duration: 4,
id: 1,
inputTimeScale: 90000,
len: 0,
nbNalu: 0,
pid: -1,
pixelRatio: [1, 1],
pps: [new Uint8Array([0x68, 0xce, 0x06, 0xe2])],
samples: [],
segmentCodec: 'avc',
sequenceNumber: 0,
sps: [new Uint8Array([0x67, 0x42, 0x00, 0x1e, 0xab, 0x40])],
timescale: 90000,
type: 'video',
width: 16,
height: 16,
};
}
function audioInitTrack(): any {
return {
codec: 'mp4a.40.2',
segmentCodec: 'aac',
channelCount: 2,
samplerate: 48000,
config: [0x11, 0x90],
duration: 4,
id: 2,
timescale: 48000,
type: 'audio',
};
}
// moof with a video traf (track 1) and an audio traf (track 2) sharing one
// mdat: video run first, audio run after (ffmpeg muxed fMP4 layout)
function muxedMp4Fragment(
videoSamples: TrackFragmentSample[],
audioSampleSizes: number[],
audioFirst = false,
): Uint8Array<ArrayBuffer> {
const videoBytes = videoSamples.reduce((total, s) => total + s.size, 0);
const audioBytes = audioSampleSizes.reduce((total, s) => total + s, 0);
const videoMoof = MP4.moof(0, 0, {
type: 'video',
id: 1,
samples: videoSamples,
});
const tfhd = new Uint8Array(8);
tfhd.set([0x00, 0x02, 0x00, 0x00]); // version 0, default-base-is-moof
writeUint32(tfhd, 4, 2); // track_ID
const tfdt = new Uint8Array(8);
const audioTrun = new Uint8Array(12 + audioSampleSizes.length * 8);
audioTrun.set([0x00, 0x00, 0x03, 0x01]); // data-offset + duration + size
writeUint32(audioTrun, 4, audioSampleSizes.length);
audioSampleSizes.forEach((size, i) => {
writeUint32(audioTrun, 12 + i * 8, 1024);
writeUint32(audioTrun, 12 + i * 8 + 4, size);
});
const audioTraf = MP4.box(
types.traf,
MP4.box(types.tfhd, tfhd),
MP4.box(types.tfdt, tfdt),
MP4.box(types.trun, audioTrun),
);
// Splice the audio traf into the moof, then patch the moof size and both
// trun data offsets for the combined mdat layout
const moof = new Uint8Array(videoMoof.byteLength + audioTraf.byteLength);
moof.set(videoMoof, 0);
moof.set(audioTraf, videoMoof.byteLength);
writeUint32(moof, 0, moof.byteLength);
const truns = findBox(moof, ['moof', 'traf', 'trun']);
writeUint32(
moof,
truns[0].byteOffset - moof.byteOffset + 8,
moof.byteLength + 8 + (audioFirst ? audioBytes : 0),
);
writeUint32(
moof,
truns[1].byteOffset - moof.byteOffset + 8,
moof.byteLength + 8 + (audioFirst ? 0 : videoBytes),
);
// CMAF muxed content addresses samples from the moof; the video traf must
// set default-base-is-moof so the remuxer can splice out the audio traf
const videoTfhd = findBox(moof, ['moof', 'traf', 'tfhd'])[0];
videoTfhd[1] = 0x02;
return appendUint8Array(
moof,
MP4.mdat(new Uint8Array(videoBytes + audioBytes)),
) as Uint8Array<ArrayBuffer>;
}
// Appends a second (empty) video track fragment to the moof so the video
// track has more than one trun
function withSecondVideoTraf(
fragment: Uint8Array<ArrayBuffer>,
): Uint8Array<ArrayBuffer> {
const tfhd = new Uint8Array(8);
tfhd.set([0x00, 0x02, 0x00, 0x00]);
writeUint32(tfhd, 4, 1); // video track_ID
const trun = new Uint8Array(12);
trun[2] = 0x01; // data-offset present
const traf = MP4.box(
types.traf,
MP4.box(types.tfhd, tfhd),
MP4.box(types.trun, trun),
);
const moofSize = findBox(fragment, ['moof'])[0].byteLength + 8;
const result = new Uint8Array(fragment.byteLength + traf.byteLength);
result.set(fragment.subarray(0, moofSize), 0);
result.set(traf, moofSize);
result.set(fragment.subarray(moofSize), moofSize + traf.byteLength);
writeUint32(result, 0, moofSize + traf.byteLength);
return result;
}
function mp4Fragment(samples: TrackFragmentSample[]): Uint8Array<ArrayBuffer> {
return mp4FragmentAt(0, samples);
}
function mp4FragmentAt(
baseMediaDecodeTime: number,
samples: TrackFragmentSample[],
): Uint8Array<ArrayBuffer> {
const mdatPayload = new Uint8Array(
samples.reduce((total, sample) => total + sample.size, 0),
);
return appendUint8Array(
MP4.moof(0, baseMediaDecodeTime, { type: 'video', id: 1, samples }),
MP4.mdat(mdatPayload),
);
}
function sample(
duration: number,
size: number,
cts: number,
): TrackFragmentSample {
return {
cts,
duration,
flags: {
degradPrio: 0,
dependsOn: 2,
hasRedundancy: 0,
isDependedOn: 0,
isLeading: 0,
isNonSync: 0,
paddingValue: 0,
},
size,
};
}
function passthroughTrack(samples: Uint8Array<ArrayBuffer>): PassthroughTrack {
return {
codec: 'avc1.42001e',
dropped: 0,
duration: 4,
id: 1,
inputTimeScale: 90000,
pid: -1,
sampleDuration: 3003,
samples,
sequenceNumber: 0,
supplemental: undefined,
timescale: 90000,
type: 'video',
};
}
function audioTrack(): DemuxedAudioTrack {
return {
dropped: 0,
id: 2,
inputTimeScale: 90000,
pid: -1,
samples: [],
segmentCodec: 'aac',
sequenceNumber: 0,
type: 'audio',
};
}
function metadataTrack(): DemuxedMetadataTrack {
return {
dropped: 0,
id: 3,
inputTimeScale: 90000,
pid: -1,
samples: [],
sequenceNumber: 0,
type: 'id3',
};
}
function userdataTrack(): DemuxedUserdataTrack {
return {
dropped: 0,
id: 4,
inputTimeScale: 90000,
pid: -1,
samples: [],
sequenceNumber: 0,
type: 'text',
};
}