@torrent-tv/proxy 2.9.82 → 2.9.84
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.84
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
|
|
5
|
+
## 2.9.83
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
1
9
|
## 2.9.82
|
|
2
10
|
|
|
3
11
|
- **Fix**: The playlist and the real segments now describe the same thing. On the copy path ffmpeg was given only a target duration and chose its own cut points, while the playlist was built from the container keyframe index — two independent calculations tied together by nothing but the assumption that they agree. They do not: the index is a navigation table and is not obliged to list every keyframe. On a field file it held 1902 while ffmpeg found roughly twice as many and cut twice as often, so segment #876 meant 1:26:50 to the player and about minute 58 to ffmpeg. A seek into the middle landed at the end and the reported duration drifted. ffmpeg now receives the very boundaries the playlist was built from, via the `segment` muxer, which takes the list outright — agreement by construction instead of by luck. Verified on deliberately uneven keyframes: cuts requested at 4.44, 10.36, 16.28, 22.2 and 28.12 s landed exactly there. Two measured details are encoded in the code: those times count from the start of the RUN, not of the file (starting at 12 s and asking for 18 s put the cut at 29.4), and a tolerance absorbs rounding so a boundary recorded a hair late cannot skip to the next keyframe and silently double a segment. MPEG-TS only for now — fMP4 can do this too, but only as self-contained fragments, which removes the shared init segment and the `tfdt` rewriting built around it; that is a separate change and not one to make blind.
|
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)) {
|
|
@@ -1868,6 +1903,9 @@ export class HlsSessionManager {
|
|
|
1868
1903
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1869
1904
|
});
|
|
1870
1905
|
session.ffmpeg = ffmpeg;
|
|
1906
|
+
// Whether this run cuts at times we gave it. Decides how a segment is
|
|
1907
|
+
// judged finished — see getFileStream.
|
|
1908
|
+
session.usesExplicitCuts = Boolean(cutTimes && cutTimes.length > 0);
|
|
1871
1909
|
session.encodeStartIndex = safeIndex;
|
|
1872
1910
|
session.pendingRestartIndex = -1;
|
|
1873
1911
|
session.lastRestartAt = Date.now();
|
|
@@ -2469,8 +2507,13 @@ export class HlsSessionManager {
|
|
|
2469
2507
|
};
|
|
2470
2508
|
}
|
|
2471
2509
|
try {
|
|
2472
|
-
|
|
2473
|
-
|
|
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) {
|
|
2474
2517
|
return { kind: "warming-up" };
|
|
2475
2518
|
}
|
|
2476
2519
|
session.initBytes = bytes;
|
|
@@ -2491,6 +2534,27 @@ export class HlsSessionManager {
|
|
|
2491
2534
|
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
2492
2535
|
try {
|
|
2493
2536
|
await access(filePath);
|
|
2537
|
+
|
|
2538
|
+
// Existing is not the same as finished. The `hls` muxer wrote each
|
|
2539
|
+
// segment to a temporary name and renamed it once complete, so a file
|
|
2540
|
+
// appearing WAS a finished segment. The `segment` muxer has no such
|
|
2541
|
+
// option: the file appears when writing begins. Serving it then hands the
|
|
2542
|
+
// player a truncated segment, which it rejects and then simply stops —
|
|
2543
|
+
// observed as playback dying a few seconds in with the encoder still
|
|
2544
|
+
// running happily ahead. A segment is finished once the NEXT one has been
|
|
2545
|
+
// started, or once the run producing it has ended.
|
|
2546
|
+
if (!isPlaylist && session.usesExplicitCuts) {
|
|
2547
|
+
const index = this.segmentFormat.segmentIndexFromName(fileName);
|
|
2548
|
+
const isLast = index >= Math.max(0, (session.segmentBoundaries?.length ?? 1) - 2);
|
|
2549
|
+
if (!isLast && session.ffmpeg) {
|
|
2550
|
+
const nextPath = path.join(session.dirPath, this.segmentFormat.segmentFileName(index + 1));
|
|
2551
|
+
try {
|
|
2552
|
+
await access(nextPath);
|
|
2553
|
+
} catch {
|
|
2554
|
+
return { kind: "warming-up" };
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2494
2558
|
// Cold-start: log the first servable SEGMENT of this session exactly once
|
|
2495
2559
|
// — the time from session-create entry to a playable first segment.
|
|
2496
2560
|
if (!isPlaylist && !session.firstSegmentLogged) {
|
|
@@ -2504,7 +2568,11 @@ export class HlsSessionManager {
|
|
|
2504
2568
|
// module; the rest stream straight off disk.
|
|
2505
2569
|
if (!isPlaylist && this.segmentFormat.needsSegmentRewrite) {
|
|
2506
2570
|
const index = this.segmentFormat.segmentIndexFromName(fileName);
|
|
2507
|
-
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;
|
|
2508
2576
|
const prepared = this.segmentFormat.prepareSegmentBytes(bytes, {
|
|
2509
2577
|
startSeconds: this.#segmentStartTime(session, index),
|
|
2510
2578
|
initBytes: session.initBytes ?? null
|
|
@@ -12,6 +12,47 @@
|
|
|
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
57
|
const SEGMENT_PATTERN = /^segment-(\d{5})\.m4s$/;
|
|
17
58
|
|
|
@@ -38,21 +79,67 @@ export const fmp4Format = {
|
|
|
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.m4s";
|
|
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() {
|
|
@@ -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
|
+
});
|