@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
|
@@ -13,9 +13,9 @@ import { Readable } from "node:stream";
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
16
17
|
import { spawn } from "node:child_process";
|
|
17
18
|
import { createRequire } from "node:module";
|
|
18
|
-
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
21
|
|
|
@@ -25,10 +25,13 @@ import {
|
|
|
25
25
|
softwareDescriptor,
|
|
26
26
|
chooseSoftwareEncodeSettings,
|
|
27
27
|
pickSoftwarePreset,
|
|
28
|
+
canSustainOutput,
|
|
29
|
+
REALTIME_SPEED_MARGIN,
|
|
28
30
|
TRANSCODE_FPS,
|
|
29
31
|
chooseOutputFps
|
|
30
32
|
} from "./hwaccel.js";
|
|
31
33
|
import {
|
|
34
|
+
parseFfmpegBitrateKbps,
|
|
32
35
|
parseFfmpegDurationSeconds,
|
|
33
36
|
parseFfmpegStartTimeSeconds,
|
|
34
37
|
parseFfmpegVideoDimensions,
|
|
@@ -72,6 +75,47 @@ const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
|
|
|
72
75
|
// directory level, so every relative name inside a variant's own playlist — its
|
|
73
76
|
// segments and its init — resolves to that variant without any of them changing.
|
|
74
77
|
const VARIANT_PATH_PREFIX = "v";
|
|
78
|
+
// Where an audio rendition lives, and the name the variants refer to it by. One
|
|
79
|
+
// directory level under the base session, exactly as a quality variant is, so
|
|
80
|
+
// every relative name inside its playlist resolves to it unchanged.
|
|
81
|
+
const AUDIO_PATH_PREFIX = "a";
|
|
82
|
+
const AUDIO_GROUP_ID = "aud";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Quote a value for an HLS attribute list. Only the quote itself can end the
|
|
86
|
+
* attribute early, and a track title comes from the file, so it is not ours to
|
|
87
|
+
* trust.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} value
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function escapeAttribute(value) {
|
|
93
|
+
// The quote would end the attribute early; a line break would end the LINE,
|
|
94
|
+
// splitting one `#EXT-X-MEDIA` into two and corrupting the master. Both come
|
|
95
|
+
// from the file's own metadata, which is not ours to trust.
|
|
96
|
+
return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ISO 639-2 codes as ffmpeg reports them, against the RFC 5646 tags HLS asks
|
|
100
|
+
// for. Only the languages this serves in practice; anything else is passed
|
|
101
|
+
// through, which is what players other than iOS accept anyway.
|
|
102
|
+
const LANGUAGE_TAGS = new Map([
|
|
103
|
+
["rus", "ru"], ["eng", "en"], ["ukr", "uk"], ["deu", "de"], ["ger", "de"],
|
|
104
|
+
["fra", "fr"], ["fre", "fr"], ["spa", "es"], ["ita", "it"], ["jpn", "ja"],
|
|
105
|
+
["kor", "ko"], ["zho", "zh"], ["chi", "zh"], ["pol", "pl"], ["por", "pt"],
|
|
106
|
+
["tur", "tr"], ["ces", "cs"], ["cze", "cs"], ["nld", "nl"], ["dut", "nl"]
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The RFC 5646 tag for a language ffmpeg named, or the name unchanged.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} language
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
function languageTag(language) {
|
|
116
|
+
const code = String(language ?? "").toLowerCase();
|
|
117
|
+
return LANGUAGE_TAGS.get(code) ?? code;
|
|
118
|
+
}
|
|
75
119
|
// The resolutions a viewer may choose between. Only rungs at or below the
|
|
76
120
|
// source are offered: upscaling invents detail and costs the encoder more than
|
|
77
121
|
// the source itself.
|
|
@@ -112,6 +156,31 @@ export function variantHeightsFor(sourceHeight) {
|
|
|
112
156
|
return [Math.round(sourceHeight), ...rungs];
|
|
113
157
|
}
|
|
114
158
|
|
|
159
|
+
/**
|
|
160
|
+
* What a source costs to DECODE, in the two figures the startup fit prices:
|
|
161
|
+
* its pixel rate and its bitrate. Every re-encode of this file pays this,
|
|
162
|
+
* whatever height it is encoded to, because the whole source is decoded first.
|
|
163
|
+
*
|
|
164
|
+
* Returns null when the probe did not report enough — the budget then prices
|
|
165
|
+
* the encoder alone rather than inventing a figure.
|
|
166
|
+
*
|
|
167
|
+
* @param {{ width: number | null, height: number | null, fps: number | null, bitrateKbps: number | null }} mediaInfo
|
|
168
|
+
* @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number } | null}
|
|
169
|
+
*/
|
|
170
|
+
export function sourceDecodeCharacteristics(mediaInfo) {
|
|
171
|
+
const width = Number(mediaInfo?.width);
|
|
172
|
+
const height = Number(mediaInfo?.height);
|
|
173
|
+
const fps = Number(mediaInfo?.fps);
|
|
174
|
+
const kbps = Number(mediaInfo?.bitrateKbps);
|
|
175
|
+
if (!(width > 0) || !(height > 0) || !(fps > 0) || !(kbps > 0)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
megapixelsPerSecond: (width * height * fps) / 1e6,
|
|
180
|
+
megabitsPerSecond: kbps / 1000
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
115
184
|
/**
|
|
116
185
|
* A fresh tally of how well a container's keyframe index matches its file.
|
|
117
186
|
*
|
|
@@ -166,20 +235,51 @@ export function noteIndexDeviation(check, index, deviationSec) {
|
|
|
166
235
|
* @param {{ ladder: { width: number, height: number }[], rungIndex: number } | null} budget
|
|
167
236
|
* @param {number} outputFps
|
|
168
237
|
* @param {unknown} benchmark
|
|
238
|
+
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
|
|
169
239
|
* @returns {object | null}
|
|
170
240
|
*/
|
|
171
|
-
function startAtLadderTop(budget, outputFps, benchmark) {
|
|
172
|
-
const
|
|
241
|
+
function startAtLadderTop(budget, outputFps, benchmark, cost = {}) {
|
|
242
|
+
const ladder = budget?.ladder;
|
|
243
|
+
const top = ladder?.[0];
|
|
173
244
|
if (!top) {
|
|
174
245
|
return null;
|
|
175
246
|
}
|
|
176
247
|
const fps = Number.isInteger(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
|
|
248
|
+
// The top rung the viewer asked for, unless this host cannot hold it. A
|
|
249
|
+
// request naming a height arrives from a browser that was told which heights
|
|
250
|
+
// are on offer — but an older page, a stale tab or a repeated URL can still
|
|
251
|
+
// name one that was refused, and starting there means the encode never
|
|
252
|
+
// catches up. The runtime downshift would eventually step down; starting
|
|
253
|
+
// where the host can hold it means the viewer does not watch that happen.
|
|
254
|
+
// When NOTHING on the ladder can be held, start at its foot — the smallest
|
|
255
|
+
// picture this host has, which is the automatic path's answer to the same
|
|
256
|
+
// question and the best effort available. Starting at the top instead would
|
|
257
|
+
// hand the weakest hosts, the ones this exists for, the heaviest rung.
|
|
258
|
+
let startIndex = ladder.length - 1;
|
|
259
|
+
for (let index = 0; index < ladder.length; index += 1) {
|
|
260
|
+
const { sustainable } = canSustainOutput({
|
|
261
|
+
benchmark,
|
|
262
|
+
decodeModel: cost.decodeModel ?? null,
|
|
263
|
+
source: cost.source ?? null,
|
|
264
|
+
outputPixelsPerSec: ladder[index].width * ladder[index].height * fps,
|
|
265
|
+
observedDecodeCostSec: cost.observedDecodeCostSec ?? null
|
|
266
|
+
});
|
|
267
|
+
if (sustainable) {
|
|
268
|
+
startIndex = index;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const start = ladder[startIndex];
|
|
177
273
|
return {
|
|
178
274
|
...budget,
|
|
179
|
-
width:
|
|
180
|
-
height:
|
|
181
|
-
|
|
182
|
-
|
|
275
|
+
width: start.width,
|
|
276
|
+
height: start.height,
|
|
277
|
+
// Priced the same way the offer was. Without the cost the preset came from
|
|
278
|
+
// the encoder alone — so a rung offered on the combined figure was then
|
|
279
|
+
// encoded with a preset chosen as if decoding were free, which is how the
|
|
280
|
+
// check and the encode came to disagree on every rung a viewer picks.
|
|
281
|
+
preset: pickSoftwarePreset(benchmark, start.width * start.height * fps, cost),
|
|
282
|
+
rungIndex: startIndex
|
|
183
283
|
};
|
|
184
284
|
}
|
|
185
285
|
|
|
@@ -363,6 +463,18 @@ const BUDGET_SUSTAINED_MS = 15_000;
|
|
|
363
463
|
const BUDGET_ACTION_COOLDOWN_MS = 30_000;
|
|
364
464
|
// Never step down more than this many rungs below the startup choice.
|
|
365
465
|
const BUDGET_MAX_DOWNSHIFTS = 3;
|
|
466
|
+
// How long an encode run must have been going before a reading of its speed is
|
|
467
|
+
// taken as evidence about decoding. `speed=` is cumulative over the run, so a
|
|
468
|
+
// restart after a seek, a resume after a suspension and the wait for the first
|
|
469
|
+
// pieces all sit in the denominator of an early reading.
|
|
470
|
+
const DECODE_LEARNING_SETTLE_MS = 20_000;
|
|
471
|
+
// How many readings the median is taken over. Long enough to outvote a single
|
|
472
|
+
// disturbed moment, short enough to follow a host whose load has changed.
|
|
473
|
+
const DECODE_LEARNING_READINGS = 7;
|
|
474
|
+
// A new median has to differ by this much to be adopted. Below it the answer is
|
|
475
|
+
// the same one, and re-publishing it would make every session recompute its
|
|
476
|
+
// offer on the path that serves every playlist, init and segment.
|
|
477
|
+
const DECODE_LEARNING_CHANGE = 0.05;
|
|
366
478
|
// The input counts as "keeping up" when the torrent downloads at least this
|
|
367
479
|
// multiple of the source's average byte rate. Below it (and not yet fully
|
|
368
480
|
// downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
|
|
@@ -621,6 +733,7 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
|
621
733
|
const dims = parseFfmpegVideoDimensions(stderr);
|
|
622
734
|
resolve({
|
|
623
735
|
durationSeconds: parseFfmpegDurationSeconds(stderr),
|
|
736
|
+
bitrateKbps: parseFfmpegBitrateKbps(stderr),
|
|
624
737
|
width: dims.width,
|
|
625
738
|
height: dims.height,
|
|
626
739
|
fps: parseFfmpegVideoFps(stderr),
|
|
@@ -1051,6 +1164,21 @@ export class HlsSessionManager {
|
|
|
1051
1164
|
*/
|
|
1052
1165
|
#sessionCreateLatencies = [];
|
|
1053
1166
|
|
|
1167
|
+
/**
|
|
1168
|
+
* What decoding costs for a source this proxy has actually run, keyed by
|
|
1169
|
+
* `sourceKey:fileIndex` — seconds of work per second of video, with a version
|
|
1170
|
+
* that rises whenever a faster reading replaces the one held.
|
|
1171
|
+
*
|
|
1172
|
+
* The startup clips are H.264 and a source that has to be re-encoded usually
|
|
1173
|
+
* is not, so their model is a first approximation. This is the file itself,
|
|
1174
|
+
* measured by the encoder that is running on it, and it replaces the model
|
|
1175
|
+
* for that file as soon as it exists. Held for the life of the process: it
|
|
1176
|
+
* describes a source, and the same source is commonly opened again.
|
|
1177
|
+
*
|
|
1178
|
+
* @type {Map<string, { costSec: number, version: number }>}
|
|
1179
|
+
*/
|
|
1180
|
+
#observedDecodeCost = new Map();
|
|
1181
|
+
|
|
1054
1182
|
/**
|
|
1055
1183
|
* @param {HlsSessionManagerOptions} options
|
|
1056
1184
|
*/
|
|
@@ -1064,13 +1192,20 @@ export class HlsSessionManager {
|
|
|
1064
1192
|
startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
|
|
1065
1193
|
videoEncoder = null,
|
|
1066
1194
|
softwarePresetBenchmark = null,
|
|
1195
|
+
decodeCostModel = null,
|
|
1067
1196
|
getSourceStats = null,
|
|
1068
1197
|
tonemapSupported = false,
|
|
1069
1198
|
getCachedMediaInfo = null,
|
|
1070
|
-
|
|
1199
|
+
getCachedAudioTracks = null,
|
|
1200
|
+
segmentFormatId = undefined,
|
|
1201
|
+
stateDir = ""
|
|
1071
1202
|
}) {
|
|
1072
1203
|
this.enabled = Boolean(enabled);
|
|
1073
1204
|
this.ffmpegBin = ffmpegBin;
|
|
1205
|
+
// Where measurements about this host are kept between runs. Empty means
|
|
1206
|
+
// beside the installed proxy; a deployment with somewhere persistent to
|
|
1207
|
+
// write names it (--state-dir).
|
|
1208
|
+
this.stateDir = typeof stateDir === "string" ? stateDir : "";
|
|
1074
1209
|
// Output container (fMP4/CMAF or MPEG-TS). Everything container-specific —
|
|
1075
1210
|
// muxer args, file naming, playlist header, per-segment correction — lives
|
|
1076
1211
|
// in this module; nothing here branches on the format.
|
|
@@ -1078,6 +1213,9 @@ export class HlsSessionManager {
|
|
|
1078
1213
|
// Optional accessor for media info the playback planner already probed for
|
|
1079
1214
|
// (sourceKey, fileIndex), so session create can skip its own ffmpeg scan.
|
|
1080
1215
|
this.getCachedMediaInfo = typeof getCachedMediaInfo === "function" ? getCachedMediaInfo : null;
|
|
1216
|
+
// The file's audio tracks, for the master playlist's rendition group. Same
|
|
1217
|
+
// inventory the browser's audio menu is built from.
|
|
1218
|
+
this.getCachedAudioTracks = typeof getCachedAudioTracks === "function" ? getCachedAudioTracks : null;
|
|
1081
1219
|
// Optional async accessor for a source's live download stats, used by the
|
|
1082
1220
|
// realtime budget to tell a CPU limit from a download-starved input:
|
|
1083
1221
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
@@ -1090,6 +1228,13 @@ export class HlsSessionManager {
|
|
|
1090
1228
|
// used to pick the best preset per stream. Null when unavailable (hardware
|
|
1091
1229
|
// encoder, or benchmark skipped/failed).
|
|
1092
1230
|
this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
|
|
1231
|
+
// Host decode cost solved at startup from the calibration clips:
|
|
1232
|
+
// `a × Mpixel/s + b × Mbit/s + c` seconds of decoding per second of video.
|
|
1233
|
+
// A re-encode pays for this as well as for the encoder, and leaving it out
|
|
1234
|
+
// is what made the budget offer rungs this host ran at a third of realtime.
|
|
1235
|
+
// Null when the clips are missing or the fit was rejected — the budget then
|
|
1236
|
+
// prices the encoder alone, as it did before.
|
|
1237
|
+
this.decodeCostModel = decodeCostModel ?? null;
|
|
1093
1238
|
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
1094
1239
|
// Gates the tonemap chain for HDR sources on the software path.
|
|
1095
1240
|
this.tonemapSupported = Boolean(tonemapSupported);
|
|
@@ -1154,6 +1299,20 @@ export class HlsSessionManager {
|
|
|
1154
1299
|
startPositionSeconds = 0,
|
|
1155
1300
|
audioTrackIndex = 0,
|
|
1156
1301
|
manualQuality = false,
|
|
1302
|
+
// The caller takes its audio from a rendition group, so the picture is
|
|
1303
|
+
// encoded without it and each audio track is encoded once for the file
|
|
1304
|
+
// instead of once per rung. Off unless asked for: a browser that does not
|
|
1305
|
+
// know about renditions must still get its audio in the stream.
|
|
1306
|
+
audioRenditions = false,
|
|
1307
|
+
// This session IS one of those renditions: one audio track, no picture, cut
|
|
1308
|
+
// on the same grid as the video it accompanies.
|
|
1309
|
+
audioOnly = false,
|
|
1310
|
+
// The arrangement decided by the session this one belongs to — a variant or
|
|
1311
|
+
// a rendition of it. Every session of one master must agree about where the
|
|
1312
|
+
// audio is, and only the base is in a position to decide: a variant asked on
|
|
1313
|
+
// its own would answer about the rungs IT would be offered at, which is a
|
|
1314
|
+
// different list. Null means "decide it here", which is what a base does.
|
|
1315
|
+
inheritedAudioSeparate = null,
|
|
1157
1316
|
segmentFormatId = "",
|
|
1158
1317
|
// The cut grid of the session this one is a quality variant of: its
|
|
1159
1318
|
// keyframe times and which container they were read from. Present only for
|
|
@@ -1208,6 +1367,11 @@ export class HlsSessionManager {
|
|
|
1208
1367
|
String(normalizedTargetWidth),
|
|
1209
1368
|
String(normalizedTargetHeight),
|
|
1210
1369
|
forceManualQuality ? "q-manual" : "q-auto",
|
|
1370
|
+
// What the output CARRIES. A picture without its audio, a single audio
|
|
1371
|
+
// track without a picture, and the two muxed together are three different
|
|
1372
|
+
// encodes of the same file, and nothing about them is interchangeable —
|
|
1373
|
+
// so they cannot share a session, a directory or an encoder.
|
|
1374
|
+
audioOnly === true ? "audio-only" : (audioRenditions === true ? "video-only" : "muxed"),
|
|
1211
1375
|
String(normalizedStartPosition),
|
|
1212
1376
|
// Two viewers asking for different containers cannot share one ffmpeg.
|
|
1213
1377
|
segmentFormat.id,
|
|
@@ -1346,7 +1510,7 @@ export class HlsSessionManager {
|
|
|
1346
1510
|
if (inheritedGrid) {
|
|
1347
1511
|
keyframeTimes = inheritedGrid.keyframeTimes;
|
|
1348
1512
|
containerFormat = inheritedGrid.containerFormat ?? "";
|
|
1349
|
-
} else if (hasDuration && !transcodeVideo) {
|
|
1513
|
+
} else if (hasDuration && !transcodeVideo && !audioOnly) {
|
|
1350
1514
|
// Video-COPY path: keyframeTimes are REQUIRED to build correct segment
|
|
1351
1515
|
// boundaries (the playlist itself), so this MUST block session creation —
|
|
1352
1516
|
// an incorrect playlist is worse than a slower start. Short timeout: mp4
|
|
@@ -1415,10 +1579,17 @@ export class HlsSessionManager {
|
|
|
1415
1579
|
// it produces every frame and may put keyframes where it likes — unless it
|
|
1416
1580
|
// is a variant of a keyframe-cut session, in which case it must land on the
|
|
1417
1581
|
// same times to be interchangeable with it.
|
|
1582
|
+
// An audio rendition carries no picture, so it has no keyframes of its own
|
|
1583
|
+
// to be cut at: it takes the grid of the video it accompanies, whatever that
|
|
1584
|
+
// is. Handed one, it uses it; handed none, the base is on the even grid and
|
|
1585
|
+
// so is this. Falling into the COPY branch instead — which is what
|
|
1586
|
+
// `transcodeVideo: false` means everywhere else — would put the audio of a
|
|
1587
|
+
// re-encoded stream on the source's keyframe times while the player was
|
|
1588
|
+
// told the even grid, and the two drift further apart with every segment.
|
|
1418
1589
|
const useKeyframeGrid = hasDuration &&
|
|
1419
1590
|
Array.isArray(keyframeTimes) &&
|
|
1420
1591
|
keyframeTimes.length > 0 &&
|
|
1421
|
-
(!transcodeVideo || inheritedGrid != null);
|
|
1592
|
+
(audioOnly ? inheritedGrid != null : (!transcodeVideo || inheritedGrid != null));
|
|
1422
1593
|
// A rung takes the grid it was handed, rather than working one out again
|
|
1423
1594
|
// from the same index. The two are not the same table: the one it is handed
|
|
1424
1595
|
// has been CORRECTED wherever a produced segment showed the index to be
|
|
@@ -1450,13 +1621,17 @@ export class HlsSessionManager {
|
|
|
1450
1621
|
// resolution, so encode exactly that box (capped to source by the scale
|
|
1451
1622
|
// filter) with the default preset, and the runtime downswitch is skipped
|
|
1452
1623
|
// for the session (budgetLadder stays null).
|
|
1624
|
+
// What decoding this source costs, which every re-encode pays on top of
|
|
1625
|
+
// the encoder. Read from the probe; null when it did not say enough.
|
|
1626
|
+
const sourceDecode = sourceDecodeCharacteristics(mediaInfo);
|
|
1453
1627
|
const chosenBudget = this.#chooseEncodeBudget({
|
|
1454
1628
|
transcodeVideo,
|
|
1455
1629
|
targetWidth: normalizedTargetWidth,
|
|
1456
1630
|
targetHeight: normalizedTargetHeight,
|
|
1457
1631
|
sourceWidth,
|
|
1458
1632
|
sourceHeight,
|
|
1459
|
-
outputFps
|
|
1633
|
+
outputFps,
|
|
1634
|
+
source: sourceDecode
|
|
1460
1635
|
});
|
|
1461
1636
|
// A forced resolution starts at exactly that size — the viewer asked for it
|
|
1462
1637
|
// — but KEEPS the ladder beneath it. Discarding the ladder is what left a
|
|
@@ -1466,7 +1641,13 @@ export class HlsSessionManager {
|
|
|
1466
1641
|
// picture that plays beats a correct label that freezes. The rung's NAME is
|
|
1467
1642
|
// settled separately and does not move with a downshift, so the player goes
|
|
1468
1643
|
// on addressing it by the height it chose.
|
|
1469
|
-
const encodeBudget = forceManualQuality
|
|
1644
|
+
const encodeBudget = forceManualQuality
|
|
1645
|
+
? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark, {
|
|
1646
|
+
decodeModel: this.decodeCostModel,
|
|
1647
|
+
source: sourceDecode,
|
|
1648
|
+
observedDecodeCostSec: this.#observedDecodeCost.get(`${sourceKey}:${fileIndex}`)?.costSec ?? null
|
|
1649
|
+
})
|
|
1650
|
+
: chosenBudget;
|
|
1470
1651
|
const softwarePreset = encodeBudget?.preset ?? null;
|
|
1471
1652
|
// Effective encode box: the budget's downscaled resolution when applied,
|
|
1472
1653
|
// otherwise the client target (0 = keep source, handled by buildVideoArgs).
|
|
@@ -1504,6 +1685,12 @@ export class HlsSessionManager {
|
|
|
1504
1685
|
transcodeVideo,
|
|
1505
1686
|
transcodeAudio,
|
|
1506
1687
|
audioTrackIndex: normalizedAudioTrack,
|
|
1688
|
+
// What this session's output carries. `audioOnly` is a rendition — one
|
|
1689
|
+
// audio track, no picture; `videoOnly` is a stream whose audio the viewer
|
|
1690
|
+
// takes from such a rendition. Neither is set on the ordinary muxed
|
|
1691
|
+
// session, which is what every browser gets until it says otherwise.
|
|
1692
|
+
audioOnly: audioOnly === true,
|
|
1693
|
+
audioRenditions: audioRenditions === true,
|
|
1507
1694
|
outputFps,
|
|
1508
1695
|
// Client-requested target box (the orientation-independent ceiling). Kept
|
|
1509
1696
|
// for the session key and reference; the actual encode uses encodeWidth/
|
|
@@ -1514,6 +1701,15 @@ export class HlsSessionManager {
|
|
|
1514
1701
|
// software hosts, else the client target). 0 = keep source.
|
|
1515
1702
|
encodeWidth,
|
|
1516
1703
|
encodeHeight,
|
|
1704
|
+
// The NAME of this rung, fixed at the height that was asked for. It is
|
|
1705
|
+
// deliberately not the height being encoded: a viewer who picked 480p on
|
|
1706
|
+
// a host that then starts them at 360p, or steps down to it later, goes
|
|
1707
|
+
// on addressing the rung as 480p — and a request under the old name must
|
|
1708
|
+
// not build a second session at a height this host has just refused.
|
|
1709
|
+
// Derived from `encodeHeight` when nothing was named, as before.
|
|
1710
|
+
variantHeight: forceManualQuality && normalizedTargetHeight > 0
|
|
1711
|
+
? normalizedTargetHeight
|
|
1712
|
+
: undefined,
|
|
1517
1713
|
// Whether to insert the HDR→SDR tone-map chain (software path only).
|
|
1518
1714
|
applyTonemap,
|
|
1519
1715
|
// Realtime-budget runtime state (software encoder only). The ladder is the
|
|
@@ -1531,6 +1727,8 @@ export class HlsSessionManager {
|
|
|
1531
1727
|
linkSlowSince: 0,
|
|
1532
1728
|
sourceWidth,
|
|
1533
1729
|
sourceHeight,
|
|
1730
|
+
// Pixel rate and bitrate of the source, for pricing a re-encode of it.
|
|
1731
|
+
sourceDecode,
|
|
1534
1732
|
// Container start time (seconds); subtracted on the copy path so the
|
|
1535
1733
|
// output timeline is 0-based even when the source starts at e.g. 0.1 s.
|
|
1536
1734
|
sourceStartTime,
|
|
@@ -1629,6 +1827,23 @@ export class HlsSessionManager {
|
|
|
1629
1827
|
}
|
|
1630
1828
|
this.sessionsById.set(sessionId, session);
|
|
1631
1829
|
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
1830
|
+
// Settled ONCE, here, and never derived again. Whether the audio travels
|
|
1831
|
+
// separately decides the ffmpeg arguments, what the master says and whether
|
|
1832
|
+
// the rendition route answers at all, and those three must agree for the
|
|
1833
|
+
// whole life of the session — a session whose picture was encoded without
|
|
1834
|
+
// audio cannot start muxing it in at the next restart without either
|
|
1835
|
+
// playing it twice or refusing the append.
|
|
1836
|
+
//
|
|
1837
|
+
// It cannot be answered before this point (it asks what heights this
|
|
1838
|
+
// session will be offered at, which needs the record) and it must not be
|
|
1839
|
+
// asked after it, because the answer moves: the offered list is recomputed
|
|
1840
|
+
// as the host learns what this source costs, and crossing "two rungs" would
|
|
1841
|
+
// flip the arrangement under a stream that is playing.
|
|
1842
|
+
session.audioSeparate = inheritedAudioSeparate === null
|
|
1843
|
+
? audioRenditions === true &&
|
|
1844
|
+
this.#variantHeights(session).length >= 2 &&
|
|
1845
|
+
this.#audioRenditionsOf(session).length > 0
|
|
1846
|
+
: inheritedAudioSeparate === true;
|
|
1632
1847
|
|
|
1633
1848
|
logger.info(
|
|
1634
1849
|
// Proxy version on the session-start line: a field report always includes
|
|
@@ -1752,9 +1967,17 @@ export class HlsSessionManager {
|
|
|
1752
1967
|
sourceKey: session.sourceKey,
|
|
1753
1968
|
fileIndex: session.fileIndex
|
|
1754
1969
|
}) ?? null;
|
|
1970
|
+
// What the SOURCE has, narrowed to what this session's output carries. A
|
|
1971
|
+
// rendition maps only audio and a stream whose audio travels separately
|
|
1972
|
+
// maps only video, so answering from the source alone would tell the
|
|
1973
|
+
// browser about a track that is not in the stream, and would leave
|
|
1974
|
+
// `#initFromFirstSegment` waiting for a second track that no init will ever
|
|
1975
|
+
// declare — its warning about a short header would then fire on every one.
|
|
1976
|
+
const carriesVideo = session.audioOnly !== true;
|
|
1977
|
+
const carriesAudio = !this.#servesAudioSeparately(session);
|
|
1755
1978
|
return {
|
|
1756
|
-
video: Boolean(probed?.videoCodec),
|
|
1757
|
-
audio: Boolean(probed?.audioCodec)
|
|
1979
|
+
video: carriesVideo && Boolean(probed?.videoCodec),
|
|
1980
|
+
audio: carriesAudio && Boolean(probed?.audioCodec)
|
|
1758
1981
|
};
|
|
1759
1982
|
}
|
|
1760
1983
|
|
|
@@ -2011,10 +2234,10 @@ export class HlsSessionManager {
|
|
|
2011
2234
|
* (no video transcode, hardware encoder, or missing benchmark/source size) —
|
|
2012
2235
|
* the encode then keeps the ceiling resolution and the default preset.
|
|
2013
2236
|
*
|
|
2014
|
-
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number }} params
|
|
2237
|
+
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
|
|
2015
2238
|
* @returns {{ width: number, height: number, preset: string } | null}
|
|
2016
2239
|
*/
|
|
2017
|
-
#chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps }) {
|
|
2240
|
+
#chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps, source = null }) {
|
|
2018
2241
|
if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
|
|
2019
2242
|
return null;
|
|
2020
2243
|
}
|
|
@@ -2022,7 +2245,12 @@ export class HlsSessionManager {
|
|
|
2022
2245
|
if (!ceiling) {
|
|
2023
2246
|
return null;
|
|
2024
2247
|
}
|
|
2025
|
-
return chooseSoftwareEncodeSettings(
|
|
2248
|
+
return chooseSoftwareEncodeSettings(
|
|
2249
|
+
this.softwarePresetBenchmark,
|
|
2250
|
+
{ width: ceiling.w, height: ceiling.h },
|
|
2251
|
+
outputFps,
|
|
2252
|
+
{ decodeModel: this.decodeCostModel, source }
|
|
2253
|
+
);
|
|
2026
2254
|
}
|
|
2027
2255
|
|
|
2028
2256
|
/**
|
|
@@ -2402,8 +2630,34 @@ export class HlsSessionManager {
|
|
|
2402
2630
|
if (this.videoEncoder?.kind !== "software") {
|
|
2403
2631
|
return;
|
|
2404
2632
|
}
|
|
2633
|
+
// One tick at a time. It awaits torrent statistics per session now, so a
|
|
2634
|
+
// slow or stuck answer would otherwise let the next tick in behind it —
|
|
2635
|
+
// two passes over the same sessions, taking the same reading twice and
|
|
2636
|
+
// acting on the same speed twice.
|
|
2637
|
+
if (this.budgetTickRunning === true) {
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
this.budgetTickRunning = true;
|
|
2641
|
+
try {
|
|
2642
|
+
await this.#realtimeBudgetPass();
|
|
2643
|
+
} finally {
|
|
2644
|
+
this.budgetTickRunning = false;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
/** One pass over the sessions. See `#enforceRealtimeBudget`. */
|
|
2649
|
+
async #realtimeBudgetPass() {
|
|
2405
2650
|
const now = Date.now();
|
|
2406
2651
|
for (const session of this.sessionsById.values()) {
|
|
2652
|
+
// What this file costs to decode is learned from EVERY encoding session,
|
|
2653
|
+
// before any of the budget's own conditions are consulted. Those exist to
|
|
2654
|
+
// decide whether to step the quality down, and they exclude most of what
|
|
2655
|
+
// is worth measuring: a rung already at the foot of its ladder has
|
|
2656
|
+
// nowhere to step, and a 240p variant IS its whole ladder — which is
|
|
2657
|
+
// exactly the rung the field measured at 0.95x on 2026-08-15, learning
|
|
2658
|
+
// nothing from three minutes of it because the loop had already skipped
|
|
2659
|
+
// the session as un-actionable.
|
|
2660
|
+
await this.#learnFromEncoder(session);
|
|
2407
2661
|
if (
|
|
2408
2662
|
!session ||
|
|
2409
2663
|
session.state === "disposed" ||
|
|
@@ -2529,7 +2783,20 @@ export class HlsSessionManager {
|
|
|
2529
2783
|
session.budgetSlowSince = 0;
|
|
2530
2784
|
session.encodeWidth = rung.width;
|
|
2531
2785
|
session.encodeHeight = rung.height;
|
|
2532
|
-
|
|
2786
|
+
// Priced the same way the offer and the starting rung are. Choosing the
|
|
2787
|
+
// preset on the encoder alone treats decoding as free, which is how the
|
|
2788
|
+
// check and the encode came to disagree in the first place — and here it
|
|
2789
|
+
// matters most, because this runs on a host that has already failed to keep
|
|
2790
|
+
// up and is spending one of its few downshifts.
|
|
2791
|
+
session.softwarePreset = pickSoftwarePreset(
|
|
2792
|
+
this.softwarePresetBenchmark,
|
|
2793
|
+
rung.width * rung.height * fps,
|
|
2794
|
+
{
|
|
2795
|
+
decodeModel: this.decodeCostModel,
|
|
2796
|
+
source: session.sourceDecode ?? null,
|
|
2797
|
+
observedDecodeCostSec: this.#observedDecodeCostFor(session)
|
|
2798
|
+
}
|
|
2799
|
+
);
|
|
2533
2800
|
// Restart at the current live-edge segment so the lighter profile takes over
|
|
2534
2801
|
// from where the viewer is watching (hard-restart tier).
|
|
2535
2802
|
const head = session.encodeStartIndex;
|
|
@@ -2589,6 +2856,10 @@ export class HlsSessionManager {
|
|
|
2589
2856
|
// produce a file that is neither, which is the only reason a restart ever
|
|
2590
2857
|
// had to wait for its predecessor to die.
|
|
2591
2858
|
session.runSerial = (session.runSerial ?? 0) + 1;
|
|
2859
|
+
// When THIS run began. ffmpeg's `speed=` is cumulative over a run, so a
|
|
2860
|
+
// reading of it says something about the machine only once the run has left
|
|
2861
|
+
// its own start behind — see #learnDecodeCost.
|
|
2862
|
+
session.encodeRunStartedAt = Date.now();
|
|
2592
2863
|
session.runDirPath = path.join(session.dirPath, `run-${session.runSerial}`);
|
|
2593
2864
|
await mkdir(session.runDirPath, { recursive: true });
|
|
2594
2865
|
// The restart backs off a segment or two from what was asked for, so the
|
|
@@ -2761,7 +3032,17 @@ export class HlsSessionManager {
|
|
|
2761
3032
|
}
|
|
2762
3033
|
args.push("-i", session.inputUrl);
|
|
2763
3034
|
}
|
|
2764
|
-
|
|
3035
|
+
// Which timeline the output is labelled on. An audio rendition has no
|
|
3036
|
+
// picture of its own to follow, so it follows the grid it was given — the
|
|
3037
|
+
// same one the video it plays with is on. Deciding by `transcodeVideo`, as
|
|
3038
|
+
// everything else here does, would put the audio of a re-encoded stream on
|
|
3039
|
+
// the copy branch: `-copyts` and a shift by the container's start time,
|
|
3040
|
+
// against a picture labelled from zero. The two would be offset by
|
|
3041
|
+
// `sourceStartTime` for the whole file.
|
|
3042
|
+
const onKeyframeGrid = session.audioOnly === true
|
|
3043
|
+
? session.cutGrid === "keyframe"
|
|
3044
|
+
: !session.transcodeVideo;
|
|
3045
|
+
if (!onKeyframeGrid) {
|
|
2765
3046
|
// Branch A (re-encode): fixed GOP makes keyframes land exactly on the
|
|
2766
3047
|
// segment grid; relabel output onto the original timeline so segment N
|
|
2767
3048
|
// carries PTS = N × segmentDuration.
|
|
@@ -2781,15 +3062,29 @@ export class HlsSessionManager {
|
|
|
2781
3062
|
args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
|
|
2782
3063
|
}
|
|
2783
3064
|
}
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
//
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
...audioCodecArgs
|
|
2792
|
-
)
|
|
3065
|
+
if (session.audioOnly === true) {
|
|
3066
|
+
// An audio RENDITION: one track, no picture. Published as its own
|
|
3067
|
+
// `#EXT-X-MEDIA` and shared by every video variant, so the track is
|
|
3068
|
+
// encoded once for the file instead of once per rung, and changing it is
|
|
3069
|
+
// the player switching rendition rather than this proxy rebuilding the
|
|
3070
|
+
// session. Cut on the same grid as the video it accompanies, which is
|
|
3071
|
+
// what lets the two be played together.
|
|
3072
|
+
args.push("-vn", "-map", `0:a:${session.audioTrackIndex ?? 0}?`, ...audioCodecArgs);
|
|
3073
|
+
} else if (this.#servesAudioSeparately(session)) {
|
|
3074
|
+
// The other half of the same arrangement: the picture alone, because its
|
|
3075
|
+
// audio is published as a rendition and would otherwise play twice.
|
|
3076
|
+
args.push("-an", "-map", "0:v:0?", ...videoCodecArgs);
|
|
3077
|
+
} else {
|
|
3078
|
+
args.push(
|
|
3079
|
+
"-map",
|
|
3080
|
+
"0:v:0?",
|
|
3081
|
+
"-map",
|
|
3082
|
+
// Type-relative audio track chosen by the viewer (default 0).
|
|
3083
|
+
`0:a:${session.audioTrackIndex ?? 0}?`,
|
|
3084
|
+
...videoCodecArgs,
|
|
3085
|
+
...audioCodecArgs
|
|
3086
|
+
);
|
|
3087
|
+
}
|
|
2793
3088
|
|
|
2794
3089
|
// Where the cuts come from. On the copy path they are the source's own
|
|
2795
3090
|
// keyframes, and until now they were only ever GUESSED: ffmpeg got a target
|
|
@@ -3362,6 +3657,19 @@ export class HlsSessionManager {
|
|
|
3362
3657
|
// variants, so a seek it reports means the stream on screen.
|
|
3363
3658
|
named.viewerPositionSeconds = positionSeconds;
|
|
3364
3659
|
named.lastAccessedAt = Date.now();
|
|
3660
|
+
// The audio the viewer is listening to moves with them. It is a separate
|
|
3661
|
+
// encoder on a separate session that the browser cannot name, and nothing
|
|
3662
|
+
// else would ever reposition it: a request far AHEAD of its run is not
|
|
3663
|
+
// treated as a seek anywhere in this class, so after a forward jump the
|
|
3664
|
+
// audio would be held, refused, and left grinding forward from where it
|
|
3665
|
+
// was — the picture playing over silence for as long as the jump was.
|
|
3666
|
+
for (const renditionId of named.audioRenditionSessions?.values() ?? []) {
|
|
3667
|
+
const rendition = this.sessionsById.get(renditionId);
|
|
3668
|
+
if (rendition && rendition.state !== "disposed") {
|
|
3669
|
+
rendition.lastAccessedAt = Date.now();
|
|
3670
|
+
this.#seekSession(rendition, positionSeconds);
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3365
3673
|
return this.#seekSession(this.#activeVariant(named), positionSeconds);
|
|
3366
3674
|
}
|
|
3367
3675
|
|
|
@@ -3611,8 +3919,20 @@ export class HlsSessionManager {
|
|
|
3611
3919
|
* @returns {number | null} Milliseconds, or null without a benchmark.
|
|
3612
3920
|
*/
|
|
3613
3921
|
/**
|
|
3614
|
-
* Where this host's recorded timings live:
|
|
3615
|
-
* beside the proxy
|
|
3922
|
+
* Where this host's recorded timings live: `--state-dir` when the deployment
|
|
3923
|
+
* names one, otherwise beside the installed proxy, which is where they have
|
|
3924
|
+
* always been kept.
|
|
3925
|
+
*
|
|
3926
|
+
* The default is deliberately the old location and not the working directory:
|
|
3927
|
+
* measured on the addon, both are inside the container's writable layer and
|
|
3928
|
+
* both are discarded when an update rebuilds it, so moving there bought
|
|
3929
|
+
* nothing — while for an ordinary `npm i -g` install the working directory is
|
|
3930
|
+
* wherever the operator happened to launch from, which splits the history
|
|
3931
|
+
* between runs and drops a file into someone's project.
|
|
3932
|
+
*
|
|
3933
|
+
* A deployment that HAS a persistent directory says so: the addon passes
|
|
3934
|
+
* `/data`, the one path its supervisor keeps across updates. Naming it here
|
|
3935
|
+
* would put Home Assistant into proxy code, which this repo does not do.
|
|
3616
3936
|
*
|
|
3617
3937
|
* Kept so a proxy that has just restarted is not back to knowing nothing —
|
|
3618
3938
|
* the browser was shown an assumed rate for the whole of the first wait after
|
|
@@ -3624,7 +3944,10 @@ export class HlsSessionManager {
|
|
|
3624
3944
|
* @returns {string}
|
|
3625
3945
|
*/
|
|
3626
3946
|
#hostTimingsPath() {
|
|
3627
|
-
|
|
3947
|
+
const stateDir = typeof this.stateDir === "string" && this.stateDir.length > 0
|
|
3948
|
+
? this.stateDir
|
|
3949
|
+
: path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
3950
|
+
return path.join(stateDir, "host-timings.json");
|
|
3628
3951
|
}
|
|
3629
3952
|
|
|
3630
3953
|
/** Load them, if any were ever written. Never throws. */
|
|
@@ -3637,9 +3960,11 @@ export class HlsSessionManager {
|
|
|
3637
3960
|
if (Array.isArray(raw?.sessionCreate)) {
|
|
3638
3961
|
this.#sessionCreateLatencies = raw.sessionCreate.filter((value) => Number.isFinite(value) && value > 0);
|
|
3639
3962
|
}
|
|
3963
|
+
const asMs = (value) => (value === null ? "n/a" : `${value}ms`);
|
|
3640
3964
|
logger.info(
|
|
3641
|
-
`host timings loaded
|
|
3642
|
-
`
|
|
3965
|
+
`host timings loaded from ${this.#hostTimingsPath()}: ` +
|
|
3966
|
+
`first-segment ${asMs(this.expectedFirstSegmentMs())}, ` +
|
|
3967
|
+
`session-create ${asMs(this.expectedSessionCreateMs())}`
|
|
3643
3968
|
);
|
|
3644
3969
|
} catch {
|
|
3645
3970
|
// No file yet, or it is unreadable. The synthetic figure answers instead.
|
|
@@ -3880,6 +4205,32 @@ export class HlsSessionManager {
|
|
|
3880
4205
|
);
|
|
3881
4206
|
}
|
|
3882
4207
|
|
|
4208
|
+
/**
|
|
4209
|
+
* The session a family answers as: the base a rung belongs to, or the session
|
|
4210
|
+
* itself when it is not a rung.
|
|
4211
|
+
*
|
|
4212
|
+
* A rung knows only its own encode, so anything that is a property of the
|
|
4213
|
+
* FILE rather than of one encode of it — what the source is, whether its
|
|
4214
|
+
* video can be copied, which heights this host can serve it at — has to be
|
|
4215
|
+
* asked here. A live base is required: a rung whose base has been disposed
|
|
4216
|
+
* answers for itself rather than following a dead reference.
|
|
4217
|
+
*
|
|
4218
|
+
* @param {HlsSession} session
|
|
4219
|
+
* @returns {HlsSession}
|
|
4220
|
+
*/
|
|
4221
|
+
#baseOf(session) {
|
|
4222
|
+
if (!(session?.variantBases instanceof Set)) {
|
|
4223
|
+
return session;
|
|
4224
|
+
}
|
|
4225
|
+
for (const baseId of session.variantBases) {
|
|
4226
|
+
const base = this.sessionsById.get(baseId);
|
|
4227
|
+
if (base && base !== session && base.state !== "disposed") {
|
|
4228
|
+
return base;
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
return session;
|
|
4232
|
+
}
|
|
4233
|
+
|
|
3883
4234
|
/**
|
|
3884
4235
|
* Every session cut on one grid: a base and its quality rungs.
|
|
3885
4236
|
*
|
|
@@ -4000,12 +4351,348 @@ export class HlsSessionManager {
|
|
|
4000
4351
|
* @returns {number[]}
|
|
4001
4352
|
*/
|
|
4002
4353
|
#variantHeights(session) {
|
|
4003
|
-
|
|
4004
|
-
|
|
4354
|
+
// Always answered by the family's BASE, whichever member is asking. A rung
|
|
4355
|
+
// is a session of its own, and it knows only its own encode: asked while
|
|
4356
|
+
// the viewer watches 240p, the 240p session priced the 1080p rung as a
|
|
4357
|
+
// re-encode — because ITS video is re-encoded — and refused it on a host
|
|
4358
|
+
// that was serving that very height by COPY minutes earlier. Field
|
|
4359
|
+
// 2026-08-15: `proxy now offers 360p 240p` seconds after the switch, and
|
|
4360
|
+
// the viewer could not go back. Only the base knows what the family can do
|
|
4361
|
+
// with the source.
|
|
4362
|
+
// Answered ON the base, never recursively: the family is one level deep by
|
|
4363
|
+
// construction, and a `variantBases` cycle would otherwise blow the stack on
|
|
4364
|
+
// the path that serves every playlist, init and segment.
|
|
4365
|
+
const owner = this.#baseOf(session);
|
|
4366
|
+
// Settled once per session, and re-settled when this file's own decode cost
|
|
4367
|
+
// is measured or improves, or when the viewer moves to another rung — the
|
|
4368
|
+
// rung on screen is exempt from refusal, so it is an INPUT to this list and
|
|
4369
|
+
// belongs in what identifies a cached answer. Left out, the exemption
|
|
4370
|
+
// outlived the rung: a rung the host cannot hold went on being offered, and
|
|
4371
|
+
// went on passing every route guard, after the viewer had left it.
|
|
4372
|
+
// Everything else is fixed for the session's life.
|
|
4373
|
+
const observed = this.#observedDecodeCost.get(`${owner.sourceKey}:${owner.fileIndex}`) ?? null;
|
|
4374
|
+
const playing = this.variantHeightOf(this.#activeVariant(owner));
|
|
4375
|
+
const version = `${observed?.version ?? 0}:${playing}`;
|
|
4376
|
+
if (Array.isArray(owner.offeredHeightsCache) && owner.offeredHeightsVersion === version) {
|
|
4377
|
+
return owner.offeredHeightsCache;
|
|
4378
|
+
}
|
|
4379
|
+
const heights = new Set(variantHeightsFor(Number(owner.sourceHeight) || 0));
|
|
4380
|
+
const own = this.variantHeightOf(owner);
|
|
4005
4381
|
if (own > 0) {
|
|
4006
4382
|
heights.add(own);
|
|
4007
4383
|
}
|
|
4008
|
-
|
|
4384
|
+
const ordered = [...heights].sort((left, right) => right - left);
|
|
4385
|
+
// The rung ON SCREEN is never withdrawn while it is on screen. The list is
|
|
4386
|
+
// recomputed as the host learns what this source costs, and the reading
|
|
4387
|
+
// that teaches it comes from the rung the viewer has just switched to — so
|
|
4388
|
+
// the rung that taught the lesson would be the first to be dropped, and
|
|
4389
|
+
// every route guard reads this list: its next segment would 404 on a stream
|
|
4390
|
+
// that is playing, with its own encoder still running.
|
|
4391
|
+
const answer = this.#sustainableHeights({
|
|
4392
|
+
heights: ordered,
|
|
4393
|
+
ownHeight: own,
|
|
4394
|
+
playingHeight: playing,
|
|
4395
|
+
sourceWidth: Number(owner.sourceWidth) || 0,
|
|
4396
|
+
sourceHeight: Math.round(Number(owner.sourceHeight) || 0),
|
|
4397
|
+
fps: Number(owner.outputFps) || TRANSCODE_FPS,
|
|
4398
|
+
source: owner.sourceDecode ?? null,
|
|
4399
|
+
transcodeVideo: owner.transcodeVideo === true,
|
|
4400
|
+
observedDecodeCostSec: observed?.costSec ?? null
|
|
4401
|
+
});
|
|
4402
|
+
if (owner !== session) {
|
|
4403
|
+
// An orphan: its base is gone, so this is the family's last word and
|
|
4404
|
+
// there is nobody to keep it for. Answering is right — the viewer is
|
|
4405
|
+
// still watching it — but caching it on a session whose flags are its
|
|
4406
|
+
// own encode's is how the wrong answer became the family's in the first
|
|
4407
|
+
// place.
|
|
4408
|
+
return answer;
|
|
4409
|
+
}
|
|
4410
|
+
owner.offeredHeightsVersion = version;
|
|
4411
|
+
owner.offeredHeightsCache = answer;
|
|
4412
|
+
return answer;
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
/**
|
|
4416
|
+
* What decoding THIS source costs, learned from the encoder already running
|
|
4417
|
+
* on it — seconds of work per second of video, or null until it is known.
|
|
4418
|
+
*
|
|
4419
|
+
* @param {HlsSession} session
|
|
4420
|
+
* @returns {number | null}
|
|
4421
|
+
*/
|
|
4422
|
+
#observedDecodeCostFor(session) {
|
|
4423
|
+
const entry = this.#observedDecodeCost.get(`${session.sourceKey}:${session.fileIndex}`);
|
|
4424
|
+
return entry ? entry.costSec : null;
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
/**
|
|
4428
|
+
* Take one reading of a running encode and turn it into the decode cost of
|
|
4429
|
+
* this source.
|
|
4430
|
+
*
|
|
4431
|
+
* A re-encode pays for both halves — unpacking the source and packing the
|
|
4432
|
+
* result — and the running session measures the SUM. The encode half is
|
|
4433
|
+
* priced by the startup benchmark for the preset and pixel rate actually in
|
|
4434
|
+
* use, so subtracting it leaves the half that no startup benchmark can know:
|
|
4435
|
+
* this file's own codec, resolution and grain, on this machine, under
|
|
4436
|
+
* whatever else it is doing.
|
|
4437
|
+
*
|
|
4438
|
+
* The MEDIAN of the recent readings is used, over a bounded window. Keeping
|
|
4439
|
+
* the fastest instead makes the figure a ratchet: `speed=` is cumulative over
|
|
4440
|
+
* a run, its maximum falls in the burst where the encoder races to the
|
|
4441
|
+
* look-ahead cap with the pieces already on disk and nothing competing, and
|
|
4442
|
+
* one such moment would re-admit — permanently — the very rung the field
|
|
4443
|
+
* measured at 0.388-0.947x. The median moves in both directions and describes
|
|
4444
|
+
* the machine as it usually is, which is what a viewer will meet.
|
|
4445
|
+
*
|
|
4446
|
+
* A reading is only taken from a run that has been going long enough to have
|
|
4447
|
+
* left its own start behind: ffmpeg's `speed=` is cumulative, so a restart
|
|
4448
|
+
* after a seek, a resume after a suspension, and the wait for the first
|
|
4449
|
+
* pieces are all in the denominator of an early reading.
|
|
4450
|
+
*
|
|
4451
|
+
* @param {HlsSession} session
|
|
4452
|
+
* @param {number} speed - The `speed=` ffmpeg reports, as a multiple of realtime.
|
|
4453
|
+
*/
|
|
4454
|
+
/**
|
|
4455
|
+
* Take a reading off an encoder that is running, if this one is worth having.
|
|
4456
|
+
*
|
|
4457
|
+
* Separate from the realtime budget, which asks a different question — should
|
|
4458
|
+
* the quality step down — and answers it only where it CAN step down. Most of
|
|
4459
|
+
* what is worth measuring is excluded by that: a rung at the foot of its
|
|
4460
|
+
* ladder, a variant whose ladder is one rung long, a base whose video is
|
|
4461
|
+
* copied. Measuring has no such preconditions.
|
|
4462
|
+
*
|
|
4463
|
+
* What it does refuse: a suspended encoder (ffmpeg reports a CUMULATIVE
|
|
4464
|
+
* speed, so a look-ahead pause is divided into it and the figure decays while
|
|
4465
|
+
* nothing is being encoded), a reading that has not moved since the last one
|
|
4466
|
+
* (the loop runs every 5 s and a stalled encoder would otherwise fill the
|
|
4467
|
+
* whole window with one frozen sample), and a run short of input, where what
|
|
4468
|
+
* is short is the torrent rather than the machine.
|
|
4469
|
+
*
|
|
4470
|
+
* @param {HlsSession} session
|
|
4471
|
+
*/
|
|
4472
|
+
async #learnFromEncoder(session) {
|
|
4473
|
+
if (
|
|
4474
|
+
!session ||
|
|
4475
|
+
session.state === "disposed" ||
|
|
4476
|
+
session.state === "failed" ||
|
|
4477
|
+
!session.ffmpeg ||
|
|
4478
|
+
session.encoderPaused === true ||
|
|
4479
|
+
session.transcodeVideo !== true
|
|
4480
|
+
) {
|
|
4481
|
+
return;
|
|
4482
|
+
}
|
|
4483
|
+
const speed = this.#parseSpeed(session.progress?.speed);
|
|
4484
|
+
if (speed === null || speed === session.lastLearnedSpeed) {
|
|
4485
|
+
return;
|
|
4486
|
+
}
|
|
4487
|
+
if (speed < BUDGET_SPEED_OK && await this.#classifyTranscodeBound(session) === "download") {
|
|
4488
|
+
return; // the torrent is what is short; this says nothing about the host
|
|
4489
|
+
}
|
|
4490
|
+
session.lastLearnedSpeed = speed;
|
|
4491
|
+
this.#learnDecodeCost(session, speed);
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
#learnDecodeCost(session, speed) {
|
|
4495
|
+
if (session.transcodeVideo !== true || !(speed > 0)) {
|
|
4496
|
+
return; // a copied video decodes nothing, so it says nothing about decoding
|
|
4497
|
+
}
|
|
4498
|
+
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
4499
|
+
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
4500
|
+
return; // no run, or one still carrying its own start in the average
|
|
4501
|
+
}
|
|
4502
|
+
if (this.videoEncoder?.kind !== "software") {
|
|
4503
|
+
return; // the benchmark that prices the encode half is libx264 only
|
|
4504
|
+
}
|
|
4505
|
+
const benchmark = this.softwarePresetBenchmark;
|
|
4506
|
+
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
4507
|
+
return;
|
|
4508
|
+
}
|
|
4509
|
+
const entry = benchmark.find((item) => item.preset === session.softwarePreset);
|
|
4510
|
+
if (!entry || !(entry.pixelsPerSec > 0)) {
|
|
4511
|
+
return;
|
|
4512
|
+
}
|
|
4513
|
+
const height = Number(session.encodeHeight) || 0;
|
|
4514
|
+
const width = Number(session.encodeWidth) || 0;
|
|
4515
|
+
const fps = Number(session.outputFps) || TRANSCODE_FPS;
|
|
4516
|
+
if (height <= 0 || width <= 0) {
|
|
4517
|
+
return;
|
|
4518
|
+
}
|
|
4519
|
+
const encodeCostSec = (width * height * fps) / entry.pixelsPerSec;
|
|
4520
|
+
const decodeCostSec = 1 / speed - encodeCostSec;
|
|
4521
|
+
if (!(decodeCostSec > 0)) {
|
|
4522
|
+
// The encode half already accounts for everything measured. Nothing is
|
|
4523
|
+
// left to attribute to decoding, and a zero or negative cost would say
|
|
4524
|
+
// decoding is free, which is a claim this reading cannot support.
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
const key = `${session.sourceKey}:${session.fileIndex}`;
|
|
4528
|
+
const known = this.#observedDecodeCost.get(key);
|
|
4529
|
+
const readings = [...(known?.readings ?? []), decodeCostSec].slice(-DECODE_LEARNING_READINGS);
|
|
4530
|
+
const sorted = [...readings].sort((left, right) => left - right);
|
|
4531
|
+
const costSec = sorted[Math.floor(sorted.length / 2)];
|
|
4532
|
+
if (known && Math.abs(costSec - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
|
|
4533
|
+
// The same answer as before. Storing it would bump the version and make
|
|
4534
|
+
// every session recompute its offer, which is asked for on the path that
|
|
4535
|
+
// serves every playlist, init and segment.
|
|
4536
|
+
this.#observedDecodeCost.set(key, { ...known, readings });
|
|
4537
|
+
return;
|
|
4538
|
+
}
|
|
4539
|
+
this.#observedDecodeCost.set(key, { costSec, readings, version: (known?.version ?? 0) + 1 });
|
|
4540
|
+
logger.info(
|
|
4541
|
+
`transcode: ${session.fileName} decodes at ${(1 / costSec).toFixed(2)}x on this host ` +
|
|
4542
|
+
`(median of ${readings.length}, latest ${(1 / decodeCostSec).toFixed(2)}x from ${height}p ` +
|
|
4543
|
+
`at ${speed.toFixed(2)}x, preset ${session.softwarePreset})`
|
|
4544
|
+
);
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
/**
|
|
4548
|
+
* The heights this session's file will be served at, largest first — the
|
|
4549
|
+
* public form of the same answer the master playlist is built from.
|
|
4550
|
+
*
|
|
4551
|
+
* The browser asks because the master is not the only way quality changes: a
|
|
4552
|
+
* stream without variants changes it by re-opening the session at a chosen
|
|
4553
|
+
* height, and that list was being invented in the browser from the source
|
|
4554
|
+
* height alone. It has to come from the host that would have to encode it.
|
|
4555
|
+
*
|
|
4556
|
+
* @param {HlsSession} session
|
|
4557
|
+
* @returns {number[]}
|
|
4558
|
+
*/
|
|
4559
|
+
offeredHeights(session) {
|
|
4560
|
+
if (!session || session.state === "disposed") {
|
|
4561
|
+
return [];
|
|
4562
|
+
}
|
|
4563
|
+
return this.#variantHeights(session);
|
|
4564
|
+
}
|
|
4565
|
+
|
|
4566
|
+
/**
|
|
4567
|
+
* The heights this host would serve a file at, answered from the PROBE alone
|
|
4568
|
+
* — before any session exists.
|
|
4569
|
+
*
|
|
4570
|
+
* The viewer sees the quality menu the moment they open a file, so the list
|
|
4571
|
+
* cannot wait for an encoder to exist. Everything it needs is already known
|
|
4572
|
+
* by then: the source's size, rate and bitrate from the probe, and this
|
|
4573
|
+
* host's two benchmarks from startup.
|
|
4574
|
+
*
|
|
4575
|
+
* Both branches are answered because only the browser knows which one it will
|
|
4576
|
+
* take — it decides per track whether it can play the video as it is. With a
|
|
4577
|
+
* COPIED video the source height costs no encoder and is always there; with a
|
|
4578
|
+
* re-encoded one it is a prediction like every other rung.
|
|
4579
|
+
*
|
|
4580
|
+
* These are first figures, not final ones: what the encoder then really does
|
|
4581
|
+
* with this file replaces them (`offeredHeights` on a live session).
|
|
4582
|
+
*
|
|
4583
|
+
* @param {{ width: number | null, height: number | null, fps: number | null, bitrateKbps: number | null }} mediaInfo
|
|
4584
|
+
* @returns {{ copy: number[], transcode: number[] } | null}
|
|
4585
|
+
*/
|
|
4586
|
+
predictOfferedHeights(mediaInfo) {
|
|
4587
|
+
const sourceHeight = Math.round(Number(mediaInfo?.height) || 0);
|
|
4588
|
+
const sourceWidth = Number(mediaInfo?.width) || 0;
|
|
4589
|
+
if (sourceHeight <= 0 || sourceWidth <= 0) {
|
|
4590
|
+
return null;
|
|
4591
|
+
}
|
|
4592
|
+
const fps = chooseOutputFps(Number(mediaInfo?.fps) || 0);
|
|
4593
|
+
const source = sourceDecodeCharacteristics(mediaInfo);
|
|
4594
|
+
const heights = variantHeightsFor(sourceHeight);
|
|
4595
|
+
// What an encoder has already been seen to cost on this very file, when it
|
|
4596
|
+
// has run before. Without it a second open of a file answers from the
|
|
4597
|
+
// startup clips again, undoing the correction the first playback earned.
|
|
4598
|
+
const observedDecodeCostSec = mediaInfo?.sourceKey !== undefined
|
|
4599
|
+
? (this.#observedDecodeCost.get(`${mediaInfo.sourceKey}:${mediaInfo.fileIndex}`)?.costSec ?? null)
|
|
4600
|
+
: null;
|
|
4601
|
+
const forBranch = (transcodeVideo) =>
|
|
4602
|
+
this.#sustainableHeights({
|
|
4603
|
+
heights,
|
|
4604
|
+
observedDecodeCostSec,
|
|
4605
|
+
// Nothing is running yet, so nothing is exempt from being predicted —
|
|
4606
|
+
// except the copy itself, which the branch flag already covers.
|
|
4607
|
+
ownHeight: 0,
|
|
4608
|
+
sourceWidth,
|
|
4609
|
+
sourceHeight,
|
|
4610
|
+
fps,
|
|
4611
|
+
source,
|
|
4612
|
+
transcodeVideo
|
|
4613
|
+
});
|
|
4614
|
+
return { copy: forBranch(false), transcode: forBranch(true) };
|
|
4615
|
+
}
|
|
4616
|
+
|
|
4617
|
+
/**
|
|
4618
|
+
* Drop the rungs this host cannot hold at realtime.
|
|
4619
|
+
*
|
|
4620
|
+
* Every rung below the source height is a full re-encode — decode the whole
|
|
4621
|
+
* source, encode a smaller picture — and on a weak host that is dearer than
|
|
4622
|
+
* the copy it replaces. Measured 2026-08-14: 1080p was copied at 7.8-8.9x
|
|
4623
|
+
* while the offered 240p rung ran at 0.388-0.947x, its first segment took
|
|
4624
|
+
* 30 s and later ones were held 22 s, so choosing a LOWER quality is what
|
|
4625
|
+
* broke playback. A rung that cannot be produced faster than it is watched
|
|
4626
|
+
* must not be offered at all.
|
|
4627
|
+
*
|
|
4628
|
+
* The session's OWN height always stays: an encoder is already producing it,
|
|
4629
|
+
* and removing it would point the player at a rung nobody is encoding.
|
|
4630
|
+
*
|
|
4631
|
+
* @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
|
|
4632
|
+
* @returns {number[]}
|
|
4633
|
+
*/
|
|
4634
|
+
#sustainableHeights({
|
|
4635
|
+
heights,
|
|
4636
|
+
ownHeight,
|
|
4637
|
+
playingHeight = 0,
|
|
4638
|
+
sourceWidth,
|
|
4639
|
+
sourceHeight,
|
|
4640
|
+
fps,
|
|
4641
|
+
source,
|
|
4642
|
+
transcodeVideo,
|
|
4643
|
+
observedDecodeCostSec = null
|
|
4644
|
+
}) {
|
|
4645
|
+
const benchmark = this.softwarePresetBenchmark;
|
|
4646
|
+
if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
|
|
4647
|
+
return heights;
|
|
4648
|
+
}
|
|
4649
|
+
/** @type {number[]} */
|
|
4650
|
+
const kept = [];
|
|
4651
|
+
/** @type {string[]} */
|
|
4652
|
+
const dropped = [];
|
|
4653
|
+
for (const height of heights) {
|
|
4654
|
+
// The height an encoder is ALREADY producing, and the source's own height
|
|
4655
|
+
// when the FAMILY serves it by copy — neither has to be predicted,
|
|
4656
|
+
// because it is happening. A copied rung costs no encoder at all, so no
|
|
4657
|
+
// measurement of this host can ever be a reason to withdraw it, and the
|
|
4658
|
+
// whole point of it is that it is where a viewer on a rung the machine
|
|
4659
|
+
// cannot hold goes back to. `transcodeVideo` here is the base's, not the
|
|
4660
|
+
// asking session's: a 240p rung re-encodes, and reading its own flag is
|
|
4661
|
+
// what withdrew a copied 1080p in the field on 2026-08-15.
|
|
4662
|
+
//
|
|
4663
|
+
// A source height that would have to be RE-ENCODED is a prediction like
|
|
4664
|
+
// any other: on a session whose budget downshifted to 480p, the source's
|
|
4665
|
+
// 1080p is neither copied nor being produced, and keeping it unpriced
|
|
4666
|
+
// would offer exactly the kind of rung this refuses.
|
|
4667
|
+
if (
|
|
4668
|
+
height === ownHeight ||
|
|
4669
|
+
height === playingHeight ||
|
|
4670
|
+
(height === sourceHeight && !transcodeVideo)
|
|
4671
|
+
) {
|
|
4672
|
+
kept.push(height);
|
|
4673
|
+
continue;
|
|
4674
|
+
}
|
|
4675
|
+
const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
|
|
4676
|
+
const { speed, sustainable } = canSustainOutput({
|
|
4677
|
+
benchmark,
|
|
4678
|
+
decodeModel: this.decodeCostModel,
|
|
4679
|
+
source,
|
|
4680
|
+
outputPixelsPerSec: width * height * fps,
|
|
4681
|
+
observedDecodeCostSec
|
|
4682
|
+
});
|
|
4683
|
+
if (sustainable) {
|
|
4684
|
+
kept.push(height);
|
|
4685
|
+
continue;
|
|
4686
|
+
}
|
|
4687
|
+
dropped.push(`${height}p=${speed === null ? "n/a" : `${speed.toFixed(2)}x`}`);
|
|
4688
|
+
}
|
|
4689
|
+
if (dropped.length > 0) {
|
|
4690
|
+
logger.info(
|
|
4691
|
+
`transcode: not offering ${dropped.join(" ")} — below realtime × ${REALTIME_SPEED_MARGIN} ` +
|
|
4692
|
+
`(offering ${kept.map((height) => `${height}p`).join(" ")})`
|
|
4693
|
+
);
|
|
4694
|
+
}
|
|
4695
|
+
return kept;
|
|
4009
4696
|
}
|
|
4010
4697
|
|
|
4011
4698
|
/**
|
|
@@ -4211,6 +4898,16 @@ export class HlsSessionManager {
|
|
|
4211
4898
|
// variants could drift onto the same height and the choice would mean
|
|
4212
4899
|
// nothing.
|
|
4213
4900
|
manualQuality: true,
|
|
4901
|
+
// A rung of a session whose audio is published separately carries no
|
|
4902
|
+
// audio either — every rung of one master must agree about that, or
|
|
4903
|
+
// switching rung would start or stop a second copy of the same track.
|
|
4904
|
+
audioRenditions: base.audioRenditions === true,
|
|
4905
|
+
// Not re-decided here: asked on its own, a variant would answer about the
|
|
4906
|
+
// rungs IT would be offered at — a 540p rung of a copied 1080p source is
|
|
4907
|
+
// offered nothing but itself, so it would conclude "audio muxed" and
|
|
4908
|
+
// start carrying a second copy of a track the player is already fetching
|
|
4909
|
+
// from the rendition.
|
|
4910
|
+
inheritedAudioSeparate: base.audioSeparate === true,
|
|
4214
4911
|
segmentFormatId: base.segmentFormat?.id ?? "",
|
|
4215
4912
|
// Cut where the base is cut. Only for a base on the source's own keyframe
|
|
4216
4913
|
// grid — a copy — where the variant has to land on those exact times to
|
|
@@ -4499,19 +5196,204 @@ export class HlsSessionManager {
|
|
|
4499
5196
|
}
|
|
4500
5197
|
const sourceWidth = Number(session.sourceWidth) || 0;
|
|
4501
5198
|
const lines = ["#EXTM3U", `#EXT-X-VERSION:${session.segmentFormat.playlistVersion}`];
|
|
5199
|
+
// The audio tracks, published once for the whole file rather than muxed
|
|
5200
|
+
// into every rung. Two things follow from that: the same track is not
|
|
5201
|
+
// encoded once per rung on a host that struggles to encode it once, and
|
|
5202
|
+
// changing track becomes the player switching rendition instead of this
|
|
5203
|
+
// proxy rebuilding the session with another `audioTrackIndex`.
|
|
5204
|
+
//
|
|
5205
|
+
// Only for a session that asked for them. A browser that does not know
|
|
5206
|
+
// about renditions is served audio in its stream, as before, and gets no
|
|
5207
|
+
// `#EXT-X-MEDIA` lines to be confused by.
|
|
5208
|
+
const renditions = this.#servesAudioSeparately(session) ? this.#audioRenditionsOf(session) : [];
|
|
5209
|
+
const audioGroup = renditions.length > 0 ? AUDIO_GROUP_ID : "";
|
|
5210
|
+
for (const rendition of renditions) {
|
|
5211
|
+
lines.push(
|
|
5212
|
+
`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="${audioGroup}",NAME="${escapeAttribute(rendition.name)}"` +
|
|
5213
|
+
(rendition.language ? `,LANGUAGE="${escapeAttribute(languageTag(rendition.language))}"` : "") +
|
|
5214
|
+
`,AUTOSELECT=YES,DEFAULT=${rendition.isDefault ? "YES" : "NO"}` +
|
|
5215
|
+
`,URI="${AUDIO_PATH_PREFIX}/${rendition.trackIndex}/${PLAYLIST_FILE_NAME}"`
|
|
5216
|
+
);
|
|
5217
|
+
}
|
|
4502
5218
|
for (const height of rungs) {
|
|
4503
5219
|
const width = sourceHeight > 0 && sourceWidth > 0
|
|
4504
5220
|
? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
|
|
4505
5221
|
: 0;
|
|
4506
5222
|
lines.push(
|
|
4507
5223
|
`#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
|
|
4508
|
-
(width > 0 ? `,RESOLUTION=${width}x${height}` : "")
|
|
5224
|
+
(width > 0 ? `,RESOLUTION=${width}x${height}` : "") +
|
|
5225
|
+
(audioGroup ? `,AUDIO="${audioGroup}"` : "")
|
|
4509
5226
|
);
|
|
4510
5227
|
lines.push(`${VARIANT_PATH_PREFIX}/${height}/${PLAYLIST_FILE_NAME}`);
|
|
4511
5228
|
}
|
|
4512
5229
|
return `${lines.join("\n")}\n`;
|
|
4513
5230
|
}
|
|
4514
5231
|
|
|
5232
|
+
/**
|
|
5233
|
+
* Whether this session's audio is published separately rather than muxed into
|
|
5234
|
+
* its picture.
|
|
5235
|
+
*
|
|
5236
|
+
* Two things have to hold, and the second is why this is asked here rather
|
|
5237
|
+
* than settled when the session was made. The browser must understand
|
|
5238
|
+
* renditions — it says so when it creates the session, and one that does not
|
|
5239
|
+
* has to be sent audio in the stream. AND there must be a master playlist to
|
|
5240
|
+
* publish them in: a stream served as a single media playlist has nowhere to
|
|
5241
|
+
* carry an `#EXT-X-MEDIA` line, so taking the audio out of it would leave the
|
|
5242
|
+
* viewer with a picture and silence.
|
|
5243
|
+
*
|
|
5244
|
+
* @param {HlsSession} session
|
|
5245
|
+
* @returns {boolean}
|
|
5246
|
+
*/
|
|
5247
|
+
#servesAudioSeparately(session) {
|
|
5248
|
+
return session.audioOnly !== true && session.audioSeparate === true;
|
|
5249
|
+
}
|
|
5250
|
+
|
|
5251
|
+
/**
|
|
5252
|
+
* One file of an audio rendition: its playlist, its init segment or one of
|
|
5253
|
+
* its segments.
|
|
5254
|
+
*
|
|
5255
|
+
* A rendition is an ordinary session underneath — same source, same file,
|
|
5256
|
+
* same cut grid, one audio track and no picture — created on the first
|
|
5257
|
+
* request for it, exactly as a quality variant is. What differs is that the
|
|
5258
|
+
* player fetches it ALONGSIDE a variant rather than instead of one, so both
|
|
5259
|
+
* encoders run: a rung and the audio it is played with.
|
|
5260
|
+
*
|
|
5261
|
+
* @param {string} baseSessionId
|
|
5262
|
+
* @param {number} trackIndex
|
|
5263
|
+
* @param {string} fileName
|
|
5264
|
+
* @returns {Promise<{ sessionId: string | null, error?: string }>}
|
|
5265
|
+
*/
|
|
5266
|
+
async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName) {
|
|
5267
|
+
if (!isSafeSessionId(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
5268
|
+
return { sessionId: null };
|
|
5269
|
+
}
|
|
5270
|
+
const base = this.sessionsById.get(baseSessionId);
|
|
5271
|
+
if (!base || base.state === "disposed" || !this.#servesAudioSeparately(base)) {
|
|
5272
|
+
return { sessionId: null };
|
|
5273
|
+
}
|
|
5274
|
+
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
5275
|
+
const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
|
|
5276
|
+
const isSegment = base.segmentFormat.isSegmentFileName(fileName);
|
|
5277
|
+
if (!isPlaylist && !isInit && !isSegment) {
|
|
5278
|
+
return { sessionId: null };
|
|
5279
|
+
}
|
|
5280
|
+
if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
|
|
5281
|
+
return { sessionId: null };
|
|
5282
|
+
}
|
|
5283
|
+
// The playlist is answered from the base, for the same reason a variant's
|
|
5284
|
+
// is: every rendition of a file has the same boundaries and the same
|
|
5285
|
+
// duration — they are cut on one grid — and the player fetches the playlist
|
|
5286
|
+
// of tracks it may never select. Starting an encoder for each would put as
|
|
5287
|
+
// many encoders on the host as the file has languages.
|
|
5288
|
+
if (isPlaylist) {
|
|
5289
|
+
return { sessionId: base.id };
|
|
5290
|
+
}
|
|
5291
|
+
let rendition;
|
|
5292
|
+
try {
|
|
5293
|
+
rendition = await this.#resolveAudioRenditionSession(base, trackIndex);
|
|
5294
|
+
} catch (error) {
|
|
5295
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5296
|
+
logger.error(
|
|
5297
|
+
`transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
|
|
5298
|
+
(error instanceof Error && error.stack ? `\n${error.stack}` : "")
|
|
5299
|
+
);
|
|
5300
|
+
return { sessionId: null, error: message };
|
|
5301
|
+
}
|
|
5302
|
+
return { sessionId: rendition?.id ?? null };
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
/**
|
|
5306
|
+
* The session producing one audio track of this file, made on first request.
|
|
5307
|
+
*
|
|
5308
|
+
* @param {HlsSession} base
|
|
5309
|
+
* @param {number} trackIndex
|
|
5310
|
+
* @returns {Promise<HlsSession | null>}
|
|
5311
|
+
*/
|
|
5312
|
+
async #resolveAudioRenditionSession(base, trackIndex) {
|
|
5313
|
+
const existingId = base.audioRenditionSessions?.get(trackIndex);
|
|
5314
|
+
const existing = existingId ? this.sessionsById.get(existingId) : null;
|
|
5315
|
+
if (existing && existing.state !== "disposed") {
|
|
5316
|
+
existing.lastAccessedAt = Date.now();
|
|
5317
|
+
return existing;
|
|
5318
|
+
}
|
|
5319
|
+
const rendition = await this.createOrGetSession({
|
|
5320
|
+
sourceKey: base.sourceKey,
|
|
5321
|
+
fileIndex: base.fileIndex,
|
|
5322
|
+
// No picture at all: the video flag says what to do with a video stream
|
|
5323
|
+
// this output does not carry.
|
|
5324
|
+
transcodeVideo: false,
|
|
5325
|
+
transcodeAudio: base.transcodeAudio,
|
|
5326
|
+
fileName: base.fileName,
|
|
5327
|
+
consumerId: variantConsumerId(base.id),
|
|
5328
|
+
audioTrackIndex: trackIndex,
|
|
5329
|
+
audioOnly: true,
|
|
5330
|
+
// Where the viewer is, so the rendition starts with the picture rather
|
|
5331
|
+
// than at the beginning of the file. Read the same way a quality variant
|
|
5332
|
+
// reads it: the base's own field is only written by a seek or by a
|
|
5333
|
+
// segment IT served, so on a resume-from-position open it is still unset
|
|
5334
|
+
// while the player is asking for segment #537 — and the audio would begin
|
|
5335
|
+
// at zero and never catch up, since nothing treats a far request as a
|
|
5336
|
+
// seek. The accessor falls back to the last segment actually requested.
|
|
5337
|
+
startPositionSeconds:
|
|
5338
|
+
Math.floor(this.#viewerPositionOf(this.#activeVariant(base)) / 10) * 10,
|
|
5339
|
+
segmentFormatId: base.segmentFormat.id,
|
|
5340
|
+
// Cut where the picture is cut. Two streams meant to be played together
|
|
5341
|
+
// have to be divided at the same times, and the grid is the base's — the
|
|
5342
|
+
// table as it stands now, corrections included. A base on the uniform
|
|
5343
|
+
// grid passes nothing: the rendition computes the same even grid from the
|
|
5344
|
+
// same duration.
|
|
5345
|
+
inheritedGrid: base.cutGrid === "keyframe"
|
|
5346
|
+
? {
|
|
5347
|
+
boundaries: base.segmentBoundaries,
|
|
5348
|
+
keyframeTimes: base.keyframeTimes,
|
|
5349
|
+
containerFormat: base.containerFormat
|
|
5350
|
+
}
|
|
5351
|
+
: null,
|
|
5352
|
+
acquireSource: base.acquireSource
|
|
5353
|
+
});
|
|
5354
|
+
if (!rendition) {
|
|
5355
|
+
return null;
|
|
5356
|
+
}
|
|
5357
|
+
if (!(base.audioRenditionSessions instanceof Map)) {
|
|
5358
|
+
base.audioRenditionSessions = new Map();
|
|
5359
|
+
}
|
|
5360
|
+
base.audioRenditionSessions.set(trackIndex, rendition.id);
|
|
5361
|
+
return rendition;
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5364
|
+
/**
|
|
5365
|
+
* The audio tracks of this session's file, as renditions for the master.
|
|
5366
|
+
*
|
|
5367
|
+
* Taken from the inventory the playback plan already probed — the same list
|
|
5368
|
+
* the browser's audio menu is built from — so nothing is probed again here.
|
|
5369
|
+
* The track the session was created with is the default one: it is what the
|
|
5370
|
+
* viewer chose (or the file's first track), and a master that defaulted to
|
|
5371
|
+
* something else would change the language on its own.
|
|
5372
|
+
*
|
|
5373
|
+
* @param {HlsSession} session
|
|
5374
|
+
* @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
|
|
5375
|
+
*/
|
|
5376
|
+
#audioRenditionsOf(session) {
|
|
5377
|
+
const tracks = this.getCachedAudioTracks?.({
|
|
5378
|
+
sourceKey: session.sourceKey,
|
|
5379
|
+
fileIndex: session.fileIndex
|
|
5380
|
+
}) ?? [];
|
|
5381
|
+
if (!Array.isArray(tracks) || tracks.length === 0) {
|
|
5382
|
+
return [];
|
|
5383
|
+
}
|
|
5384
|
+
const chosen = Number(session.audioTrackIndex) || 0;
|
|
5385
|
+
return tracks.map((track, order) => {
|
|
5386
|
+
const language = typeof track?.language === "string" ? track.language : "";
|
|
5387
|
+
const title = typeof track?.title === "string" && track.title.length > 0 ? track.title : "";
|
|
5388
|
+
return {
|
|
5389
|
+
trackIndex: order,
|
|
5390
|
+
name: title || language || `Track ${order + 1}`,
|
|
5391
|
+
language,
|
|
5392
|
+
isDefault: order === chosen
|
|
5393
|
+
};
|
|
5394
|
+
});
|
|
5395
|
+
}
|
|
5396
|
+
|
|
4515
5397
|
/**
|
|
4516
5398
|
* How many times the viewer has moved since this session started.
|
|
4517
5399
|
*
|
|
@@ -5075,6 +5957,12 @@ export class HlsSessionManager {
|
|
|
5075
5957
|
currentHeight: session.transcodeVideo
|
|
5076
5958
|
? (session.encodeHeight ?? session.sourceHeight ?? 0)
|
|
5077
5959
|
: (session.sourceHeight ?? 0),
|
|
5960
|
+
// The rungs still worth offering, as they stand NOW. The list the browser
|
|
5961
|
+
// was given when the file opened came from the startup benchmarks; this
|
|
5962
|
+
// one is corrected by what the encoder has since been seen to do with
|
|
5963
|
+
// this very source, so a rung that turns out to be beyond the host
|
|
5964
|
+
// disappears from the menu instead of being discovered by switching to it.
|
|
5965
|
+
offeredHeights: this.offeredHeights(session),
|
|
5078
5966
|
// What this host takes to create a session and to make a first segment.
|
|
5079
5967
|
// Also on the playback plan, but the browser reads that once per file:
|
|
5080
5968
|
// measured 2026-08-06 across four seeks, a proxy that had just restarted
|
|
@@ -5156,6 +6044,21 @@ export class HlsSessionManager {
|
|
|
5156
6044
|
);
|
|
5157
6045
|
}
|
|
5158
6046
|
}
|
|
6047
|
+
// The audio renditions of this session, for the same reason and in the same
|
|
6048
|
+
// way. Nobody outside this class knows their ids — the browser holds one id
|
|
6049
|
+
// for the whole file — so nothing else could ever release them, and each
|
|
6050
|
+
// holds a consumer, a claim on the torrent, a directory and a live encoder.
|
|
6051
|
+
if (session.audioRenditionSessions instanceof Map) {
|
|
6052
|
+
const renditionIds = [...session.audioRenditionSessions.values()];
|
|
6053
|
+
session.audioRenditionSessions.clear();
|
|
6054
|
+
for (const renditionId of renditionIds) {
|
|
6055
|
+
await this.releaseSessionConsumer(
|
|
6056
|
+
renditionId,
|
|
6057
|
+
variantConsumerId(sessionId),
|
|
6058
|
+
"the session its audio belonged to ended"
|
|
6059
|
+
);
|
|
6060
|
+
}
|
|
6061
|
+
}
|
|
5159
6062
|
// Disposed on its own (idle, or with its last family): it must stop being
|
|
5160
6063
|
// offered, or the next request for that height would be answered with a
|
|
5161
6064
|
// session that no longer exists.
|