@torrent-tv/proxy 2.12.2 → 2.14.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 +756 -724
- package/assets/calibration/NOTICE.md +30 -0
- package/assets/calibration/cal-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-720.mp4 +0 -0
- package/bin/cli.js +6 -1
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +13 -0
- package/routes/transcode/audio-file/get.js +45 -0
- package/server.js +30 -4
- package/services/ffmpeg-banner.js +42 -0
- package/services/hls-session-manager.js +940 -37
- package/services/hwaccel.js +478 -12
- package/services/playback-planner.js +50 -3
- package/test/decode-cost.test.js +347 -0
- package/test/quality-variants.test.js +166 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Calibration clips — attribution
|
|
2
|
+
|
|
3
|
+
The three clips in this folder are excerpts from **"Meridian"**, part of
|
|
4
|
+
[Netflix Open Content](https://opencontent.netflix.com/), licensed under the
|
|
5
|
+
**Creative Commons Attribution 4.0 International (CC BY 4.0)** licence:
|
|
6
|
+
<https://creativecommons.org/licenses/by/4.0/>.
|
|
7
|
+
|
|
8
|
+
They were cut from `Meridian/Meridian_UHD4k5994_HDR_P3PQ.mp4` and re-encoded to
|
|
9
|
+
H.264 High, 24 fps, 5.04 s (121 frames), no audio:
|
|
10
|
+
|
|
11
|
+
| file | resolution | bitrate |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `cal-1080-hi.mp4` | 1920×1080 | 11.38 Mbit/s |
|
|
14
|
+
| `cal-1080-lo.mp4` | 1920×1080 | 0.97 Mbit/s |
|
|
15
|
+
| `cal-720.mp4` | 1280×720 | 2.25 Mbit/s |
|
|
16
|
+
|
|
17
|
+
## Why these three, and why real footage
|
|
18
|
+
|
|
19
|
+
`services/hwaccel.js` decodes them at startup and solves the host's decode cost,
|
|
20
|
+
`a × Mpixel/s + b × Mbit/s + c`, from the three measurements. The first two
|
|
21
|
+
clips share a pixel count and differ 11.7× in bitrate; the third changes the
|
|
22
|
+
pixel count. Three points, three unknowns.
|
|
23
|
+
|
|
24
|
+
Real, grainy live action rather than a generated pattern: measured 2026-08-14 on
|
|
25
|
+
the addon host, this material decodes 11 % away from the film being served,
|
|
26
|
+
where a generated `testsrc2` clip is 158 % away.
|
|
27
|
+
|
|
28
|
+
Replacing a clip is allowed — the benchmark reads each clip's dimensions, frame
|
|
29
|
+
rate and bitrate from ffmpeg's own output rather than from this table — but the
|
|
30
|
+
three must keep spanning the two axes, or the fit has nothing to separate.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/bin/cli.js
CHANGED
|
@@ -76,6 +76,10 @@ program
|
|
|
76
76
|
.option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
|
|
77
77
|
.option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
|
|
78
78
|
.option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
|
|
79
|
+
.option(
|
|
80
|
+
"--state-dir <path>",
|
|
81
|
+
"Where to keep what this host has measured about itself (default: beside the installed proxy)"
|
|
82
|
+
)
|
|
79
83
|
.option(
|
|
80
84
|
"--segment-format <format>",
|
|
81
85
|
`HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
|
|
@@ -273,7 +277,8 @@ try {
|
|
|
273
277
|
ffmpegBin,
|
|
274
278
|
maxDiskBytes,
|
|
275
279
|
memoryBytes,
|
|
276
|
-
segmentFormat: options.segmentFormat
|
|
280
|
+
segmentFormat: options.segmentFormat,
|
|
281
|
+
stateDir: options.stateDir
|
|
277
282
|
});
|
|
278
283
|
app = started.app;
|
|
279
284
|
actualPort = started.port;
|
package/package.json
CHANGED
|
@@ -73,6 +73,11 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
73
73
|
// (capped to source), with the realtime budget's auto-downscale + runtime
|
|
74
74
|
// downswitch disabled for the session.
|
|
75
75
|
const manualQuality = payload.manualQuality === true;
|
|
76
|
+
// Whether this browser will take its audio from a separate rendition group in
|
|
77
|
+
// the master playlist rather than muxed into the picture. It has to say so:
|
|
78
|
+
// publishing renditions AND muxing the same audio would play it twice, while
|
|
79
|
+
// a browser that does not know about them would get no sound at all.
|
|
80
|
+
const audioRenditions = payload.audioRenditions === true;
|
|
76
81
|
const startPositionSeconds = Number(payload.startPositionSeconds);
|
|
77
82
|
const audioTrackIndex = Number(payload.audioTrackIndex);
|
|
78
83
|
// Which container to produce. The browser knows what its media stack will
|
|
@@ -96,6 +101,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
96
101
|
targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
|
|
97
102
|
targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
|
|
98
103
|
manualQuality,
|
|
104
|
+
audioRenditions,
|
|
99
105
|
startPositionSeconds:
|
|
100
106
|
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
101
107
|
? startPositionSeconds
|
|
@@ -129,6 +135,13 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
129
135
|
variantHeight: hlsSessionManager.variantHeightOf(session)
|
|
130
136
|
}
|
|
131
137
|
: {}),
|
|
138
|
+
// The heights this host will actually serve this file at, largest first
|
|
139
|
+
// — the ladder minus every rung it cannot produce faster than it is
|
|
140
|
+
// watched. The browser needs it whether or not a master exists: without
|
|
141
|
+
// it, it fell back to a ladder of its own invention and offered rungs the
|
|
142
|
+
// proxy had just refused, and picking one re-opened the session at a
|
|
143
|
+
// height measured at a third of realtime.
|
|
144
|
+
offeredHeights: hlsSessionManager.offeredHeights(session),
|
|
132
145
|
// What this session's output will carry, stated rather than left to be
|
|
133
146
|
// discovered. The browser checks what it actually got against this: a
|
|
134
147
|
// track that never arrives is otherwise noticed only by its absence,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file GET /transcode/:sessionId/a/:trackIndex/:fileName — one file of an audio
|
|
3
|
+
* rendition.
|
|
4
|
+
*
|
|
5
|
+
* A rendition is one audio track of the file, encoded once and shared by every
|
|
6
|
+
* quality rung, published in the master playlist as `#EXT-X-MEDIA`. It is an
|
|
7
|
+
* ordinary session underneath, living one directory level below the base
|
|
8
|
+
* session so every relative name inside its playlist resolves to it unchanged —
|
|
9
|
+
* the same arrangement quality variants use.
|
|
10
|
+
*
|
|
11
|
+
* Unlike a variant, a rendition is fetched ALONGSIDE the picture rather than
|
|
12
|
+
* instead of it: both encoders run, one for the rung and one for the audio.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { serveSessionFile } from "../session-file/get.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import("fastify").FastifyRequest} req
|
|
19
|
+
* @param {import("fastify").FastifyReply} reply
|
|
20
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
export async function handleTranscodeAudioFileGet(req, reply, { hlsSessionManager }) {
|
|
24
|
+
const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
25
|
+
const trackIndex = Number(req.params.trackIndex);
|
|
26
|
+
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
27
|
+
|
|
28
|
+
const resolved = await hlsSessionManager.resolveAudioRenditionFile(baseSessionId, trackIndex, fileName);
|
|
29
|
+
if (resolved.error) {
|
|
30
|
+
// Retryable, like every other not-ready answer on this path: a 500 for a
|
|
31
|
+
// rendition playlist would end the stream over something the next attempt
|
|
32
|
+
// may well get past.
|
|
33
|
+
reply.header("Retry-After", "1");
|
|
34
|
+
return reply.code(503).send({ error: `Could not prepare the audio rendition: ${resolved.error}` });
|
|
35
|
+
}
|
|
36
|
+
if (!resolved.sessionId) {
|
|
37
|
+
return reply.code(404).send({ error: "No such audio rendition for this transcode session." });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return serveSessionFile(req, reply, {
|
|
41
|
+
hlsSessionManager,
|
|
42
|
+
sessionId: resolved.sessionId,
|
|
43
|
+
fileName
|
|
44
|
+
});
|
|
45
|
+
}
|
package/server.js
CHANGED
|
@@ -30,12 +30,13 @@ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessio
|
|
|
30
30
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
31
31
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
32
32
|
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
33
|
+
import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
|
|
33
34
|
import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
|
|
34
35
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
35
36
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
36
37
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
37
38
|
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
38
|
-
import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
|
|
39
|
+
import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, detectTonemapSupport } from "./services/hwaccel.js";
|
|
39
40
|
import { logger } from "./utils/logger.js";
|
|
40
41
|
|
|
41
42
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -68,6 +69,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
68
69
|
* @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
|
|
69
70
|
* @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
|
|
70
71
|
* @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
|
|
72
|
+
* @property {string} [stateDir] - Where to keep what this host has measured about itself.
|
|
71
73
|
*/
|
|
72
74
|
|
|
73
75
|
/**
|
|
@@ -76,7 +78,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
76
78
|
* @param {ProxyServerOptions} options
|
|
77
79
|
* @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
|
|
78
80
|
*/
|
|
79
|
-
export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat }) {
|
|
81
|
+
export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir }) {
|
|
80
82
|
const app = Fastify({
|
|
81
83
|
// No practical body-size limit — the proxy server is localhost-only and
|
|
82
84
|
// receives torrent source payloads that may be arbitrarily large.
|
|
@@ -124,6 +126,18 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
124
126
|
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
125
127
|
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
126
128
|
: null;
|
|
129
|
+
// A re-encode pays for decoding as well, and the preset benchmark measures
|
|
130
|
+
// only the encoder — which is how a rung this host runs at 0.39x came to be
|
|
131
|
+
// offered as if it cleared realtime 2.5× over (measured 2026-08-14). Solve
|
|
132
|
+
// the decode cost from the bundled calibration clips once at startup; every
|
|
133
|
+
// source is then priced from figures the probe already has.
|
|
134
|
+
// Only the software path can read it: the budget and the ladder both bail
|
|
135
|
+
// on a missing preset benchmark, and that is only produced for libx264. A
|
|
136
|
+
// host with a hardware encoder would pay three decodes at every start for a
|
|
137
|
+
// figure nothing would ever ask for.
|
|
138
|
+
const decodeCostModel = videoEncoder?.kind === "software"
|
|
139
|
+
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
140
|
+
: null;
|
|
127
141
|
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
128
142
|
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
129
143
|
// sources on the software path when available.
|
|
@@ -137,8 +151,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
137
151
|
localPort: selectedPort,
|
|
138
152
|
videoEncoder,
|
|
139
153
|
softwarePresetBenchmark,
|
|
154
|
+
decodeCostModel,
|
|
140
155
|
tonemapSupported,
|
|
141
156
|
segmentFormatId: segmentFormat,
|
|
157
|
+
stateDir,
|
|
142
158
|
// Live download stats accessor for the realtime budget: lets it tell a
|
|
143
159
|
// CPU-bound transcode from a download-starved input before downscaling.
|
|
144
160
|
getSourceStats: async (sourceKey, fileIndex) => {
|
|
@@ -158,7 +174,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
158
174
|
// Reuse the media info the planner already probed for this file (same
|
|
159
175
|
// ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
|
|
160
176
|
// only at session-create time, after playbackPlanner is initialised.
|
|
161
|
-
getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
|
|
177
|
+
getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
|
|
178
|
+
// The file's audio tracks, for the master playlist's rendition group. Already
|
|
179
|
+
// probed for the browser's audio menu; read from there rather than probed again.
|
|
180
|
+
getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params)
|
|
162
181
|
});
|
|
163
182
|
const playbackPlanner = createPlaybackPlanner({
|
|
164
183
|
ffmpegBin,
|
|
@@ -168,7 +187,11 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
168
187
|
torrentPool,
|
|
169
188
|
warmKeyframeIndex: (params) => hlsSessionManager.warmKeyframeIndex(params),
|
|
170
189
|
expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs(),
|
|
171
|
-
expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs()
|
|
190
|
+
expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs(),
|
|
191
|
+
// The quality menu is on screen from the moment a file is opened, so the
|
|
192
|
+
// heights this host can actually serve have to be answerable before any
|
|
193
|
+
// encoder exists — from the probe and the startup benchmarks alone.
|
|
194
|
+
predictOfferedHeights: (mediaInfo) => hlsSessionManager.predictOfferedHeights(mediaInfo)
|
|
172
195
|
});
|
|
173
196
|
|
|
174
197
|
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
@@ -226,6 +249,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
226
249
|
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
227
250
|
handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
|
|
228
251
|
);
|
|
252
|
+
app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
|
|
253
|
+
handleTranscodeAudioFileGet(req, reply, { hlsSessionManager })
|
|
254
|
+
);
|
|
229
255
|
app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
|
|
230
256
|
handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
|
|
231
257
|
);
|
|
@@ -54,6 +54,48 @@ export function parseFfmpegStartTimeSeconds(stderrText) {
|
|
|
54
54
|
return Number.isFinite(value) ? value : 0;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Parse the bitrate (kbit/s) that the decode cost is priced from: the VIDEO
|
|
59
|
+
* stream's own, falling back to the container's when the stream does not state
|
|
60
|
+
* one. Returns null when neither is present.
|
|
61
|
+
*
|
|
62
|
+
* The distinction is not cosmetic. The calibration clips carry video alone and
|
|
63
|
+
* are decoded with `-an`, so the fitted bitrate term describes VIDEO bits; the
|
|
64
|
+
* container figure adds every audio and subtitle track. A Russian release with
|
|
65
|
+
* two or three AC-3/DTS tracks carries 1-2 Mbit/s of audio, and on this host's
|
|
66
|
+
* own fit that inflates the predicted decode cost by 10-25 % — refusing rungs
|
|
67
|
+
* on the strength of audio the benchmark never decoded. And the term is not the
|
|
68
|
+
* weak one it was once described as: on the shipped clips an 11.7× bitrate
|
|
69
|
+
* change moved the cost 2.47×, and it accounts for about two thirds of the
|
|
70
|
+
* predicted cost of a high-bitrate 1080p source.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} stderrText
|
|
73
|
+
* @returns {number | null}
|
|
74
|
+
*/
|
|
75
|
+
export function parseFfmpegBitrateKbps(stderrText) {
|
|
76
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// `Stream #0:0 … Video: h264 … 11375 kb/s, 24 fps` — the stream's own rate,
|
|
80
|
+
// stated per stream and therefore free of the other tracks. Only the INPUT
|
|
81
|
+
// section is read: everything from "Stream mapping:" onwards describes what
|
|
82
|
+
// ffmpeg is about to produce, and that line carries a bitrate of its own.
|
|
83
|
+
const inputSection = stderrText.split(/^Stream mapping:/m)[0];
|
|
84
|
+
const perStream = inputSection.match(/Stream\s+#[^\n]*?Video:[^\n]*?,\s*(\d+)\s*kb\/s/i);
|
|
85
|
+
if (perStream) {
|
|
86
|
+
const streamValue = Number(perStream[1]);
|
|
87
|
+
if (Number.isFinite(streamValue) && streamValue > 0) {
|
|
88
|
+
return streamValue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const match = stderrText.match(/Duration:[^\n]*?bitrate:\s*(\d+)\s*kb\/s/i);
|
|
92
|
+
if (!match) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const value = Number(match[1]);
|
|
96
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
57
99
|
/**
|
|
58
100
|
* Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
|
|
59
101
|
* … WxH" line). Returns `{ width: null, height: null }` when absent.
|