@torrent-tv/proxy 2.81.2 → 2.82.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.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/server.js +38 -19
- package/services/encode/Encoder.js +27 -0
- package/services/encode/QsvEncoder.js +6 -0
- package/services/encode/SoftwareEncoder.js +5 -0
- package/services/encode/VaapiEncoder.js +8 -0
- package/services/encode/run-costs.js +37 -2
- package/services/encode/start-stop-cost.js +174 -0
- package/services/hls-session-manager.js +6 -0
- package/services/hwaccel.js +1850 -1843
- package/services/orchestrators/EncodeOrchestrator.js +11 -0
- package/test/startup-readings.test.js +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.82.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: THE ENCODER PING-PONGED BECAUSE NOTHING MEASURED WHAT A START COSTS. The plan compares when the wanted pieces appear under each arrangement, and for that it needs how long a fresh encoder takes to produce anything and how long killing one takes. Both were learned only from runs that had ENDED, so at a cold open both were zero — and zero does not read as “not measured”, it reads as “free”. Keeping a warming encoder then cost one piece and moving it cost `0 + 0 + one piece`: the same figure to the millisecond, so the tie fell to position and any advantage however small won. Field 2026-09-08: start at #68, a second later kill and start at #69, half a second later kill and start at #68 again, each dying having produced nothing. Over two days 153 runs were stopped that way and 68 of them made no segment at all.
|
|
4
|
+
**Both are measured at startup now, from one ffmpeg run**: spawn to the first piece the encoder itself announces closed, then SIGTERM to exit. 0.68 s and 0.02 s on the developer’s desktop. Readings from real runs replace them as they arrive. Worth stating because it is what the arithmetic already assumed: subtract keeping from moving and what is left is the killing plus the time the run has already lived — the warm-up a move throws away. That term exists in the formula and vanishes with the measurement, which is why the plan behaved as though a move were free.
|
|
5
|
+
- **Fix**: The decode cost and the contention penalty were asked only where the chosen encoder was SOFTWARE, and neither is about the encoder. A host with a GPU decodes in software just the same — no hardware decoder is asked for anywhere — and what a second job costs is a property of the machine. So a GPU host had no decode model, no contention penalty and no encoder throughput at all, and the quality offer, which is arithmetic over those three, had nothing to compute from.
|
|
6
|
+
- **New**: The throughput benchmark walks the CHOSEN encoder’s own speed ladder — libx264’s presets, NVENC’s `p1`…`p7`, QSV’s `veryfast`…`veryslow`, VAAPI’s quality levels. Each kind says how to measure itself (`Encoder.benchmarkArgs`), which is what the ladders declared and nothing ever ran. A kind with no ladder is measured once, which is still a reading where there was none.
|
|
7
|
+
- **Chore**: 1000 checks pass, biome clean. NOT yet seen in the field. What the next session must show: `a start costs …s to a first piece and a stop …s on this host` once at startup, `firstByte=` and `kill=` non-zero in the very first `encode-plan` line, and no run stopped with “scores worse than standing at” an adjacent number.
|
|
8
|
+
|
|
1
9
|
## 2.81.2
|
|
2
10
|
|
|
3
11
|
- **Fix**: `disk: 0MB free` on a host with 103 GB. The free space was read from the segments’ own directory, which is made when the first session starts and removed when the proxy stops — so at every start, and after every clean exit, the reading failed and answered zero, which means “no room” to everything downstream. It reads the nearest ancestor that exists; the disk is the same disk either way.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -40,6 +40,7 @@ import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
|
40
40
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
41
41
|
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
42
42
|
import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport } from "./services/hwaccel.js";
|
|
43
|
+
import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
|
|
43
44
|
import { logger } from "./utils/logger.js";
|
|
44
45
|
|
|
45
46
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -126,23 +127,31 @@ export async function startProxyServer({
|
|
|
126
127
|
const videoEncoder = transcodeAudio
|
|
127
128
|
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
128
129
|
: null;
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
130
|
+
// WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
|
|
131
|
+
// was gated on the chosen encoder being SOFTWARE, and two of the three are
|
|
132
|
+
// not about the encoder at all: a host with a GPU decodes in software just
|
|
133
|
+
// the same — no hardware decoder is asked for anywhere — and what a second
|
|
134
|
+
// job costs is a property of the machine. So a GPU host had no decode cost,
|
|
135
|
+
// no contention penalty and no encoder throughput, and the quality offer,
|
|
136
|
+
// which is arithmetic over those three, had nothing to compute from.
|
|
137
|
+
//
|
|
138
|
+
// The decode model first and the throughput second — they are independent
|
|
139
|
+
// (the rungs are timed on raw frames), but the order keeps the two figures
|
|
140
|
+
// side by side in the log.
|
|
141
|
+
const decodeCostModel = transcodeAudio
|
|
136
142
|
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
137
143
|
: null;
|
|
138
144
|
// What a second job costs on this host. Measured because the budget adds
|
|
139
145
|
// independent prices and this host says two jobs that each fit alone do not
|
|
140
146
|
// fit together — 2.6× on the addon box (2026-08-18).
|
|
141
|
-
const contentionPenalties =
|
|
147
|
+
const contentionPenalties = transcodeAudio
|
|
142
148
|
? await benchmarkContention({ ffmpegBin, logger })
|
|
143
149
|
: null;
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
// The chosen encoder walked over its OWN speed ladder: libx264's presets,
|
|
151
|
+
// NVENC's p1…p7, QSV's veryfast…veryslow, VAAPI's quality levels. A kind with
|
|
152
|
+
// no ladder is measured once, which is still a reading where there was none.
|
|
153
|
+
const softwarePresetBenchmark = videoEncoder
|
|
154
|
+
? await benchmarkSoftwarePresets({ ffmpegBin, logger, encoder: videoEncoder })
|
|
146
155
|
: null;
|
|
147
156
|
// What this host does with a picture it does NOT re-encode. Every other
|
|
148
157
|
// startup measurement prices encoding or decoding, and a copied picture does
|
|
@@ -155,6 +164,15 @@ export async function startProxyServer({
|
|
|
155
164
|
const copySpeedX = transcodeAudio
|
|
156
165
|
? await benchmarkCopySpeed({ ffmpegBin, logger })
|
|
157
166
|
: null;
|
|
167
|
+
// WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
|
|
168
|
+
// thing — leave an encoder where it stands, or kill it and start another —
|
|
169
|
+
// and both used to be learned only from runs that had ENDED, so at a cold
|
|
170
|
+
// open they were zero. Zero does not read as "not measured": it reads as
|
|
171
|
+
// "free", and a free move is always taken. Field 2026-09-08: an encoder moved
|
|
172
|
+
// between two adjacent numbers every half second and produced nothing.
|
|
173
|
+
const startStopCost = transcodeAudio
|
|
174
|
+
? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
|
|
175
|
+
: null;
|
|
158
176
|
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
159
177
|
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
160
178
|
// sources on the software path when available.
|
|
@@ -171,6 +189,7 @@ export async function startProxyServer({
|
|
|
171
189
|
decodeCostModel,
|
|
172
190
|
contentionPenalties,
|
|
173
191
|
copySpeedX,
|
|
192
|
+
startStopCost,
|
|
174
193
|
tonemapSupported,
|
|
175
194
|
segmentFormatId: segmentFormat,
|
|
176
195
|
stateDir,
|
|
@@ -178,15 +197,15 @@ export async function startProxyServer({
|
|
|
178
197
|
// CPU-bound transcode from a download-starved input before downscaling.
|
|
179
198
|
// What every torrent here has moved, so the proxy can price its own
|
|
180
199
|
// downloading, hashing and delivery against the machine (roadmap item 7).
|
|
181
|
-
// What the spilled pieces weigh on the torrent thread, and how to tell them
|
|
182
|
-
// their share of the disk. The owner of the disk is on this side, where the
|
|
183
|
-
// segments are; the pieces are on the other.
|
|
184
|
-
spillDisk: typeof torrentPool.allowSpillBytes === "function"
|
|
185
|
-
? {
|
|
186
|
-
held: () => torrentPool.spilledBytes ?? 0,
|
|
187
|
-
allow: (bytes) => torrentPool.allowSpillBytes(bytes)
|
|
188
|
-
}
|
|
189
|
-
: null,
|
|
200
|
+
// What the spilled pieces weigh on the torrent thread, and how to tell them
|
|
201
|
+
// their share of the disk. The owner of the disk is on this side, where the
|
|
202
|
+
// segments are; the pieces are on the other.
|
|
203
|
+
spillDisk: typeof torrentPool.allowSpillBytes === "function"
|
|
204
|
+
? {
|
|
205
|
+
held: () => torrentPool.spilledBytes ?? 0,
|
|
206
|
+
allow: (bytes) => torrentPool.allowSpillBytes(bytes)
|
|
207
|
+
}
|
|
208
|
+
: null,
|
|
190
209
|
getTorrentTotals: async () => {
|
|
191
210
|
if (typeof torrentPool.getTorrentTotals !== "function") {
|
|
192
211
|
return null;
|
|
@@ -81,4 +81,31 @@ export class Encoder {
|
|
|
81
81
|
buildVideoArgs(_options) {
|
|
82
82
|
throw new Error(`${this.name} does not say how to build its video arguments.`);
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How to encode raw frames at one rung of this kind's speed ladder, for the
|
|
87
|
+
* startup benchmark and nothing else.
|
|
88
|
+
*
|
|
89
|
+
* SEPARATE FROM `buildVideoArgs` on purpose. That one produces a picture from
|
|
90
|
+
* a film: it scales, it tone-maps, it forces keyframes onto a grid, it caps a
|
|
91
|
+
* bitrate. The benchmark is fed raw frames of a known size and wants the
|
|
92
|
+
* encoder and the rung with nothing else in the way — otherwise the reading
|
|
93
|
+
* prices a scaler as though it were the encoder.
|
|
94
|
+
*
|
|
95
|
+
* WHY EVERY KIND MUST ANSWER IT. Until now the throughput benchmark existed
|
|
96
|
+
* only for libx264 and was gated on the chosen encoder being software, so a
|
|
97
|
+
* host with NVENC, QSV, VAAPI or V4L2M2M measured its encoder not at all —
|
|
98
|
+
* and the quality offer, which is arithmetic over pixels per second, had no
|
|
99
|
+
* pixels per second to work with. NVENC's own ladder is `p1`…`p7` and QSV's
|
|
100
|
+
* is `veryfast`…`veryslow`; both are declared here and neither was ever run.
|
|
101
|
+
*
|
|
102
|
+
* @param {string | null} rung - One value of {@link speedLadder}, or null
|
|
103
|
+
* where the kind has no ladder and there is one thing to measure.
|
|
104
|
+
* @returns {string[]}
|
|
105
|
+
*/
|
|
106
|
+
benchmarkArgs(rung = null) {
|
|
107
|
+
const ladder = this.speedLadder;
|
|
108
|
+
const setting = ladder?.flag && rung ? [ladder.flag, rung] : [];
|
|
109
|
+
return ["-c:v", this.name, ...setting];
|
|
110
|
+
}
|
|
84
111
|
}
|
|
@@ -35,6 +35,12 @@ export class QsvEncoder extends Encoder {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** @param {string | null} rung @returns {string[]} */
|
|
39
|
+
benchmarkArgs(rung = null) {
|
|
40
|
+
const preset = rung ? ["-preset", rung] : [];
|
|
41
|
+
return ["-c:v", "h264_qsv", "-global_quality", "24", ...preset];
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
|
|
39
45
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
40
46
|
return [
|
|
@@ -42,6 +42,11 @@ export class SoftwareEncoder extends Encoder {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** @param {string | null} rung @returns {string[]} */
|
|
46
|
+
benchmarkArgs(rung = null) {
|
|
47
|
+
return ["-c:v", "libx264", "-preset", rung ?? SOFTWARE_PRESET, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p"];
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
buildVideoArgs({
|
|
46
51
|
targetWidth,
|
|
47
52
|
targetHeight,
|
|
@@ -40,6 +40,14 @@ export class VaapiEncoder extends Encoder {
|
|
|
40
40
|
|
|
41
41
|
// No fps filter: VAAPI inherits the source rate and keeps keyframes on the
|
|
42
42
|
// grid via time-based -force_key_frames, so it already honours source fps.
|
|
43
|
+
/** @param {string | null} rung @returns {string[]} */
|
|
44
|
+
benchmarkArgs(rung = null) {
|
|
45
|
+
// Raw frames live in this process; VAAPI encodes what is in the device, so
|
|
46
|
+
// the upload is part of what this kind costs and belongs in the reading.
|
|
47
|
+
const quality = rung ? ["-quality", rung] : [];
|
|
48
|
+
return ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...quality];
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
|
|
44
52
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
45
53
|
return [
|
|
@@ -48,6 +48,40 @@ export class RunCosts {
|
|
|
48
48
|
/** How long the first output took to appear, in milliseconds. @type {number[]} */
|
|
49
49
|
#firstOutput = [];
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* What this host was measured to do at startup, before any viewer existed.
|
|
53
|
+
*
|
|
54
|
+
* WITHOUT IT BOTH FIGURES ARE ZERO AT A COLD OPEN, and zero does not read as
|
|
55
|
+
* "not measured" — it reads as "free". The whole comparison the plan makes is
|
|
56
|
+
* between leaving an encoder where it stands, which costs the remainder of its
|
|
57
|
+
* warm-up, and moving it, which costs the killing plus a warm-up from the
|
|
58
|
+
* beginning. Subtract one from the other and what is left is the killing plus
|
|
59
|
+
* the time the run has already lived — the warm-up a move throws away. Set the
|
|
60
|
+
* warm-up to zero and that difference collapses to zero as well: keeping and
|
|
61
|
+
* moving cost exactly the same, the tie falls to position, and any advantage
|
|
62
|
+
* however small wins. Field 2026-09-08: an encoder moved between two adjacent
|
|
63
|
+
* numbers every half second, produced nothing, and was killed each time.
|
|
64
|
+
*
|
|
65
|
+
* Readings from real runs replace it as they arrive; this is where the plan
|
|
66
|
+
* starts from, not where it stays.
|
|
67
|
+
*
|
|
68
|
+
* @type {{ firstByteWaitSec: number, killCostSec: number } | null}
|
|
69
|
+
*/
|
|
70
|
+
#atStartup = null;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Take what the startup measurement found on this host.
|
|
74
|
+
*
|
|
75
|
+
* @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
|
|
76
|
+
* @returns {void}
|
|
77
|
+
*/
|
|
78
|
+
noteStartup(measured) {
|
|
79
|
+
this.#atStartup =
|
|
80
|
+
Number.isFinite(measured?.firstByteWaitSec) && measured.firstByteWaitSec > 0
|
|
81
|
+
? { firstByteWaitSec: measured.firstByteWaitSec, killCostSec: Math.max(0, measured.killCostSec ?? 0) }
|
|
82
|
+
: null;
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
/**
|
|
52
86
|
* Take the two readings a finished run carries. Either may be absent — a run
|
|
53
87
|
* that was never told to stop did not die on command, and one that produced
|
|
@@ -112,8 +146,9 @@ export class RunCosts {
|
|
|
112
146
|
// piece's encoding. Whoever uses it separates the two, because the piece
|
|
113
147
|
// costs more when encoders share the machine and the spawn does not.
|
|
114
148
|
return {
|
|
115
|
-
killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
|
|
116
|
-
firstByteWaitSec:
|
|
149
|
+
killCostSec: (middleOf(this.#dying) ?? 0) / 1000 || (this.#atStartup?.killCostSec ?? 0),
|
|
150
|
+
firstByteWaitSec:
|
|
151
|
+
(middleOf(this.#firstOutput) ?? 0) / 1000 || (this.#atStartup?.firstByteWaitSec ?? 0),
|
|
117
152
|
samples: Math.min(this.#dying.length, this.#firstOutput.length)
|
|
118
153
|
};
|
|
119
154
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What starting and stopping an encoder costs on THIS host, measured
|
|
3
|
+
* before any viewer exists.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT HAS TO BE MEASURED AT STARTUP. Both figures decide one thing: whether
|
|
6
|
+
* to leave an encoder where it stands or kill it and start another elsewhere.
|
|
7
|
+
* The plan compares when the wanted pieces appear under each arrangement, and
|
|
8
|
+
* for that it needs how long a fresh encoder takes to produce anything and how
|
|
9
|
+
* long killing one takes.
|
|
10
|
+
*
|
|
11
|
+
* Until now both were learned only from runs that had ENDED, so at a cold open
|
|
12
|
+
* they were zero — and zero does not read as "not measured", it reads as
|
|
13
|
+
* "free". A warming encoder then owed one piece and a moved one owed
|
|
14
|
+
* `0 + 0 + one piece`: the same figure to the millisecond. The tie fell to
|
|
15
|
+
* position, so any advantage however small won, and the plan moved the encoder
|
|
16
|
+
* on every pass. Field 2026-09-08, the first fifteen seconds of a session:
|
|
17
|
+
* start at #68, a second later kill and start at #69, half a second later kill
|
|
18
|
+
* and start at #68 again, each dying having produced nothing. Over two days,
|
|
19
|
+
* 153 runs stopped that way and 68 of them made no segment at all.
|
|
20
|
+
*
|
|
21
|
+
* ONE RUN GIVES BOTH READINGS. An encoder is started on a generated picture
|
|
22
|
+
* through the same pipeline a session uses, timed until it says it has closed
|
|
23
|
+
* its first piece, then killed and timed until it exits. Nothing about the
|
|
24
|
+
* measurement is chosen: it is the same encoder, the same muxer, the same
|
|
25
|
+
* channel the encoder announces finished pieces on.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long the measurement may take before it is abandoned.
|
|
35
|
+
*
|
|
36
|
+
* Not a property of the host and not a figure anything is derived from: it
|
|
37
|
+
* bounds a startup step so a machine that cannot produce a piece at all does not
|
|
38
|
+
* hold the proxy closed. A host that hits it has said something useful — that
|
|
39
|
+
* its first piece takes longer than this — and the plan is told the bound rather
|
|
40
|
+
* than a zero.
|
|
41
|
+
*/
|
|
42
|
+
const GIVE_UP_AFTER_MS = 30_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Measure a start and a stop on this host.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} params
|
|
48
|
+
* @param {string} params.ffmpegBin
|
|
49
|
+
* @param {import("./Encoder.js").Encoder} params.encoder - The encoder this
|
|
50
|
+
* proxy has chosen, so the reading is of the thing that will actually run.
|
|
51
|
+
* @param {number} [params.segmentDurationSec]
|
|
52
|
+
* @param {{ info: Function, warn: Function }} [params.logger]
|
|
53
|
+
* @param {() => number} [params.now]
|
|
54
|
+
* @returns {Promise<{ firstByteWaitSec: number, killCostSec: number } | null>}
|
|
55
|
+
* Null where nothing could be measured, which is said rather than passed off
|
|
56
|
+
* as a zero.
|
|
57
|
+
*/
|
|
58
|
+
export async function measureStartAndStop({
|
|
59
|
+
ffmpegBin,
|
|
60
|
+
encoder,
|
|
61
|
+
segmentDurationSec = 4,
|
|
62
|
+
logger = null,
|
|
63
|
+
now = Date.now
|
|
64
|
+
}) {
|
|
65
|
+
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
66
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "tt-startstop-"));
|
|
67
|
+
try {
|
|
68
|
+
const args = [
|
|
69
|
+
"-hide_banner",
|
|
70
|
+
"-nostats",
|
|
71
|
+
"-loglevel",
|
|
72
|
+
"error",
|
|
73
|
+
// A generated picture: the reading is of this host's encoder and muxer,
|
|
74
|
+
// and a file would add its own reading and its own download.
|
|
75
|
+
"-f",
|
|
76
|
+
"lavfi",
|
|
77
|
+
"-i",
|
|
78
|
+
`testsrc2=size=640x360:rate=25`,
|
|
79
|
+
"-t",
|
|
80
|
+
String(segmentDurationSec * 4),
|
|
81
|
+
// The encoder this proxy has chosen, asked for its own arguments: the
|
|
82
|
+
// reading must be of the thing that will actually run, since what a start
|
|
83
|
+
// costs is mostly the encoder opening.
|
|
84
|
+
...(typeof encoder?.buildVideoArgs === "function"
|
|
85
|
+
? encoder.buildVideoArgs({ targetWidth: 640, targetHeight: 360, segmentDurationSec, fps: 25 })
|
|
86
|
+
: ["-c:v", "libx264", "-preset", "ultrafast"]),
|
|
87
|
+
"-an",
|
|
88
|
+
"-f",
|
|
89
|
+
"segment",
|
|
90
|
+
"-segment_time",
|
|
91
|
+
String(segmentDurationSec),
|
|
92
|
+
// The channel the encoder names its finished pieces on — the same one a
|
|
93
|
+
// session reads, so "the first piece exists" means here what it means
|
|
94
|
+
// there.
|
|
95
|
+
"-segment_list",
|
|
96
|
+
"pipe:3",
|
|
97
|
+
"-segment_list_flags",
|
|
98
|
+
"+live",
|
|
99
|
+
"-segment_format",
|
|
100
|
+
"mp4",
|
|
101
|
+
path.join(dir, "seg-%05d.mp4")
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const spawnedAt = now();
|
|
105
|
+
const child = spawn(ffmpegBin, args, {
|
|
106
|
+
stdio: ["ignore", "ignore", "pipe", "pipe"],
|
|
107
|
+
windowsHide: true
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const firstPiece = await new Promise((resolve) => {
|
|
111
|
+
const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
child.stdio?.[3]?.on("data", () => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
resolve(now() - spawnedAt);
|
|
116
|
+
});
|
|
117
|
+
child.on("error", () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve(null);
|
|
120
|
+
});
|
|
121
|
+
child.on("exit", () => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
resolve(null);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (firstPiece === null) {
|
|
128
|
+
try {
|
|
129
|
+
child.kill("SIGKILL");
|
|
130
|
+
} catch {
|
|
131
|
+
// Already gone.
|
|
132
|
+
}
|
|
133
|
+
log.warn("hwaccel: a start could not be measured; the plan is told so rather than told zero");
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const killedAt = now();
|
|
138
|
+
const died = await new Promise((resolve) => {
|
|
139
|
+
const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
child.on("exit", () => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
resolve(now() - killedAt);
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
child.kill("SIGTERM");
|
|
147
|
+
} catch {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
resolve(null);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const firstByteWaitSec = firstPiece / 1000;
|
|
154
|
+
const killCostSec = died === null ? 0 : died / 1000;
|
|
155
|
+
log.info(
|
|
156
|
+
`hwaccel: a start costs ${firstByteWaitSec.toFixed(2)}s to a first piece and ` +
|
|
157
|
+
`a stop ${killCostSec.toFixed(2)}s on this host — measured before any viewer, ` +
|
|
158
|
+
"because a plan told zero reads it as free and moves an encoder for nothing"
|
|
159
|
+
);
|
|
160
|
+
return { firstByteWaitSec, killCostSec };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
log.warn(
|
|
163
|
+
`hwaccel: a start and a stop could not be measured: ` +
|
|
164
|
+
`${error instanceof Error ? error.message : String(error)}`
|
|
165
|
+
);
|
|
166
|
+
return null;
|
|
167
|
+
} finally {
|
|
168
|
+
try {
|
|
169
|
+
rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
} catch {
|
|
171
|
+
// best effort
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -1399,6 +1399,7 @@ export class HlsSessionManager {
|
|
|
1399
1399
|
stateDir = "",
|
|
1400
1400
|
segmentStore = null,
|
|
1401
1401
|
getTorrentTotals,
|
|
1402
|
+
startStopCost = null,
|
|
1402
1403
|
spillDisk = null}) {
|
|
1403
1404
|
this.enabled = Boolean(enabled);
|
|
1404
1405
|
this.ffmpegBin = ffmpegBin;
|
|
@@ -1576,6 +1577,11 @@ export class HlsSessionManager {
|
|
|
1576
1577
|
segmentStore: this.segmentStore,
|
|
1577
1578
|
logger
|
|
1578
1579
|
});
|
|
1580
|
+
// What a start and a stop were measured to cost here, before any viewer
|
|
1581
|
+
// existed. Without it both read zero at a cold open, and zero is not
|
|
1582
|
+
// "unmeasured" — it is "free", which is what moved an encoder between two
|
|
1583
|
+
// adjacent numbers every half second in the field.
|
|
1584
|
+
this.encodeOrchestrator.noteStartupCosts(startStopCost);
|
|
1579
1585
|
// Where each file is cut, held once per file and grid rather than once per
|
|
1580
1586
|
// session. Two sessions of one film MUST agree about this to the
|
|
1581
1587
|
// millisecond — a segment made by either has to be appendable where the
|