Skip to content

Commit c660ce3

Browse files
authored
Fix I-frame playback for single-keyframe fMP4 segments and tfhd default_sample_size parsing (#7884)
1 parent 7a751fb commit c660ce3

5 files changed

Lines changed: 379 additions & 12 deletions

File tree

src/remux/passthrough-remuxer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,10 @@ class PassThroughRemuxer extends Logger implements Remuxer {
283283
if (
284284
__USE_IFRAMES__ &&
285285
videoSampleTimestamps &&
286-
videoSampleTimestamps.sampleCount > 1 &&
286+
(videoSampleTimestamps.sampleCount > 1 ||
287+
(videoSampleTimestamps.sampleCount === 1 &&
288+
!syncOnAudio &&
289+
chunkMeta.duration / (videoEndTime - videoStartTime) > 1.5)) &&
287290
initData.video &&
288291
chunkMeta.iframe
289292
) {

src/utils/mp4-tools.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -784,19 +784,29 @@ export function getSampleData(
784784
}
785785

786786
const trackDefault = track.default;
787-
const tfhdFlags = readUint32(tfhd, 0) | trackDefault?.flags!;
787+
const tfhdFlags = readUint32(tfhd, 0) & 0xffffff;
788+
// tfhd optional fields follow track_ID (at byte 8) in this fixed order,
789+
// each present only when its flag is set. Walk them so default_sample_size
790+
// is read at the correct offset regardless of which earlier fields exist.
788791
let defaultSampleDuration = trackDefault?.duration || 0;
789-
const defaultSampleSize = trackDefault?.sampleSize || 0;
792+
let defaultSampleSize = trackDefault?.sampleSize || 0;
793+
let tfhdOptOffset = 8;
794+
if (tfhdFlags & 0x000001) {
795+
// base_data_offset (64-bit)
796+
tfhdOptOffset += 8;
797+
}
798+
if (tfhdFlags & 0x000002) {
799+
// sample_description_index
800+
tfhdOptOffset += 4;
801+
}
790802
if (tfhdFlags & 0x000008) {
791-
// 0x000008 indicates the presence of the default_sample_duration field
792-
if (tfhdFlags & 0x000002) {
793-
// 0x000002 indicates the presence of the sample_description_index field, which precedes default_sample_duration
794-
// If present, the default_sample_duration exists at byte offset 12
795-
defaultSampleDuration = readUint32(tfhd, 12);
796-
} else {
797-
// Otherwise, the duration is at byte offset 8
798-
defaultSampleDuration = readUint32(tfhd, 8);
799-
}
803+
// default_sample_duration
804+
defaultSampleDuration = readUint32(tfhd, tfhdOptOffset);
805+
tfhdOptOffset += 4;
806+
}
807+
if (tfhdFlags & 0x000010) {
808+
// default_sample_size
809+
defaultSampleSize = readUint32(tfhd, tfhdOptOffset);
800810
}
801811
let baseDataOffset = 0;
802812
const baseDataOffsetPresent = (tfhdFlags & 0x000001) !== 0;

tests/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import './unit/loader/level';
4141
import './unit/loader/m3u8-parser';
4242
import './unit/loader/playlist-loader';
4343
import './unit/remux/mp4-remuxer';
44+
import './unit/remux/passthrough-remuxer';
4445
import './unit/utils/attr-list';
4546
import './unit/utils/binary-search';
4647
import './unit/utils/buffer-helper';
@@ -51,6 +52,7 @@ import './unit/utils/fetch-loader';
5152
import './unit/utils/discontinuities';
5253
import './unit/utils/exp-golomb';
5354
import './unit/utils/mediacapabilities-helper';
55+
import './unit/utils/mp4-tools';
5456
import './unit/utils/output-filter';
5557
import './unit/utils/safe-json-stringify';
5658
import './unit/utils/texttrack-utils';
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { expect } from 'chai';
2+
import EventEmitter from 'eventemitter3';
3+
import { hlsDefaultConfig } from '../../../src/config';
4+
import MP4 from '../../../src/remux/mp4-generator';
5+
import PassThroughRemuxer from '../../../src/remux/passthrough-remuxer';
6+
import { PlaylistLevelType } from '../../../src/types/loader';
7+
import { ChunkMetadata } from '../../../src/types/transmuxer';
8+
import { logger } from '../../../src/utils/logger';
9+
import { appendUint8Array } from '../../../src/utils/mp4-tools';
10+
import type { HlsEventEmitter } from '../../../src/events';
11+
import type { TrackFragmentSample } from '../../../src/remux/mp4-generator';
12+
import type {
13+
DemuxedAudioTrack,
14+
DemuxedMetadataTrack,
15+
DemuxedUserdataTrack,
16+
PassthroughTrack,
17+
} from '../../../src/types/demuxer';
18+
import type { TypeSupported } from '../../../src/utils/codecs';
19+
20+
describe('passthrough-remuxer', function () {
21+
let remuxer: PassThroughRemuxer;
22+
23+
beforeEach(function () {
24+
const observer: HlsEventEmitter = new EventEmitter() as HlsEventEmitter;
25+
const typeSupported: TypeSupported = {
26+
ac3: true,
27+
mpeg: true,
28+
mp3: true,
29+
};
30+
remuxer = new PassThroughRemuxer(
31+
observer,
32+
{ ...hlsDefaultConfig },
33+
typeSupported,
34+
logger,
35+
);
36+
});
37+
38+
afterEach(function () {
39+
remuxer.destroy();
40+
});
41+
42+
function remuxIFrameFragment(
43+
fragmentData: Uint8Array<ArrayBuffer>,
44+
extinfDuration: number,
45+
) {
46+
const initSegment = MP4.initSegment([videoInitTrack()]);
47+
remuxer.resetInitSegment(initSegment, undefined, 'avc1.42001e', null);
48+
49+
return remuxer.remux(
50+
audioTrack(),
51+
passthroughTrack(fragmentData),
52+
metadataTrack(),
53+
userdataTrack(),
54+
0,
55+
true,
56+
true,
57+
PlaylistLevelType.MAIN,
58+
new ChunkMetadata(
59+
0,
60+
0,
61+
0,
62+
fragmentData.byteLength,
63+
-1,
64+
false,
65+
extinfDuration,
66+
true,
67+
),
68+
);
69+
}
70+
71+
it('remuxes a single-keyframe iframe segment whose native sample duration is far short of EXTINF', function () {
72+
// Dedicated i-frame segment: one keyframe carrying ~one-frame duration
73+
// (3003/90000 ≈ 0.033s) while the playlist advertises a multi-second EXTINF.
74+
// The duration ratio is well above the 1.5 heuristic, so the remuxer rewrites
75+
// the moof, stretching the keyframe to the EXTINF window.
76+
const extinfDuration = 4;
77+
const fragmentData = mp4Fragment([sample(3003, 4, 0)]);
78+
79+
const result = remuxIFrameFragment(fragmentData, extinfDuration);
80+
81+
expect(result.video, 'video track').to.exist;
82+
// data2 is only populated when the iframe moof is rewritten (remuxed)
83+
expect(result.video!.data2, 'remuxed mdat').to.exist;
84+
expect(result.video!.endDTS - result.video!.startDTS).to.equal(
85+
extinfDuration,
86+
);
87+
// track.nb stays an informative parse count, not forced to 1
88+
expect(result.video!.nb).to.equal(1);
89+
});
90+
91+
it('does not remux a byte-range iframe segment whose sample duration already matches EXTINF', function () {
92+
// Byte-range addressed iframe (single sample spanning the whole playback
93+
// segment): sample duration == EXTINF, so the ratio is ~1 and the heuristic
94+
// keeps the more-accurate sample-based timing instead of remuxing.
95+
const extinfDuration = 4;
96+
const fragmentData = mp4Fragment([sample(extinfDuration * 90000, 4, 0)]);
97+
98+
const result = remuxIFrameFragment(fragmentData, extinfDuration);
99+
100+
expect(result.video, 'video track').to.exist;
101+
// not remuxed: the original fragment is passed through and no mdat is split out
102+
expect(result.video!.data2, 'remuxed mdat').to.not.exist;
103+
expect(result.video!.data1).to.equal(fragmentData);
104+
});
105+
106+
it('remuxes a multi-sample in-range iframe fragment, preserving every sample', function () {
107+
// Several samples fully present in the mdat (e.g. a byte-range that spans a
108+
// multi-sample moof). The sampleCount > 1 branch must still emit all samples,
109+
// back-loading the EXTINF remainder onto the last sample's duration.
110+
const extinfDuration = 4;
111+
const fragmentData = mp4Fragment([
112+
sample(3003, 4, 0),
113+
sample(3003, 4, 0),
114+
sample(3003, 4, 0),
115+
]);
116+
117+
const result = remuxIFrameFragment(fragmentData, extinfDuration);
118+
119+
expect(result.video, 'video track').to.exist;
120+
expect(result.video!.data2, 'remuxed mdat').to.exist;
121+
// all three parsed samples are reported, not collapsed to one
122+
expect(result.video!.nb).to.equal(3);
123+
expect(result.video!.endDTS - result.video!.startDTS).to.equal(
124+
extinfDuration,
125+
);
126+
});
127+
});
128+
129+
function videoInitTrack(): any {
130+
return {
131+
codec: 'avc1.42001e',
132+
dropped: 0,
133+
duration: 4,
134+
id: 1,
135+
inputTimeScale: 90000,
136+
len: 0,
137+
nbNalu: 0,
138+
pid: -1,
139+
pixelRatio: [1, 1],
140+
pps: [new Uint8Array([0x68, 0xce, 0x06, 0xe2])],
141+
samples: [],
142+
segmentCodec: 'avc',
143+
sequenceNumber: 0,
144+
sps: [new Uint8Array([0x67, 0x42, 0x00, 0x1e, 0xab, 0x40])],
145+
timescale: 90000,
146+
type: 'video',
147+
width: 16,
148+
height: 16,
149+
};
150+
}
151+
152+
function mp4Fragment(samples: TrackFragmentSample[]): Uint8Array<ArrayBuffer> {
153+
const mdatPayload = new Uint8Array(
154+
samples.reduce((total, sample) => total + sample.size, 0),
155+
);
156+
return appendUint8Array(
157+
MP4.moof(0, 0, { type: 'video', id: 1, samples }),
158+
MP4.mdat(mdatPayload),
159+
);
160+
}
161+
162+
function sample(
163+
duration: number,
164+
size: number,
165+
cts: number,
166+
): TrackFragmentSample {
167+
return {
168+
cts,
169+
duration,
170+
flags: {
171+
degradPrio: 0,
172+
dependsOn: 2,
173+
hasRedundancy: 0,
174+
isDependedOn: 0,
175+
isLeading: 0,
176+
isNonSync: 0,
177+
paddingValue: 0,
178+
},
179+
size,
180+
};
181+
}
182+
183+
function passthroughTrack(samples: Uint8Array<ArrayBuffer>): PassthroughTrack {
184+
return {
185+
codec: 'avc1.42001e',
186+
dropped: 0,
187+
duration: 4,
188+
id: 1,
189+
inputTimeScale: 90000,
190+
pid: -1,
191+
sampleDuration: 3003,
192+
samples,
193+
sequenceNumber: 0,
194+
supplemental: undefined,
195+
timescale: 90000,
196+
type: 'video',
197+
};
198+
}
199+
200+
function audioTrack(): DemuxedAudioTrack {
201+
return {
202+
dropped: 0,
203+
id: 2,
204+
inputTimeScale: 90000,
205+
pid: -1,
206+
samples: [],
207+
segmentCodec: 'aac',
208+
sequenceNumber: 0,
209+
type: 'audio',
210+
};
211+
}
212+
213+
function metadataTrack(): DemuxedMetadataTrack {
214+
return {
215+
dropped: 0,
216+
id: 3,
217+
inputTimeScale: 90000,
218+
pid: -1,
219+
samples: [],
220+
sequenceNumber: 0,
221+
type: 'id3',
222+
};
223+
}
224+
225+
function userdataTrack(): DemuxedUserdataTrack {
226+
return {
227+
dropped: 0,
228+
id: 4,
229+
inputTimeScale: 90000,
230+
pid: -1,
231+
samples: [],
232+
sequenceNumber: 0,
233+
type: 'text',
234+
};
235+
}

0 commit comments

Comments
 (0)