@torrent-tv/proxy 2.9.83 → 2.9.85
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
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.85
|
|
2
|
+
|
|
3
|
+
- **Fix**: fMP4 playback did not start at all in 2.9.84 — every request for the init segment answered 500. ffmpeg had refused to open the output: `Could not write header (incorrect codec parameters ?)`, because the `segment` muxer determines the container from the file extension and does not recognise `.m4s` for MP4, whatever `-segment_format` says. Segments are now written and named `.mp4` on both paths. The extension is internal: it appears only in our own playlist and in the temporary directory, so nothing outside changes.
|
|
4
|
+
|
|
5
|
+
## 2.9.84
|
|
6
|
+
|
|
7
|
+
- **Fix**: fMP4 now cuts segments where the playlist says too, closing the gap left by 2.9.82 (which covered MPEG-TS only). The muxer that takes explicit cut times writes each fMP4 piece self-contained — `ftyp moov moof mdat … mfra`, confirmed on the field host — which is not what HLS wants, so the pieces are split on serve: the header is lifted out of the first one to become the init segment named by `#EXT-X-MAP`, and removed from every media segment along with the trailing random-access index, whose offsets describe a file that no longer exists. Timestamps still need stamping exactly as before: measured, all pieces of a run report a start of 0.080 s, each carrying its own zero, which is the same defect the existing rewriting already corrects. Verified end to end on a real piece from the field host — split into a 779-byte init and 221 KB of fragments, recombined, and decoded clean.
|
|
8
|
+
|
|
1
9
|
## 2.9.83
|
|
2
10
|
|
|
3
11
|
- **Fix**: Playback died a few seconds in after 2.9.82. The previous muxer wrote each segment under a temporary name and renamed it once complete, so a file appearing WAS a finished segment; the one that takes explicit cut times has no such option and creates the file when writing starts. The route kept judging readiness by existence, so the player was handed a segment that was still being written, rejected it and stopped — while the encoder ran happily ahead, which is exactly how it looked in the field: three segments served, then silence with the transcode at 7.5x. A segment is now considered finished once the next one has been started, or once the run producing it has ended.
|
package/package.json
CHANGED
|
@@ -1222,6 +1222,41 @@ export class HlsSessionManager {
|
|
|
1222
1222
|
* @returns {Promise<number[] | null>} Ascending seconds, or null when this
|
|
1223
1223
|
* file carries no readable index.
|
|
1224
1224
|
*/
|
|
1225
|
+
/**
|
|
1226
|
+
* The init header, lifted out of the first segment that exists.
|
|
1227
|
+
*
|
|
1228
|
+
* Needed only on the explicit-cut path, where the muxer produces no init file
|
|
1229
|
+
* of its own. Scans rather than assuming segment 0: a run started by a seek
|
|
1230
|
+
* begins at whatever index the viewer asked for.
|
|
1231
|
+
*
|
|
1232
|
+
* @param {HlsSession} session
|
|
1233
|
+
* @returns {Promise<Buffer | null>}
|
|
1234
|
+
*/
|
|
1235
|
+
async #initFromFirstSegment(session) {
|
|
1236
|
+
if (typeof this.segmentFormat.extractInit !== "function") {
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
let names;
|
|
1240
|
+
try {
|
|
1241
|
+
names = (await readdir(session.dirPath))
|
|
1242
|
+
.filter((name) => this.segmentFormat.isSegmentFileName(name))
|
|
1243
|
+
.sort();
|
|
1244
|
+
} catch {
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
for (const name of names) {
|
|
1248
|
+
try {
|
|
1249
|
+
const init = this.segmentFormat.extractInit(await readFile(path.join(session.dirPath, name)));
|
|
1250
|
+
if (init && init.length > 0) {
|
|
1251
|
+
return init;
|
|
1252
|
+
}
|
|
1253
|
+
} catch {
|
|
1254
|
+
// Being written right now — try the next one.
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1225
1260
|
async #readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName }) {
|
|
1226
1261
|
const cacheKey = `${sourceKey}:${fileIndex}`;
|
|
1227
1262
|
if (this.keyframeIndexCache.has(cacheKey)) {
|
|
@@ -2472,8 +2507,13 @@ export class HlsSessionManager {
|
|
|
2472
2507
|
};
|
|
2473
2508
|
}
|
|
2474
2509
|
try {
|
|
2475
|
-
|
|
2476
|
-
|
|
2510
|
+
// With explicit cut times there is no init file: that muxer writes each
|
|
2511
|
+
// piece self-contained, header and all. The header is identical in every
|
|
2512
|
+
// piece, so the first one to exist supplies it.
|
|
2513
|
+
const bytes = session.usesExplicitCuts
|
|
2514
|
+
? await this.#initFromFirstSegment(session)
|
|
2515
|
+
: await readFile(path.join(session.dirPath, initFileName));
|
|
2516
|
+
if (!bytes || bytes.length === 0) {
|
|
2477
2517
|
return { kind: "warming-up" };
|
|
2478
2518
|
}
|
|
2479
2519
|
session.initBytes = bytes;
|
|
@@ -2528,7 +2568,11 @@ export class HlsSessionManager {
|
|
|
2528
2568
|
// module; the rest stream straight off disk.
|
|
2529
2569
|
if (!isPlaylist && this.segmentFormat.needsSegmentRewrite) {
|
|
2530
2570
|
const index = this.segmentFormat.segmentIndexFromName(fileName);
|
|
2531
|
-
const
|
|
2571
|
+
const raw = await readFile(filePath);
|
|
2572
|
+
// Self-contained pieces carry the init header; a media segment must not.
|
|
2573
|
+
const bytes = session.usesExplicitCuts && this.segmentFormat.stripInit
|
|
2574
|
+
? this.segmentFormat.stripInit(raw)
|
|
2575
|
+
: raw;
|
|
2532
2576
|
const prepared = this.segmentFormat.prepareSegmentBytes(bytes, {
|
|
2533
2577
|
startSeconds: this.#segmentStartTime(session, index),
|
|
2534
2578
|
initBytes: session.initBytes ?? null
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file fMP4 (CMAF) segment format — `.
|
|
2
|
+
* @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
|
|
3
3
|
* `init.mp4` referenced by `#EXT-X-MAP`.
|
|
4
4
|
*
|
|
5
5
|
* Codec configuration (SPS/PPS) lives once in the init segment instead of being
|
|
@@ -12,8 +12,49 @@
|
|
|
12
12
|
|
|
13
13
|
import { readTrackTimescales, stampSegmentStartTime } from "./mp4-boxes.js";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Where the fragments begin and where the trailing index starts, as offsets of
|
|
17
|
+
* whole boxes at the TOP level.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately not `walkBoxes`: that reports the start of a box's *body* and
|
|
20
|
+
* descends into containers, so it cannot say where a box begins — and cutting a
|
|
21
|
+
* file at the wrong offset by eight bytes produces something that parses as
|
|
22
|
+
* garbage rather than failing outright.
|
|
23
|
+
*
|
|
24
|
+
* @param {Buffer} bytes
|
|
25
|
+
* @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
|
|
26
|
+
*/
|
|
27
|
+
function findFragmentBounds(bytes) {
|
|
28
|
+
let firstFragment = -1;
|
|
29
|
+
let trailingIndex = -1;
|
|
30
|
+
let offset = 0;
|
|
31
|
+
while (offset + 8 <= bytes.length) {
|
|
32
|
+
let size = bytes.readUInt32BE(offset);
|
|
33
|
+
const type = bytes.toString("latin1", offset + 4, offset + 8);
|
|
34
|
+
if (size === 1) {
|
|
35
|
+
if (offset + 16 > bytes.length) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
size = Number(bytes.readBigUInt64BE(offset + 8));
|
|
39
|
+
} else if (size === 0) {
|
|
40
|
+
size = bytes.length - offset;
|
|
41
|
+
}
|
|
42
|
+
if (size < 8) {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
|
|
46
|
+
firstFragment = offset;
|
|
47
|
+
}
|
|
48
|
+
if (type === "mfra") {
|
|
49
|
+
trailingIndex = offset;
|
|
50
|
+
}
|
|
51
|
+
offset += size;
|
|
52
|
+
}
|
|
53
|
+
return { firstFragment, trailingIndex };
|
|
54
|
+
}
|
|
55
|
+
|
|
15
56
|
const INIT_FILE_NAME = "init.mp4";
|
|
16
|
-
const SEGMENT_PATTERN = /^segment-(\d{5})\.
|
|
57
|
+
const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
|
|
17
58
|
|
|
18
59
|
/**
|
|
19
60
|
* @type {import("./index.js").SegmentFormat}
|
|
@@ -33,26 +74,72 @@ export const fmp4Format = {
|
|
|
33
74
|
"-hls_fmp4_init_filename",
|
|
34
75
|
INIT_FILE_NAME,
|
|
35
76
|
"-hls_segment_filename",
|
|
36
|
-
"segment-%05d.
|
|
77
|
+
"segment-%05d.mp4"
|
|
37
78
|
];
|
|
38
79
|
},
|
|
39
80
|
|
|
40
81
|
/**
|
|
41
|
-
*
|
|
82
|
+
* Cut at times we choose rather than times ffmpeg picks.
|
|
83
|
+
*
|
|
84
|
+
* The `segment` muxer writes each fMP4 piece as a **self-contained** file:
|
|
85
|
+
* `ftyp moov moof mdat … mfra`, verified on the field host. That is not what
|
|
86
|
+
* HLS wants — it wants one init segment named by `#EXT-X-MAP` and media
|
|
87
|
+
* segments carrying only fragments — so the pieces are split on serve:
|
|
88
|
+
* {@link extractInit} takes the header off the first one to serve as the init,
|
|
89
|
+
* and {@link stripInit} removes it from every one.
|
|
42
90
|
*
|
|
43
|
-
* The
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* failure to get right. Changing all of that at once, on a path no current
|
|
48
|
-
* deployment exercises and that cannot be verified without a real browser, is
|
|
49
|
-
* how the last round of regressions happened. MPEG-TS, which is what runs in
|
|
50
|
-
* the field, gets the fix first.
|
|
91
|
+
* The timestamps still need stamping, exactly as before: measured on the
|
|
92
|
+
* field host, all three pieces of a run reported a start time of 0.080 s,
|
|
93
|
+
* i.e. each carries its own zero. That is the same defect the shared-init
|
|
94
|
+
* path already corrects, so `prepareSegmentBytes` handles both.
|
|
51
95
|
*
|
|
52
|
-
* @returns {
|
|
96
|
+
* @returns {string[]}
|
|
53
97
|
*/
|
|
54
98
|
explicitTimesMuxerArgs() {
|
|
55
|
-
return
|
|
99
|
+
return [
|
|
100
|
+
"-segment_format",
|
|
101
|
+
"mp4",
|
|
102
|
+
// `empty_moov` is what makes each piece self-describing, which is what
|
|
103
|
+
// lets the init be lifted out of it; `default_base_moof` keeps fragment
|
|
104
|
+
// offsets relative, so removing the header does not invalidate them.
|
|
105
|
+
"-segment_format_options",
|
|
106
|
+
"movflags=+frag_keyframe+empty_moov+default_base_moof"
|
|
107
|
+
];
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** The output path template for the `segment` muxer. */
|
|
111
|
+
segmentFileNameTemplate() {
|
|
112
|
+
return "segment-%05d.mp4";
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The init part of a self-contained piece: everything before the first
|
|
117
|
+
* fragment.
|
|
118
|
+
*
|
|
119
|
+
* @param {Buffer} bytes
|
|
120
|
+
* @returns {Buffer | null} `null` when the piece carries no fragment, which
|
|
121
|
+
* means it is not one of these and must not be cut up.
|
|
122
|
+
*/
|
|
123
|
+
extractInit(bytes) {
|
|
124
|
+
const { firstFragment } = findFragmentBounds(bytes);
|
|
125
|
+
return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A piece with its init header removed, and the trailing random-access index
|
|
130
|
+
* dropped — a player reading a media segment has no use for either, and
|
|
131
|
+
* `mfra` describes offsets that stop being true once the header is gone.
|
|
132
|
+
*
|
|
133
|
+
* @param {Buffer} bytes
|
|
134
|
+
* @returns {Buffer}
|
|
135
|
+
*/
|
|
136
|
+
stripInit(bytes) {
|
|
137
|
+
const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
|
|
138
|
+
if (firstFragment < 0) {
|
|
139
|
+
return bytes;
|
|
140
|
+
}
|
|
141
|
+
const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
|
|
142
|
+
return bytes.subarray(firstFragment, end);
|
|
56
143
|
},
|
|
57
144
|
|
|
58
145
|
playlistHeaderLines() {
|
|
@@ -64,7 +151,7 @@ export const fmp4Format = {
|
|
|
64
151
|
},
|
|
65
152
|
|
|
66
153
|
segmentFileName(index) {
|
|
67
|
-
return `segment-${String(index).padStart(5, "0")}.
|
|
154
|
+
return `segment-${String(index).padStart(5, "0")}.mp4`;
|
|
68
155
|
},
|
|
69
156
|
|
|
70
157
|
isSegmentFileName(fileName) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splitting a self-contained fMP4 piece into an init segment and a media
|
|
3
|
+
* segment.
|
|
4
|
+
*
|
|
5
|
+
* The muxer that takes explicit cut times writes every piece whole —
|
|
6
|
+
* `ftyp moov moof mdat … mfra` — but HLS wants one init named by `#EXT-X-MAP`
|
|
7
|
+
* and media segments carrying only fragments. Cutting at the wrong offset does
|
|
8
|
+
* not fail loudly; it produces something that parses as garbage, which is why
|
|
9
|
+
* the boundaries are pinned here rather than trusted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A piece shaped like the muxer's output. Sizes are what matters, not contents.
|
|
18
|
+
*
|
|
19
|
+
* @param {{ withIndex?: boolean }} [options]
|
|
20
|
+
* @returns {{ piece: Buffer, initLength: number, fragmentsLength: number }}
|
|
21
|
+
*/
|
|
22
|
+
function makePiece({ withIndex = true } = {}) {
|
|
23
|
+
const box = (type, bodyLength) => {
|
|
24
|
+
const buffer = Buffer.alloc(8 + bodyLength);
|
|
25
|
+
buffer.writeUInt32BE(8 + bodyLength, 0);
|
|
26
|
+
buffer.write(type, 4, "latin1");
|
|
27
|
+
buffer.fill(0x5a, 8);
|
|
28
|
+
return buffer;
|
|
29
|
+
};
|
|
30
|
+
const ftyp = box("ftyp", 24);
|
|
31
|
+
const moov = box("moov", 400);
|
|
32
|
+
const moof = box("moof", 120);
|
|
33
|
+
const mdat = box("mdat", 900);
|
|
34
|
+
const mfra = box("mfra", 40);
|
|
35
|
+
const parts = withIndex ? [ftyp, moov, moof, mdat, mfra] : [ftyp, moov, moof, mdat];
|
|
36
|
+
return {
|
|
37
|
+
piece: Buffer.concat(parts),
|
|
38
|
+
initLength: ftyp.length + moov.length,
|
|
39
|
+
fragmentsLength: moof.length + mdat.length
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("the init is everything before the first fragment", () => {
|
|
44
|
+
const { piece, initLength } = makePiece();
|
|
45
|
+
const init = fmp4Format.extractInit(piece);
|
|
46
|
+
assert.ok(init, "no init found in a piece that has one");
|
|
47
|
+
assert.equal(init.length, initLength, "init must end exactly where the first fragment begins");
|
|
48
|
+
assert.equal(init.toString("latin1", 4, 8), "ftyp", "init must start at the file header");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("the media segment is the fragments alone, index dropped", () => {
|
|
52
|
+
const { piece, initLength, fragmentsLength } = makePiece();
|
|
53
|
+
const media = fmp4Format.stripInit(piece);
|
|
54
|
+
assert.equal(media.toString("latin1", 4, 8), "moof", "a media segment must begin at a fragment");
|
|
55
|
+
assert.equal(
|
|
56
|
+
media.length,
|
|
57
|
+
fragmentsLength,
|
|
58
|
+
"the trailing random-access index must be dropped: its offsets describe a file that no longer exists"
|
|
59
|
+
);
|
|
60
|
+
assert.equal(piece.length, initLength + fragmentsLength + 48, "test fixture sanity");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a piece without a trailing index keeps everything to the end", () => {
|
|
64
|
+
const { piece, fragmentsLength } = makePiece({ withIndex: false });
|
|
65
|
+
assert.equal(fmp4Format.stripInit(piece).length, fragmentsLength);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a segment that is already fragments-only is left alone", () => {
|
|
69
|
+
const alreadyMedia = fmp4Format.stripInit(makePiece().piece);
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
fmp4Format.stripInit(alreadyMedia),
|
|
72
|
+
alreadyMedia,
|
|
73
|
+
"stripping twice must not eat the first fragment"
|
|
74
|
+
);
|
|
75
|
+
assert.equal(
|
|
76
|
+
fmp4Format.extractInit(alreadyMedia),
|
|
77
|
+
null,
|
|
78
|
+
"there is no init to lift out of a media segment, and inventing one would corrupt playback"
|
|
79
|
+
);
|
|
80
|
+
});
|