@torrent-tv/proxy 2.9.134 → 2.9.136
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.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +61 -3
- package/services/segment-formats/fmp4.js +300 -262
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.136
|
|
2
|
+
|
|
3
|
+
- **Fix**: What a complete init segment must describe is now taken from what the proxy DECLARES it will output, not from a count. 2.9.135 required two tracks, which is a guess — wrong for a film with no soundtrack, and meaningless for a source carrying several dubs, subtitles or a cover-art video stream. The output does not inherit the source's track list: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output at all. So the proxy knows the output's set exactly, because it chose it — the probe says which kinds exist, the mapping says how many are taken. Deriving the figure from produced pieces instead reads correctly only once a piece carrying every track exists, and the moment that matters is the one before that: an early piece written before the video was muxed sets the requirement to one and waves through precisely the header this exists to reject. Pieces remain as a floor, since a piece carrying more than declared is evidence, and evidence outranks a declaration.
|
|
4
|
+
|
|
5
|
+
## 2.9.135
|
|
6
|
+
|
|
7
|
+
- **Fix**: A session no longer plays sound with no picture at all. The init segment — the header that tells the browser which tracks exist — is lifted out of the first self-contained piece and then cached for the WHOLE session, because the player fetches `#EXT-X-MAP` once and never again. A piece written before the video track had been muxed declares audio alone, and the browser then has no video source buffer for the rest of the session however much video arrives afterwards. Measured 2026-08-10 from the browser's own counters: sixty-five seconds of playing sound with `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element perfectly satisfied, with no picture in it. A header short of a track is now passed over and the next piece tried; if no piece carries the full set the richest one found is served and the shortfall is logged, so a source that genuinely lacks a stream still plays while the other possibility stays visible.
|
|
8
|
+
|
|
1
9
|
## 2.9.134
|
|
2
10
|
|
|
3
11
|
- **Fix**: The line reporting how long a segment waited before anyone decided to restart for it now prints. A restart backs off a segment or two from what was asked for, so the request that prompted it is recorded under a higher index than the run starts at; looking it up by the start index alone found nothing, and the instrument added in 2.9.132 never said a word. It now takes the earliest request at or above the index the run begins from.
|
package/package.json
CHANGED
|
@@ -1528,6 +1528,35 @@ export class HlsSessionManager {
|
|
|
1528
1528
|
if (typeof session.segmentFormat.extractInit !== "function") {
|
|
1529
1529
|
return null;
|
|
1530
1530
|
}
|
|
1531
|
+
// How many tracks a complete header must declare is ANSWERED, not assumed.
|
|
1532
|
+
//
|
|
1533
|
+
// The probe already knows the source's stream list, and the output maps at
|
|
1534
|
+
// most one of each (`-map 0:v:0? -map 0:a:0?`), so the count follows from
|
|
1535
|
+
// what the source actually has. A film with no soundtrack expects one; an
|
|
1536
|
+
// ordinary file expects two; neither is a convention.
|
|
1537
|
+
//
|
|
1538
|
+
// Deriving it from the produced pieces instead — the first version of this
|
|
1539
|
+
// — reads correctly only once a piece carrying every track exists, and the
|
|
1540
|
+
// whole point is the moment BEFORE that: early pieces written before the
|
|
1541
|
+
// video was muxed would set the requirement to one and wave through exactly
|
|
1542
|
+
// the header this exists to reject. The pieces are still consulted, but
|
|
1543
|
+
// only as a floor: a piece carrying more than the probe led us to expect is
|
|
1544
|
+
// evidence, and evidence outranks the probe.
|
|
1545
|
+
const probed = this.getCachedMediaInfo?.({
|
|
1546
|
+
sourceKey: session.sourceKey,
|
|
1547
|
+
fileIndex: session.fileIndex
|
|
1548
|
+
}) ?? null;
|
|
1549
|
+
let expectedTracks = probed
|
|
1550
|
+
? (probed.videoCodec ? 1 : 0) + (probed.audioCodec ? 1 : 0)
|
|
1551
|
+
: 0;
|
|
1552
|
+
if (expectedTracks === 0) {
|
|
1553
|
+
// No probe to consult. Fall back to the evidence, with its known lag.
|
|
1554
|
+
expectedTracks = 1;
|
|
1555
|
+
}
|
|
1556
|
+
let best = null;
|
|
1557
|
+
let bestTracks = 0;
|
|
1558
|
+
/** @type {Map<string, Buffer>} Pieces read once and used for both passes. */
|
|
1559
|
+
const pieces = new Map();
|
|
1531
1560
|
let names;
|
|
1532
1561
|
try {
|
|
1533
1562
|
names = (this.#runDirs(session).flatMap((dir) => {
|
|
@@ -1538,13 +1567,32 @@ export class HlsSessionManager {
|
|
|
1538
1567
|
} catch {
|
|
1539
1568
|
return null;
|
|
1540
1569
|
}
|
|
1570
|
+
// First pass: what do the produced pieces actually carry? The answer is the
|
|
1571
|
+
// requirement — no assumption about the source is involved.
|
|
1572
|
+
if (typeof session.segmentFormat.countSegmentTracks === "function") {
|
|
1573
|
+
for (const name of names) {
|
|
1574
|
+
try {
|
|
1575
|
+
const found = await this.#findProducedFile(session, name);
|
|
1576
|
+
if (!found) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
const bytes = await readFile(found);
|
|
1580
|
+
pieces.set(name, bytes);
|
|
1581
|
+
expectedTracks = Math.max(expectedTracks, session.segmentFormat.countSegmentTracks(bytes));
|
|
1582
|
+
} catch {
|
|
1583
|
+
// Being written right now — it says nothing about the others.
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1541
1588
|
for (const name of names) {
|
|
1542
1589
|
try {
|
|
1543
|
-
const
|
|
1590
|
+
const cached = pieces.get(name);
|
|
1591
|
+
const found = cached ? name : await this.#findProducedFile(session, name);
|
|
1544
1592
|
if (!found) {
|
|
1545
1593
|
continue;
|
|
1546
1594
|
}
|
|
1547
|
-
const init = session.segmentFormat.extractInit(await readFile(found));
|
|
1595
|
+
const init = session.segmentFormat.extractInit(cached ?? await readFile(found));
|
|
1548
1596
|
if (init && init.length > 0) {
|
|
1549
1597
|
return init;
|
|
1550
1598
|
}
|
|
@@ -1552,7 +1600,17 @@ export class HlsSessionManager {
|
|
|
1552
1600
|
// Being written right now — try the next one.
|
|
1553
1601
|
}
|
|
1554
1602
|
}
|
|
1555
|
-
|
|
1603
|
+
if (best !== null) {
|
|
1604
|
+
// Nothing carried the full set. The source is probably missing a stream;
|
|
1605
|
+
// serving the richest header found is right, and saying so makes the
|
|
1606
|
+
// other possibility — every piece so far written before the video was
|
|
1607
|
+
// muxed — visible rather than silent.
|
|
1608
|
+
logger.warn(
|
|
1609
|
+
`transcode ${session.id} no piece declared ${expectedTracks} tracks; ` +
|
|
1610
|
+
`serving an init with ${bestTracks}`
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
return best;
|
|
1556
1614
|
}
|
|
1557
1615
|
|
|
1558
1616
|
/**
|
|
@@ -1,262 +1,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
|
-
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
};
|