@torrent-tv/proxy 2.12.2 → 2.13.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 +18 -0
- 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 +7 -0
- package/server.js +22 -3
- package/services/ffmpeg-banner.js +42 -0
- package/services/hls-session-manager.js +461 -20
- package/services/hwaccel.js +478 -12
- package/services/playback-planner.js +36 -3
- package/test/decode-cost.test.js +401 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a re-encode costs on this host, decoding included.
|
|
3
|
+
*
|
|
4
|
+
* The budget used to price the encoder alone. Measured on the addon host
|
|
5
|
+
* 2026-08-14, that made it offer a 240p rung it then ran at 0.388-0.947x while
|
|
6
|
+
* claiming the host cleared the bar 2.5 times over — the error on that rung was
|
|
7
|
+
* 209 %. The decode term brings a controlled measurement to within 5 %.
|
|
8
|
+
*
|
|
9
|
+
* The first test runs the real benchmark against the shipped clips with the
|
|
10
|
+
* real ffmpeg, because a fit that only ever runs against invented numbers can
|
|
11
|
+
* be wrong in every way that matters (2.9.124: a module tested only through its
|
|
12
|
+
* own exports missed the caller that never called it).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
benchmarkDecodeCost,
|
|
24
|
+
canSustainOutput,
|
|
25
|
+
decodeSpeedFor,
|
|
26
|
+
predictedRealtimeSpeed,
|
|
27
|
+
REALTIME_SPEED_MARGIN
|
|
28
|
+
} from "../services/hwaccel.js";
|
|
29
|
+
import { HlsSessionManager, sourceDecodeCharacteristics } from "../services/hls-session-manager.js";
|
|
30
|
+
import { parseFfmpegBitrateKbps, parseFfmpegDurationSeconds, parseFfmpegVideoDimensions, parseFfmpegVideoFps } from "../services/ffmpeg-banner.js";
|
|
31
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const ffmpegBin = require("ffmpeg-static");
|
|
35
|
+
const CALIBRATION_DIR = path.join(import.meta.dirname, "..", "assets", "calibration");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How long one ffmpeg run takes, and what its banner said.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} clip
|
|
41
|
+
* @param {number} repeats - `-stream_loop`.
|
|
42
|
+
* @returns {Promise<{ elapsedSec: number, stderr: string }>}
|
|
43
|
+
*/
|
|
44
|
+
function runDecode(clip, repeats) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const args = [
|
|
47
|
+
"-hide_banner", "-loglevel", "info",
|
|
48
|
+
"-stream_loop", String(repeats),
|
|
49
|
+
"-i", path.join(CALIBRATION_DIR, clip),
|
|
50
|
+
"-an", "-f", "null", "-"
|
|
51
|
+
];
|
|
52
|
+
const startedAt = Date.now();
|
|
53
|
+
let stderr = "";
|
|
54
|
+
const child = spawn(ffmpegBin, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
|
|
55
|
+
child.stderr.on("data", (chunk) => {
|
|
56
|
+
stderr += String(chunk);
|
|
57
|
+
});
|
|
58
|
+
child.on("error", reject);
|
|
59
|
+
child.on("close", (code) => {
|
|
60
|
+
if (code !== 0) {
|
|
61
|
+
reject(new Error(`decoding ${clip} failed (code ${code})`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
resolve({ elapsedSec: (Date.now() - startedAt) / 1000, stderr });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What decoding one calibration clip costs, in seconds of work per second of
|
|
71
|
+
* video — measured the way the benchmark measures it, as the difference
|
|
72
|
+
* between two passes and one, so no part of starting the process is counted.
|
|
73
|
+
* A prediction can only be held against a figure that means the same thing.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} clip
|
|
76
|
+
* @returns {Promise<number>}
|
|
77
|
+
*/
|
|
78
|
+
async function timeDecode(clip) {
|
|
79
|
+
// Four passes against one, so the difference covers THREE decodes. The
|
|
80
|
+
// benchmark differences two runs against one, which is enough on a weak host
|
|
81
|
+
// where a pass takes seconds; on a fast desktop a pass is under a fifth of a
|
|
82
|
+
// second and run-to-run jitter is the same size, so a single difference here
|
|
83
|
+
// would measure the machine's mood rather than its decoder.
|
|
84
|
+
const single = await runDecode(clip, 0);
|
|
85
|
+
const quadruple = await runDecode(clip, 3);
|
|
86
|
+
const seconds = parseFfmpegDurationSeconds(single.stderr);
|
|
87
|
+
if (!(seconds > 0)) {
|
|
88
|
+
throw new Error(`${clip} reported no duration`);
|
|
89
|
+
}
|
|
90
|
+
return (quadruple.elapsedSec - single.elapsedSec) / 3 / seconds;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The constants measured on the addon host (CM4) on 2026-08-14. Used here as
|
|
94
|
+
// a fixed host, so the arithmetic can be checked against figures that were
|
|
95
|
+
// measured rather than against figures this test invented.
|
|
96
|
+
const ADDON_HOST_MODEL = { pixelTerm: 0.005555, bitrateTerm: 0.00990, constantTerm: 0.0572 };
|
|
97
|
+
// The film measured that day: 1920x1080 at 24 fps, about 8 Mbit/s.
|
|
98
|
+
const MEASURED_FILM = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 8 };
|
|
99
|
+
|
|
100
|
+
test("the fit comes out of the real clips, and predicts one of them back", async () => {
|
|
101
|
+
const model = await benchmarkDecodeCost({ ffmpegBin });
|
|
102
|
+
|
|
103
|
+
assert.ok(model, "the clips ship with the package and this host has ffmpeg");
|
|
104
|
+
assert.ok(model.pixelTerm > 0, "more pixels cannot decode faster");
|
|
105
|
+
assert.ok(Number.isFinite(model.bitrateTerm) && Number.isFinite(model.constantTerm));
|
|
106
|
+
|
|
107
|
+
// What the model must get right is how cost SCALES between one source and
|
|
108
|
+
// another — that is the whole of its job, since it is asked about rungs
|
|
109
|
+
// nobody has decoded. So the check is a ratio, measured by a different method
|
|
110
|
+
// (differencing whole processes) than the fit uses (the slope inside one).
|
|
111
|
+
//
|
|
112
|
+
// Deliberately not absolute cost: the two measurements happen at different
|
|
113
|
+
// moments, this suite runs its files in parallel, and the machine's load
|
|
114
|
+
// moves both of them together. A ratio divides that out; an absolute
|
|
115
|
+
// comparison pins how quiet the machine happened to be, and failed for
|
|
116
|
+
// exactly that reason.
|
|
117
|
+
const clip720 = { megapixelsPerSecond: (1280 * 720 * 24) / 1e6, megabitsPerSecond: 2.248 };
|
|
118
|
+
const clip1080 = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 11.375 };
|
|
119
|
+
const predictedRatio = decodeSpeedFor(model, clip720) / decodeSpeedFor(model, clip1080);
|
|
120
|
+
const measuredRatio = (await timeDecode("cal-1080-hi.mp4")) / (await timeDecode("cal-720.mp4"));
|
|
121
|
+
|
|
122
|
+
assert.ok(
|
|
123
|
+
Math.abs(predictedRatio - measuredRatio) / measuredRatio < 0.5,
|
|
124
|
+
`the model scales 720 against 1080-hi by ${predictedRatio.toFixed(2)}, ` +
|
|
125
|
+
`measured ${measuredRatio.toFixed(2)}`
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("decode cost prices the film it was checked against", () => {
|
|
130
|
+
const speed = decodeSpeedFor(ADDON_HOST_MODEL, MEASURED_FILM);
|
|
131
|
+
|
|
132
|
+
// 0.005555 x 49.77 + 0.00990 x 8 + 0.0572 = 0.4129 s per second of video.
|
|
133
|
+
assert.ok(Math.abs(1 / speed - 0.4129) < 0.001, `cost was ${(1 / speed).toFixed(4)} s/s`);
|
|
134
|
+
// Measured that day: 0.434 s/s. The claim is 5 %, not exactness.
|
|
135
|
+
assert.ok(Math.abs(1 / speed - 0.434) / 0.434 < 0.05);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("decoding and encoding share the machine, so their speeds combine", () => {
|
|
139
|
+
// The rung that broke playback: 240p at 24 fps, encoded at 5.99x by the
|
|
140
|
+
// measurement, decoded at 2.31x. Measured combined speed was 1.48x.
|
|
141
|
+
const outputPixelsPerSec = 426 * 240 * 24;
|
|
142
|
+
const speed = predictedRealtimeSpeed({
|
|
143
|
+
decodeModel: { pixelTerm: 0, bitrateTerm: 0, constantTerm: 1 / 2.31 },
|
|
144
|
+
encodePixelsPerSec: outputPixelsPerSec * 5.99,
|
|
145
|
+
outputPixelsPerSec,
|
|
146
|
+
source: MEASURED_FILM
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.ok(Math.abs(speed - 1.67) < 0.01, `predicted ${speed.toFixed(2)}x`);
|
|
150
|
+
// 1.67 against the 1.48x that rung measured under a controlled encode: 12.8 %
|
|
151
|
+
// out. The figure is pinned so a change to the combination has to state what
|
|
152
|
+
// it does to it.
|
|
153
|
+
assert.ok(Math.abs(speed - 1.48) / 1.48 < 0.13);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("with no decode fit the prediction is the encoder alone — what it was before", () => {
|
|
157
|
+
const outputPixelsPerSec = 426 * 240 * 24;
|
|
158
|
+
const speed = predictedRealtimeSpeed({
|
|
159
|
+
decodeModel: null,
|
|
160
|
+
encodePixelsPerSec: outputPixelsPerSec * 5.99,
|
|
161
|
+
outputPixelsPerSec,
|
|
162
|
+
source: MEASURED_FILM
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
assert.equal(speed, 5.99, "the old figure, four times the truth on that rung");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a rung is refused when the combined speed is under the margin", () => {
|
|
169
|
+
// The addon host's fastest preset, read from its own log: 11.2 Mpx/s.
|
|
170
|
+
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
171
|
+
const source = MEASURED_FILM;
|
|
172
|
+
|
|
173
|
+
const heavy = canSustainOutput({
|
|
174
|
+
benchmark,
|
|
175
|
+
decodeModel: ADDON_HOST_MODEL,
|
|
176
|
+
source,
|
|
177
|
+
outputPixelsPerSec: 1280 * 720 * 24
|
|
178
|
+
});
|
|
179
|
+
assert.equal(heavy.sustainable, false, "720p needs 22 Mpx/s from an 11.2 Mpx/s host");
|
|
180
|
+
assert.ok(heavy.speed < REALTIME_SPEED_MARGIN);
|
|
181
|
+
|
|
182
|
+
// And this is the gap the model does NOT close: the 240p rung predicts 1.58x
|
|
183
|
+
// and clears a margin of 1.5, while the field measured that same rung at
|
|
184
|
+
// 0.388-0.947x under real load — a host simultaneously copying 1080p,
|
|
185
|
+
// downloading the torrent and pushing segments. The prediction is honest for
|
|
186
|
+
// an idle machine; the margin is what has to carry the load, and 1.5 does not
|
|
187
|
+
// carry it. Pinned here so the arithmetic is not rediscovered from a log.
|
|
188
|
+
const light = canSustainOutput({
|
|
189
|
+
benchmark,
|
|
190
|
+
decodeModel: ADDON_HOST_MODEL,
|
|
191
|
+
source,
|
|
192
|
+
outputPixelsPerSec: 426 * 240 * 24
|
|
193
|
+
});
|
|
194
|
+
assert.ok(Math.abs(light.speed - 1.58) < 0.01, `predicted ${light.speed.toFixed(2)}x`);
|
|
195
|
+
assert.equal(light.sustainable, true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a reading from the running encoder outranks the model of the clips", () => {
|
|
199
|
+
// The field case, 2026-08-14: the 240p rung of that film ran at 0.95x at its
|
|
200
|
+
// best on a host whose fastest preset benchmarked at 11.2 Mpx/s. Subtracting
|
|
201
|
+
// the encode half of that reading leaves what the SOURCE costs to decode
|
|
202
|
+
// there — 0.83 s per second of video, i.e. 1.20x — which is four times what
|
|
203
|
+
// the H.264 clips predicted for it, and is the truth about this file.
|
|
204
|
+
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
205
|
+
const observedDecodeCostSec = 1 / 0.95 - (426 * 240 * 24) / 11.2e6;
|
|
206
|
+
assert.ok(Math.abs(observedDecodeCostSec - 0.8335) < 0.001);
|
|
207
|
+
|
|
208
|
+
const withObservation = canSustainOutput({
|
|
209
|
+
benchmark,
|
|
210
|
+
decodeModel: ADDON_HOST_MODEL,
|
|
211
|
+
source: MEASURED_FILM,
|
|
212
|
+
outputPixelsPerSec: 426 * 240 * 24,
|
|
213
|
+
observedDecodeCostSec
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// The same rung the clip model called 1.58x and admitted is now priced at
|
|
217
|
+
// 0.95x — the speed it was actually seen to run at — and refused.
|
|
218
|
+
assert.ok(Math.abs(withObservation.speed - 0.95) < 0.01, `priced ${withObservation.speed.toFixed(2)}x`);
|
|
219
|
+
assert.equal(withObservation.sustainable, false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("an observation prices a host whose clips were never fitted", () => {
|
|
223
|
+
// No model at all — clips missing, or a fit rejected. Before an observation
|
|
224
|
+
// nothing can be refused; after one, the same rung is priced and refused.
|
|
225
|
+
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
226
|
+
const outputPixelsPerSec = 426 * 240 * 24;
|
|
227
|
+
|
|
228
|
+
const unpriced = canSustainOutput({
|
|
229
|
+
benchmark,
|
|
230
|
+
decodeModel: null,
|
|
231
|
+
source: MEASURED_FILM,
|
|
232
|
+
outputPixelsPerSec
|
|
233
|
+
});
|
|
234
|
+
assert.deepEqual(unpriced, { speed: null, sustainable: true });
|
|
235
|
+
|
|
236
|
+
const priced = canSustainOutput({
|
|
237
|
+
benchmark,
|
|
238
|
+
decodeModel: null,
|
|
239
|
+
source: MEASURED_FILM,
|
|
240
|
+
outputPixelsPerSec,
|
|
241
|
+
observedDecodeCostSec: 0.8335
|
|
242
|
+
});
|
|
243
|
+
assert.equal(priced.sustainable, false);
|
|
244
|
+
assert.ok(priced.speed < 1, `priced ${priced.speed?.toFixed(2)}x`);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("nothing measured means nothing refused", () => {
|
|
248
|
+
const verdict = canSustainOutput({
|
|
249
|
+
benchmark: [],
|
|
250
|
+
decodeModel: null,
|
|
251
|
+
source: null,
|
|
252
|
+
outputPixelsPerSec: 1920 * 1080 * 24
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
assert.deepEqual(verdict, { speed: null, sustainable: true });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a real ffmpeg banner reads into the figures the budget prices", async () => {
|
|
259
|
+
// The chain a session actually runs: ffmpeg prints its banner, the four
|
|
260
|
+
// readers take the four facts out of it, and `sourceDecodeCharacteristics`
|
|
261
|
+
// turns them into the two the fit uses. Run against a real file, because the
|
|
262
|
+
// bitrate reader is new and a banner is the one input nobody can invent
|
|
263
|
+
// faithfully.
|
|
264
|
+
const stderr = await new Promise((resolve) => {
|
|
265
|
+
let text = "";
|
|
266
|
+
const child = spawn(
|
|
267
|
+
ffmpegBin,
|
|
268
|
+
["-hide_banner", "-loglevel", "info", "-i", path.join(CALIBRATION_DIR, "cal-720.mp4"), "-t", "0.1", "-f", "null", "-"],
|
|
269
|
+
{ stdio: ["ignore", "ignore", "pipe"], windowsHide: true }
|
|
270
|
+
);
|
|
271
|
+
child.stderr.on("data", (chunk) => {
|
|
272
|
+
text += String(chunk);
|
|
273
|
+
});
|
|
274
|
+
child.on("close", () => resolve(text));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// The VIDEO stream's rate (2248), not the container's (2252). On a clip that
|
|
278
|
+
// carries nothing else the two differ only by container overhead; on a film
|
|
279
|
+
// with two AC-3 tracks they differ by the whole of the audio, and the fit
|
|
280
|
+
// these figures feed was made from clips decoded with `-an`.
|
|
281
|
+
assert.equal(parseFfmpegBitrateKbps(stderr), 2248, "the video stream's own bitrate, in kb/s");
|
|
282
|
+
const dimensions = parseFfmpegVideoDimensions(stderr);
|
|
283
|
+
const figures = sourceDecodeCharacteristics({
|
|
284
|
+
width: dimensions.width,
|
|
285
|
+
height: dimensions.height,
|
|
286
|
+
fps: parseFfmpegVideoFps(stderr),
|
|
287
|
+
bitrateKbps: parseFfmpegBitrateKbps(stderr)
|
|
288
|
+
});
|
|
289
|
+
assert.ok(Math.abs(figures.megapixelsPerSecond - (1280 * 720 * 24) / 1e6) < 0.01);
|
|
290
|
+
assert.ok(Math.abs(figures.megabitsPerSecond - 2.248) < 0.001);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("the video stream's bitrate is preferred, and the output's is never read", () => {
|
|
294
|
+
// A real banner states three rates: the container's, the input video
|
|
295
|
+
// stream's, and — below "Stream mapping:" — the one ffmpeg is about to
|
|
296
|
+
// produce. Only the middle one describes the source.
|
|
297
|
+
const banner = [
|
|
298
|
+
" Duration: 01:31:19.00, start: 0.000000, bitrate: 9500 kb/s",
|
|
299
|
+
" Stream #0:0: Video: h264 (High), yuv420p, 1920x1080, 7800 kb/s, 23.98 fps",
|
|
300
|
+
" Stream #0:1: Audio: ac3, 48000 Hz, 5.1, fltp, 640 kb/s",
|
|
301
|
+
"Stream mapping:",
|
|
302
|
+
" Stream #0:0(und): Video: wrapped_avframe, yuv420p, 1920x1080, q=2-31, 200 kb/s, 24 fps"
|
|
303
|
+
].join("\n");
|
|
304
|
+
assert.equal(parseFfmpegBitrateKbps(banner), 7800);
|
|
305
|
+
|
|
306
|
+
// No per-stream rate: the container's stands in, as it always did.
|
|
307
|
+
const noStreamRate = [
|
|
308
|
+
" Duration: 00:10:00.00, bitrate: 4200 kb/s",
|
|
309
|
+
" Stream #0:0: Video: hevc (Main 10), yuv420p10le, 1920x1080, 23.98 fps"
|
|
310
|
+
].join("\n");
|
|
311
|
+
assert.equal(parseFfmpegBitrateKbps(noStreamRate), 4200);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a banner with no bitrate reads as no bitrate, not as zero", () => {
|
|
315
|
+
assert.equal(parseFfmpegBitrateKbps("Duration: 00:01:00.00, start: 0.000000\n"), null);
|
|
316
|
+
assert.equal(parseFfmpegBitrateKbps(""), null);
|
|
317
|
+
assert.equal(parseFfmpegBitrateKbps("Duration: 00:01:00.00, start: 0.000, bitrate: 8000 kb/s"), 8000);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the source's decode figures come off the probe, or not at all", () => {
|
|
321
|
+
assert.deepEqual(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 }), {
|
|
322
|
+
megapixelsPerSecond: (1920 * 1080 * 24) / 1e6,
|
|
323
|
+
megabitsPerSecond: 8
|
|
324
|
+
});
|
|
325
|
+
assert.equal(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: null }), null);
|
|
326
|
+
assert.equal(sourceDecodeCharacteristics(null), null);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("the master playlist drops the rungs the host cannot hold", async (t) => {
|
|
330
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "decode-cost-"));
|
|
331
|
+
const manager = new HlsSessionManager({
|
|
332
|
+
enabled: true,
|
|
333
|
+
ffmpegBin: "ffmpeg",
|
|
334
|
+
localBindHost: "127.0.0.1",
|
|
335
|
+
localPort: 9090,
|
|
336
|
+
// A host that can encode a little: 3 Mpx/s, so only the smallest rungs of a
|
|
337
|
+
// 1080p source can be produced faster than they are watched.
|
|
338
|
+
softwarePresetBenchmark: [{ preset: "ultrafast", pixelsPerSec: 3e6 }],
|
|
339
|
+
decodeCostModel: ADDON_HOST_MODEL
|
|
340
|
+
});
|
|
341
|
+
t.after(async () => {
|
|
342
|
+
await manager.disposeAll();
|
|
343
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const session = {
|
|
347
|
+
id: "cccccccc-dddd-eeee-ffff-000000000000",
|
|
348
|
+
dirPath,
|
|
349
|
+
state: "ready",
|
|
350
|
+
fileName: "video.mkv",
|
|
351
|
+
startedAt: Date.now(),
|
|
352
|
+
lastAccessedAt: Date.now(),
|
|
353
|
+
ffmpeg: null,
|
|
354
|
+
lastError: "",
|
|
355
|
+
consumers: new Set(),
|
|
356
|
+
segmentFormat: fmp4Format,
|
|
357
|
+
transcodeVideo: false,
|
|
358
|
+
transcodeAudio: true,
|
|
359
|
+
audioTrackIndex: 0,
|
|
360
|
+
sourceKey: "source-1",
|
|
361
|
+
fileIndex: 0,
|
|
362
|
+
sourceWidth: 1920,
|
|
363
|
+
sourceHeight: 1080,
|
|
364
|
+
sourceDecode: MEASURED_FILM,
|
|
365
|
+
outputFps: 24,
|
|
366
|
+
// A copy can only be cut where the source already has a keyframe, and a
|
|
367
|
+
// master is offered only when that grid is real.
|
|
368
|
+
cutGrid: "keyframe",
|
|
369
|
+
encodeWidth: 0,
|
|
370
|
+
encodeHeight: 0,
|
|
371
|
+
usesExplicitCuts: true,
|
|
372
|
+
useSyntheticPlaylist: true,
|
|
373
|
+
playlistText: "#EXTM3U\n",
|
|
374
|
+
segmentBoundaries: Array.from({ length: 101 }, (_, index) => index * 4),
|
|
375
|
+
segmentCount: 100,
|
|
376
|
+
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
377
|
+
};
|
|
378
|
+
manager.sessionsById.set(session.id, session);
|
|
379
|
+
|
|
380
|
+
assert.equal(
|
|
381
|
+
manager.buildMasterPlaylist(session.id),
|
|
382
|
+
null,
|
|
383
|
+
"every rung under the copy runs below realtime here, so there is nothing to switch to"
|
|
384
|
+
);
|
|
385
|
+
assert.deepEqual(
|
|
386
|
+
manager.offeredHeights(session),
|
|
387
|
+
[1080],
|
|
388
|
+
"and the list the browser is given says the same, since both come from one answer"
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// A host with a little more encoder keeps the rungs it can actually hold. A
|
|
392
|
+
// second session, because the answer is settled once per session.
|
|
393
|
+
manager.softwarePresetBenchmark = [{ preset: "ultrafast", pixelsPerSec: 12e6 }];
|
|
394
|
+
const stronger = { ...session, id: "dddddddd-eeee-ffff-0000-111111111111", offeredHeightsCache: undefined };
|
|
395
|
+
manager.sessionsById.set(stronger.id, stronger);
|
|
396
|
+
const master = manager.buildMasterPlaylist(stronger.id);
|
|
397
|
+
assert.ok(master, "1080p copied plus the one rung this host can produce");
|
|
398
|
+
const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
|
|
399
|
+
assert.deepEqual(heights, [1080, 240]);
|
|
400
|
+
assert.deepEqual(manager.offeredHeights(stronger), heights);
|
|
401
|
+
});
|