@torrent-tv/proxy 2.53.0 → 2.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,300 +1,320 @@
1
- /**
2
- * @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
3
- * `init.mp4` referenced by `#EXT-X-MAP`.
4
- *
5
- * Codec configuration (SPS/PPS) lives once in the init segment instead of being
6
- * repeated per segment. That is what makes hardware encoders which do not
7
- * repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce
8
- * independently usable segments, and it lowers container overhead.
9
- *
10
- * See {@link SegmentFormat} in `./index.js` for the interface contract.
11
- */
12
-
13
- import {
14
- readSelfContainedStartSeconds,
15
- readTrackTimescales,
16
- stampSegmentStartTime,
17
- walkBoxes
18
- } from "./mp4-boxes.js";
19
-
20
- /**
21
- * How many distinct tracks have a fragment in this segment.
22
- *
23
- * @param {Buffer} bytes
24
- * @returns {number}
25
- */
26
- function countFragmentTracks(bytes) {
27
- const tracks = new Set();
28
- walkBoxes(bytes, (type, bodyStart) => {
29
- if (type === "tfhd" && bodyStart + 8 <= bytes.length) {
30
- tracks.add(bytes.readUInt32BE(bodyStart + 4));
31
- }
32
- });
33
- return tracks.size;
34
- }
35
-
36
- /**
37
- * Where the fragments begin and where the trailing index starts, as offsets of
38
- * whole boxes at the TOP level.
39
- *
40
- * Deliberately not `walkBoxes`: that reports the start of a box's *body* and
41
- * descends into containers, so it cannot say where a box begins and cutting a
42
- * file at the wrong offset by eight bytes produces something that parses as
43
- * garbage rather than failing outright.
44
- *
45
- * @param {Buffer} bytes
46
- * @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
47
- */
48
- function findFragmentBounds(bytes) {
49
- let firstFragment = -1;
50
- let trailingIndex = -1;
51
- let offset = 0;
52
- while (offset + 8 <= bytes.length) {
53
- let size = bytes.readUInt32BE(offset);
54
- const type = bytes.toString("latin1", offset + 4, offset + 8);
55
- if (size === 1) {
56
- if (offset + 16 > bytes.length) {
57
- break;
58
- }
59
- size = Number(bytes.readBigUInt64BE(offset + 8));
60
- } else if (size === 0) {
61
- size = bytes.length - offset;
62
- }
63
- if (size < 8) {
64
- break;
65
- }
66
- if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
67
- firstFragment = offset;
68
- }
69
- if (type === "mfra") {
70
- trailingIndex = offset;
71
- }
72
- offset += size;
73
- }
74
- return { firstFragment, trailingIndex };
75
- }
76
-
77
- const INIT_FILE_NAME = "init.mp4";
78
- const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
79
-
80
- /**
81
- * @type {import("./index.js").SegmentFormat}
82
- */
83
- export const fmp4Format = {
84
- id: "fmp4",
85
- initFileName: INIT_FILE_NAME,
86
- initContentType: "video/mp4",
87
- segmentContentType: "video/mp4",
88
- // Version 7 is the minimum that allows fMP4 media segments + `#EXT-X-MAP`.
89
- playlistVersion: 7,
90
-
91
- muxerArgs() {
92
- return [
93
- "-hls_segment_type",
94
- "fmp4",
95
- "-hls_fmp4_init_filename",
96
- INIT_FILE_NAME,
97
- "-hls_segment_filename",
98
- "segment-%05d.mp4"
99
- ];
100
- },
101
-
102
- /**
103
- * Cut at times we choose rather than times ffmpeg picks.
104
- *
105
- * The `segment` muxer writes each fMP4 piece as a **self-contained** file:
106
- * `ftyp moov moof mdat mfra`, verified on the field host. That is not what
107
- * HLS wants it wants one init segment named by `#EXT-X-MAP` and media
108
- * segments carrying only fragments so the pieces are split on serve:
109
- * {@link extractInit} takes the header off the first one to serve as the init,
110
- * and {@link stripInit} removes it from every one.
111
- *
112
- * The timestamps still need stamping, exactly as before: measured on the
113
- * field host, all three pieces of a run reported a start time of 0.080 s,
114
- * i.e. each carries its own zero. That is the same defect the shared-init
115
- * path already corrects, so `prepareSegmentBytes` handles both.
116
- *
117
- * @returns {string[]}
118
- */
119
- explicitTimesMuxerArgs() {
120
- return [
121
- "-segment_format",
122
- "mp4",
123
- // `empty_moov` is what makes each piece self-describing, which is what
124
- // lets the init be lifted out of it; `default_base_moof` keeps fragment
125
- // offsets relative, so removing the header does not invalidate them.
126
- //
127
- // `delay_moov` is not optional here. The MP4 muxer builds a copied AC-3
128
- // track's `dac3` box out of the bitstream, so it cannot write `moov`
129
- // until the first audio packet has arrived while `empty_moov` asks for
130
- // it at header time. Without this flag ffmpeg exits before producing
131
- // anything: "Cannot write moov atom before AC3 packets", which is exactly
132
- // how fMP4 playback died in the field on 2.9.84/2.9.85. The `hls` muxer
133
- // sets this flag itself, which is why the fault only appeared once the
134
- // muxing moved here. Delaying `moov` does not change the piece layout
135
- // measured: still `ftyp moov moof mdat mfra`.
136
- "-segment_format_options",
137
- "movflags=+frag_keyframe+empty_moov+default_base_moof+delay_moov"
138
- ];
139
- },
140
-
141
- /** The output path template for the `segment` muxer. */
142
- segmentFileNameTemplate() {
143
- return "segment-%05d.mp4";
144
- },
145
-
146
- /**
147
- * The init part of a self-contained piece: everything before the first
148
- * fragment.
149
- *
150
- * @param {Buffer} bytes
151
- * @returns {Buffer | null} `null` when the piece carries no fragment, which
152
- * means it is not one of these and must not be cut up.
153
- */
154
- extractInit(bytes) {
155
- const { firstFragment } = findFragmentBounds(bytes);
156
- return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
157
- },
158
-
159
- /**
160
- * A piece with its init header removed, and the trailing random-access index
161
- * dropped a player reading a media segment has no use for either, and
162
- * `mfra` describes offsets that stop being true once the header is gone.
163
- *
164
- * @param {Buffer} bytes
165
- * @returns {Buffer}
166
- */
167
- stripInit(bytes) {
168
- const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
169
- if (firstFragment < 0) {
170
- return bytes;
171
- }
172
- const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
173
- return bytes.subarray(firstFragment, end);
174
- },
175
-
176
- playlistHeaderLines() {
177
- return [
178
- // The init segment (codec config). Fetched once; applies to every media
179
- // segment in the playlist.
180
- `#EXT-X-MAP:URI="${INIT_FILE_NAME}"`
181
- ];
182
- },
183
-
184
- segmentFileName(index) {
185
- return `segment-${String(index).padStart(5, "0")}.mp4`;
186
- },
187
-
188
- isSegmentFileName(fileName) {
189
- return SEGMENT_PATTERN.test(fileName);
190
- },
191
-
192
- segmentIndexFromName(fileName) {
193
- const match = SEGMENT_PATTERN.exec(fileName);
194
- return match ? Number(match[1]) : -1;
195
- },
196
-
197
- /**
198
- * Where a self-contained piece says it begins, in seconds, or null.
199
- *
200
- * Only the pieces the `segment` muxer writes carry this — they have their
201
- * own `moov` which is exactly the path where the playlist's own answer
202
- * can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
203
- * removes the header the position lives in.
204
- *
205
- * @param {Buffer} piece
206
- * @returns {number | null}
207
- */
208
- readSegmentStartSeconds(piece) {
209
- return readSelfContainedStartSeconds(piece);
210
- },
211
-
212
- /**
213
- * fMP4 segments must be read into memory and corrected before being served —
214
- * see {@link stampSegmentStartTime} for the full reasoning. Without this a
215
- * seek is permanently broken: ffmpeg leaves every segment claiming to start
216
- * at 0 and puts the real offset in the per-run init, which we do not serve
217
- * (the player fetches the session's first init once and keeps it).
218
- *
219
- * Segments are a few hundred KB, so reading one whole is cheap next to the
220
- * transcode itself; the box walk never descends into `mdat`.
221
- */
222
- needsSegmentRewrite: true,
223
-
224
- /**
225
- * Whether a segment carries every track the init promises.
226
- *
227
- * A run that is TERMINATED closes its current output file properly — trailing
228
- * index and all but the file holds only what had been muxed by then, which
229
- * after a seek-restart is routinely one track of two. Nothing about it looks
230
- * unfinished: the file exists, the next one exists, so the readiness rule
231
- * calls it done and it is served. The player then cannot use it and the seek
232
- * never completes. Measured 2026-08-06: segment #133 carried one `tfdt`
233
- * where its neighbours carried two, and a viewer sat on a spinner while the
234
- * proxy answered every request in 98 ms.
235
- *
236
- * Cheap to check: the fragments are already walked to stamp them.
237
- *
238
- * @param {Buffer} bytes - The media segment, init header already removed.
239
- * @param {Buffer | null} initBytes
240
- * @returns {boolean} False only when a track is provably missing.
241
- */
242
- hasEveryTrack(bytes, initBytes) {
243
- if (!initBytes || initBytes.length === 0) {
244
- return true;
245
- }
246
- const expected = readTrackTimescales(initBytes);
247
- if (expected.size === 0) {
248
- return true;
249
- }
250
- return countFragmentTracks(bytes) >= expected.size;
251
- },
252
-
253
- /**
254
- * How many tracks an init segment declares.
255
- *
256
- * The init is extracted from the first self-contained piece and then cached
257
- * for the WHOLE session the player fetches `#EXT-X-MAP` once and never
258
- * again. So an init taken from a piece written before the video track was
259
- * muxed describes audio alone, and the browser then has no video source
260
- * buffer for the rest of the session however much video arrives afterwards.
261
- * Measured 2026-08-10: sixty-five seconds of playing sound with
262
- * `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` an element
263
- * perfectly happy, with no picture in it.
264
- *
265
- * @param {Buffer} initBytes
266
- * @returns {number}
267
- */
268
- /**
269
- * How many distinct tracks this piece's own fragments carry.
270
- *
271
- * Needs no knowledge of the source: whatever a produced piece contains, it
272
- * contains. Comparing the richest piece against a candidate header is what
273
- * lets an init be judged without assuming how many tracks a file "should"
274
- * have a film with no soundtrack and a file with two audio tracks are both
275
- * answered correctly, and neither is guessed at.
276
- *
277
- * @param {Buffer} bytes
278
- * @returns {number}
279
- */
280
- countSegmentTracks(bytes) {
281
- return countFragmentTracks(bytes);
282
- },
283
-
284
- countInitTracks(initBytes) {
285
- if (!initBytes || initBytes.length === 0) {
286
- return 0;
287
- }
288
- return readTrackTimescales(initBytes).size;
289
- },
290
-
291
- prepareSegmentBytes(bytes, { startSeconds, initBytes }) {
292
- if (!initBytes || initBytes.length === 0) {
293
- // No init cached yet nothing to read timescales from. The player always
294
- // fetches `#EXT-X-MAP` before any segment, so this is not reachable in
295
- // practice; serve unmodified rather than guess a timescale.
296
- return bytes;
297
- }
298
- return stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
299
- }
300
- };
1
+ /**
2
+ * @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
3
+ * `init.mp4` referenced by `#EXT-X-MAP`.
4
+ *
5
+ * Codec configuration (SPS/PPS) lives once in the init segment instead of being
6
+ * repeated per segment. That is what makes hardware encoders which do not
7
+ * repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce
8
+ * independently usable segments, and it lowers container overhead.
9
+ *
10
+ * See {@link SegmentFormat} in `./index.js` for the interface contract.
11
+ */
12
+
13
+ import {
14
+ readSelfContainedStartSeconds,
15
+ readTrackTimescales,
16
+ readVideoSampleSize,
17
+ stampSegmentStartTime,
18
+ walkBoxes
19
+ } from "./mp4-boxes.js";
20
+
21
+ /**
22
+ * How many distinct tracks have a fragment in this segment.
23
+ *
24
+ * @param {Buffer} bytes
25
+ * @returns {number}
26
+ */
27
+ function countFragmentTracks(bytes) {
28
+ const tracks = new Set();
29
+ walkBoxes(bytes, (type, bodyStart) => {
30
+ if (type === "tfhd" && bodyStart + 8 <= bytes.length) {
31
+ tracks.add(bytes.readUInt32BE(bodyStart + 4));
32
+ }
33
+ });
34
+ return tracks.size;
35
+ }
36
+
37
+ /**
38
+ * Where the fragments begin and where the trailing index starts, as offsets of
39
+ * whole boxes at the TOP level.
40
+ *
41
+ * Deliberately not `walkBoxes`: that reports the start of a box's *body* and
42
+ * descends into containers, so it cannot say where a box begins and cutting a
43
+ * file at the wrong offset by eight bytes produces something that parses as
44
+ * garbage rather than failing outright.
45
+ *
46
+ * @param {Buffer} bytes
47
+ * @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
48
+ */
49
+ function findFragmentBounds(bytes) {
50
+ let firstFragment = -1;
51
+ let trailingIndex = -1;
52
+ let offset = 0;
53
+ while (offset + 8 <= bytes.length) {
54
+ let size = bytes.readUInt32BE(offset);
55
+ const type = bytes.toString("latin1", offset + 4, offset + 8);
56
+ if (size === 1) {
57
+ if (offset + 16 > bytes.length) {
58
+ break;
59
+ }
60
+ size = Number(bytes.readBigUInt64BE(offset + 8));
61
+ } else if (size === 0) {
62
+ size = bytes.length - offset;
63
+ }
64
+ if (size < 8) {
65
+ break;
66
+ }
67
+ if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
68
+ firstFragment = offset;
69
+ }
70
+ if (type === "mfra") {
71
+ trailingIndex = offset;
72
+ }
73
+ offset += size;
74
+ }
75
+ return { firstFragment, trailingIndex };
76
+ }
77
+
78
+ const INIT_FILE_NAME = "init.mp4";
79
+ const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
80
+
81
+ /**
82
+ * @type {import("./index.js").SegmentFormat}
83
+ */
84
+ export const fmp4Format = {
85
+ id: "fmp4",
86
+ initFileName: INIT_FILE_NAME,
87
+ initContentType: "video/mp4",
88
+ segmentContentType: "video/mp4",
89
+ // Version 7 is the minimum that allows fMP4 media segments + `#EXT-X-MAP`.
90
+ playlistVersion: 7,
91
+
92
+ muxerArgs() {
93
+ return [
94
+ "-hls_segment_type",
95
+ "fmp4",
96
+ "-hls_fmp4_init_filename",
97
+ INIT_FILE_NAME,
98
+ "-hls_segment_filename",
99
+ "segment-%05d.mp4"
100
+ ];
101
+ },
102
+
103
+ /**
104
+ * Cut at times we choose rather than times ffmpeg picks.
105
+ *
106
+ * The `segment` muxer writes each fMP4 piece as a **self-contained** file:
107
+ * `ftyp moov moof mdat mfra`, verified on the field host. That is not what
108
+ * HLS wants it wants one init segment named by `#EXT-X-MAP` and media
109
+ * segments carrying only fragments so the pieces are split on serve:
110
+ * {@link extractInit} takes the header off the first one to serve as the init,
111
+ * and {@link stripInit} removes it from every one.
112
+ *
113
+ * The timestamps still need stamping, exactly as before: measured on the
114
+ * field host, all three pieces of a run reported a start time of 0.080 s,
115
+ * i.e. each carries its own zero. That is the same defect the shared-init
116
+ * path already corrects, so `prepareSegmentBytes` handles both.
117
+ *
118
+ * @returns {string[]}
119
+ */
120
+ explicitTimesMuxerArgs() {
121
+ return [
122
+ "-segment_format",
123
+ "mp4",
124
+ // `empty_moov` is what makes each piece self-describing, which is what
125
+ // lets the init be lifted out of it; `default_base_moof` keeps fragment
126
+ // offsets relative, so removing the header does not invalidate them.
127
+ //
128
+ // `delay_moov` is not optional here. The MP4 muxer builds a copied AC-3
129
+ // track's `dac3` box out of the bitstream, so it cannot write `moov`
130
+ // until the first audio packet has arrived while `empty_moov` asks for
131
+ // it at header time. Without this flag ffmpeg exits before producing
132
+ // anything: "Cannot write moov atom before AC3 packets", which is exactly
133
+ // how fMP4 playback died in the field on 2.9.84/2.9.85. The `hls` muxer
134
+ // sets this flag itself, which is why the fault only appeared once the
135
+ // muxing moved here. Delaying `moov` does not change the piece layout —
136
+ // measured: still `ftyp moov moof mdat … mfra`.
137
+ "-segment_format_options",
138
+ "movflags=+frag_keyframe+empty_moov+default_base_moof+delay_moov"
139
+ ];
140
+ },
141
+
142
+ /** The output path template for the `segment` muxer. */
143
+ segmentFileNameTemplate() {
144
+ return "segment-%05d.mp4";
145
+ },
146
+
147
+ /**
148
+ * The init part of a self-contained piece: everything before the first
149
+ * fragment.
150
+ *
151
+ * @param {Buffer} bytes
152
+ * @returns {Buffer | null} `null` when the piece carries no fragment, which
153
+ * means it is not one of these and must not be cut up.
154
+ */
155
+ extractInit(bytes) {
156
+ const { firstFragment } = findFragmentBounds(bytes);
157
+ return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
158
+ },
159
+
160
+ /**
161
+ * A piece with its init header removed, and the trailing random-access index
162
+ * dropped a player reading a media segment has no use for either, and
163
+ * `mfra` describes offsets that stop being true once the header is gone.
164
+ *
165
+ * @param {Buffer} bytes
166
+ * @returns {Buffer}
167
+ */
168
+ stripInit(bytes) {
169
+ const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
170
+ if (firstFragment < 0) {
171
+ return bytes;
172
+ }
173
+ const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
174
+ return bytes.subarray(firstFragment, end);
175
+ },
176
+
177
+ playlistHeaderLines() {
178
+ return [
179
+ // The init segment (codec config). Fetched once; applies to every media
180
+ // segment in the playlist.
181
+ `#EXT-X-MAP:URI="${INIT_FILE_NAME}"`
182
+ ];
183
+ },
184
+
185
+ segmentFileName(index) {
186
+ return `segment-${String(index).padStart(5, "0")}.mp4`;
187
+ },
188
+
189
+ isSegmentFileName(fileName) {
190
+ return SEGMENT_PATTERN.test(fileName);
191
+ },
192
+
193
+ segmentIndexFromName(fileName) {
194
+ const match = SEGMENT_PATTERN.exec(fileName);
195
+ return match ? Number(match[1]) : -1;
196
+ },
197
+
198
+ /**
199
+ * Where a self-contained piece says it begins, in seconds, or null.
200
+ *
201
+ * Only the pieces the `segment` muxer writes carry this they have their
202
+ * own `moov` which is exactly the path where the playlist's own answer
203
+ * can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
204
+ * removes the header the position lives in.
205
+ *
206
+ * @param {Buffer} piece
207
+ * @returns {number | null}
208
+ */
209
+ readSegmentStartSeconds(piece) {
210
+ return readSelfContainedStartSeconds(piece);
211
+ },
212
+
213
+ /**
214
+ * fMP4 segments must be read into memory and corrected before being served —
215
+ * see {@link stampSegmentStartTime} for the full reasoning. Without this a
216
+ * seek is permanently broken: ffmpeg leaves every segment claiming to start
217
+ * at 0 and puts the real offset in the per-run init, which we do not serve
218
+ * (the player fetches the session's first init once and keeps it).
219
+ *
220
+ * Segments are a few hundred KB, so reading one whole is cheap next to the
221
+ * transcode itself; the box walk never descends into `mdat`.
222
+ */
223
+ needsSegmentRewrite: true,
224
+
225
+ /**
226
+ * Whether a segment carries every track the init promises.
227
+ *
228
+ * A run that is TERMINATED closes its current output file properly trailing
229
+ * index and all but the file holds only what had been muxed by then, which
230
+ * after a seek-restart is routinely one track of two. Nothing about it looks
231
+ * unfinished: the file exists, the next one exists, so the readiness rule
232
+ * calls it done and it is served. The player then cannot use it and the seek
233
+ * never completes. Measured 2026-08-06: segment #133 carried one `tfdt`
234
+ * where its neighbours carried two, and a viewer sat on a spinner while the
235
+ * proxy answered every request in 98 ms.
236
+ *
237
+ * Cheap to check: the fragments are already walked to stamp them.
238
+ *
239
+ * @param {Buffer} bytes - The media segment, init header already removed.
240
+ * @param {Buffer | null} initBytes
241
+ * @returns {boolean} False only when a track is provably missing.
242
+ */
243
+ hasEveryTrack(bytes, initBytes) {
244
+ if (!initBytes || initBytes.length === 0) {
245
+ return true;
246
+ }
247
+ const expected = readTrackTimescales(initBytes);
248
+ if (expected.size === 0) {
249
+ return true;
250
+ }
251
+ return countFragmentTracks(bytes) >= expected.size;
252
+ },
253
+
254
+ /**
255
+ * How many tracks an init segment declares.
256
+ *
257
+ * The init is extracted from the first self-contained piece and then cached
258
+ * for the WHOLE session the player fetches `#EXT-X-MAP` once and never
259
+ * again. So an init taken from a piece written before the video track was
260
+ * muxed describes audio alone, and the browser then has no video source
261
+ * buffer for the rest of the session however much video arrives afterwards.
262
+ * Measured 2026-08-10: sixty-five seconds of playing sound with
263
+ * `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` an element
264
+ * perfectly happy, with no picture in it.
265
+ *
266
+ * @param {Buffer} initBytes
267
+ * @returns {number}
268
+ */
269
+ /**
270
+ * How many distinct tracks this piece's own fragments carry.
271
+ *
272
+ * Needs no knowledge of the source: whatever a produced piece contains, it
273
+ * contains. Comparing the richest piece against a candidate header is what
274
+ * lets an init be judged without assuming how many tracks a file "should"
275
+ * have a film with no soundtrack and a file with two audio tracks are both
276
+ * answered correctly, and neither is guessed at.
277
+ *
278
+ * @param {Buffer} bytes
279
+ * @returns {number}
280
+ */
281
+ countSegmentTracks(bytes) {
282
+ return countFragmentTracks(bytes);
283
+ },
284
+
285
+ countInitTracks(initBytes) {
286
+ if (!initBytes || initBytes.length === 0) {
287
+ return 0;
288
+ }
289
+ return readTrackTimescales(initBytes).size;
290
+ },
291
+
292
+ /**
293
+ * The picture size this init segment describes, or null when it describes no
294
+ * picture.
295
+ *
296
+ * Asked because one init serves the session's whole life while an encode run
297
+ * can be restarted with other settings, and a run that changes the SIZE makes
298
+ * every fragment after it undecodable against the init already in the
299
+ * player's hands.
300
+ *
301
+ * @param {Buffer | null} initBytes
302
+ * @returns {{ width: number, height: number } | null}
303
+ */
304
+ initVideoSize(initBytes) {
305
+ if (!initBytes || initBytes.length === 0) {
306
+ return null;
307
+ }
308
+ return readVideoSampleSize(initBytes);
309
+ },
310
+
311
+ prepareSegmentBytes(bytes, { startSeconds, initBytes }) {
312
+ if (!initBytes || initBytes.length === 0) {
313
+ // No init cached yet — nothing to read timescales from. The player always
314
+ // fetches `#EXT-X-MAP` before any segment, so this is not reachable in
315
+ // practice; serve unmodified rather than guess a timescale.
316
+ return bytes;
317
+ }
318
+ return stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
319
+ }
320
+ };