@torrent-tv/proxy 2.80.10 → 2.80.11

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.
@@ -1,1209 +1,1222 @@
1
- /**
2
- * @file Quality variants: the master playlist, and what happens when the viewer
3
- * moves between rungs.
4
- *
5
- * What makes a mid-stream change of quality possible at all is that every
6
- * variant is cut at the SAME times, so a segment produced by one encoder can be
7
- * appended where another encoder's would have gone. The last test here pins
8
- * that property at its source; the rest cover the wiring that a route reaches —
9
- * a module test that imports a function directly cannot see a caller that never
10
- * calls it (2.9.124).
11
- */
12
-
13
- import test from "node:test";
14
- import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
15
- import assert from "node:assert/strict";
16
- import { SourceFile } from "../services/source/SourceFile.js";
17
- import { Timeline } from "../services/output/Timeline.js";
18
- import { mkdtemp, rm } from "node:fs/promises";
19
- import os from "node:os";
20
- import path from "node:path";
21
- import {
22
- costKindForSession,
23
- HlsSessionManager
24
- } from "../services/hls-session-manager.js";
25
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
26
- import { computeCutGrid } from "../services/output/cut-grid.js";
27
- import { buildRunCommand, nearestKeyframeAtOrBefore } from "../services/encode/run-command.js";
28
- import { Output } from "../services/output/Output.js";
29
- import { viewerOf } from "../services/viewer/Viewer.js";
30
-
31
- const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
32
- const VARIANT_ID = "11111111-2222-3333-4444-555555555555";
33
- const SECOND_VARIANT_ID = "99999999-8888-7777-6666-555555555555";
34
- const SEGMENT_SECONDS = 4;
35
-
36
- /**
37
- * A session shaped like a live one, without the ffmpeg run behind it.
38
- *
39
- * @param {{ id: string, encodeHeight: number, dirPath: string, transcodeVideo?: boolean }} params
40
- * @returns {object}
41
- */
42
- function fakeSession({ id, encodeHeight, dirPath, transcodeVideo = true }) {
43
- return {
44
- id,
45
- dirPath,
46
- // Where this file is cut, held by the file. A fixture that stated it
47
- // on the session was describing what production no longer does.
48
- timeline: new Timeline({
49
- boundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
50
- cutGrid: "uniform"
51
- }),
52
- state: "ready",
53
- // The file's own facts, which is where the size and what decoding it costs
54
- // both come from: 1080p24 at 8 Mbit/s, the field file of 2026-08-17. A
55
- // fixture that assigned a cost object instead was stating an answer the
56
- // file now derives, so nothing it said reached the arithmetic.
57
- file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }).learn({
58
- width: 1920,
59
- height: 1080,
60
- fps: 24,
61
- bitrateKbps: 8000
62
- }),
63
- // An ordinary session reads its own file, and its sound is inside it. The
64
- // three differ only for a soundtrack shipped as a file of its own.
65
- get inputFile() { return this.file; },
66
- get audioFile() { return this.file; },
67
- startedAt: Date.now(),
68
- createEntryMs: Date.now(),
69
- lastAccessedAt: Date.now(),
70
- ffmpeg: null,
71
- lastError: "",
72
- consumers: new Set(),
73
- segmentFormat: fmp4Format,
74
- transcodeVideo,
75
- transcodeAudio: true,
76
- audioTrackIndex: 0,
77
- // The shape this output is encoded AS, decided once for the output rather
78
- // than once per session.
79
- output: new Output({ encodeWidth: 0, encodeHeight, outputFps: 24, softwarePreset: null, applyTonemap: false }),
80
- encodeRunGeneration: 0,
81
- lastRestartAt: 0,
82
- failedStartAt: -1,
83
- failedStartCount: 0,
84
- seekSettleTimer: null,
85
- seekTarget: null,
86
- waitEpoch: 0,
87
- viewers: new Map(),
88
- usesExplicitCuts: false,
89
- useSyntheticPlaylist: true,
90
- playlistText: "#EXTM3U\n",
91
- segmentCount: 100,
92
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
93
- };
94
- }
95
-
96
-
97
- /**
98
- * @returns {Promise<{ manager: HlsSessionManager, base: object, dirPath: string }>}
99
- */
100
- async function managerWithBase() {
101
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "quality-variants-"));
102
- const manager = new HlsSessionManager({
103
- enabled: true,
104
- ffmpegBin: "ffmpeg",
105
- localBindHost: "127.0.0.1",
106
- localPort: 9090
107
- });
108
- // 812p is what a viewport-sized budget actually produces — deliberately not a
109
- // ladder rung, because that is the case the master has to carry.
110
- const base = fakeSession({ id: BASE_ID, encodeHeight: 812, dirPath });
111
- manager.sessionsById.set(BASE_ID, base);
112
- return { manager, base, dirPath };
113
- }
114
-
115
- test("the master offers every rung, the session's own height among them", async (t) => {
116
- const { manager, dirPath } = await managerWithBase();
117
- t.after(async () => {
118
- await manager.disposeAll();
119
- await rm(dirPath, { recursive: true, force: true });
120
- });
121
-
122
- const master = manager.buildMasterPlaylist(BASE_ID);
123
-
124
- assert.ok(master, "a re-encoded 1080p source has rungs to choose between");
125
- const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
126
- assert.deepEqual(
127
- heights,
128
- [1080, 812, 720, 540, 480, 360, 240],
129
- "the source height, the height already being encoded, and the rungs below it, largest first"
130
- );
131
- assert.match(master, /^#EXT-X-VERSION:7$/m, "the version the segment format requires");
132
- assert.match(master, /RESOLUTION=1280x720/, "each variant states the size it decodes to");
133
- assert.ok(
134
- !master.includes("2160"),
135
- "a rung above the source would be upscaling — invented detail at a higher cost than the source"
136
- );
137
- });
138
-
139
- test("audio is published once for the file, and every rung points at it", async (t) => {
140
- const { manager, base, dirPath } = await managerWithBase();
141
- t.after(async () => {
142
- await manager.disposeAll();
143
- await rm(dirPath, { recursive: true, force: true });
144
- });
145
- // The inventory the plan already probed the same list the browser's audio
146
- // menu is built from.
147
- manager.getCachedAudioTracks = () => [
148
- { index: 0, language: "rus", title: "Дубляж", isDefault: true },
149
- { index: 1, language: "eng", title: "", isDefault: false }
150
- ];
151
- // Settled at creation in production; set here directly, since this test
152
- // builds its session by hand.
153
- base.audioSeparate = true;
154
- base.audioTrackIndex = 1;
155
-
156
- const master = manager.buildMasterPlaylist(BASE_ID);
157
-
158
- assert.match(
159
- master,
160
- /#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Дубляж",LANGUAGE="ru",AUTOSELECT=YES,DEFAULT=NO,URI="a\/0\/index\.m3u8"/,
161
- "a track with a title is named by it"
162
- );
163
- assert.match(
164
- master,
165
- /#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="eng",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=YES,URI="a\/1\/index\.m3u8"/,
166
- "the track the session was created with is the default one, and an untitled track is named by its language"
167
- );
168
- const streams = [...master.matchAll(/^#EXT-X-STREAM-INF:.*$/gm)].map((match) => match[0]);
169
- assert.equal(streams.length, 7, "every rung");
170
- assert.ok(
171
- streams.every((line) => line.includes('AUDIO="aud"')),
172
- "each rung plays with the shared audio rather than carrying its own"
173
- );
174
- });
175
-
176
- test("a session that did not ask for renditions gets audio in its stream, as before", async (t) => {
177
- const { manager, dirPath } = await managerWithBase();
178
- t.after(async () => {
179
- await manager.disposeAll();
180
- await rm(dirPath, { recursive: true, force: true });
181
- });
182
- manager.getCachedAudioTracks = () => [{ index: 0, language: "rus", title: "", isDefault: true }];
183
-
184
- const master = manager.buildMasterPlaylist(BASE_ID);
185
-
186
- assert.ok(!master.includes("EXT-X-MEDIA"), "a browser that does not know about renditions is not sent any");
187
- assert.ok(!master.includes("AUDIO="), "and its rungs still carry their own audio");
188
- });
189
-
190
- test("a copied video is offered variants when its cut grid is real", async (t) => {
191
- const { manager, base, dirPath } = await managerWithBase();
192
- t.after(async () => {
193
- await manager.disposeAll();
194
- await rm(dirPath, { recursive: true, force: true });
195
- });
196
- // A copy is cut at the source's own keyframes — it has no other choice. A
197
- // re-encoded rung CAN be cut there too, by being told those times, and then
198
- // its segments cover the same spans and can stand in the copy's place.
199
- base.transcodeVideo = false;
200
- base.timeline = new Timeline({ boundaries: base.timeline?.boundaries ?? [], cutGrid: "keyframe" });
201
-
202
- const master = manager.buildMasterPlaylist(BASE_ID);
203
-
204
- assert.ok(master, "the obstacle was never the encoder, it was the cut points");
205
- assert.match(master, /^v\/1080\/index\.m3u8$/m, "the copy itself is the top rung — no encoder, no cost");
206
- assert.match(master, /^v\/540\/index\.m3u8$/m);
207
- });
208
-
209
- test("a copied video with no readable keyframe index is offered nothing", async (t) => {
210
- const { manager, base, dirPath } = await managerWithBase();
211
- t.after(async () => {
212
- await manager.disposeAll();
213
- await rm(dirPath, { recursive: true, force: true });
214
- });
215
- // Its playlist claims an even grid that ffmpeg does not cut on. Aligning a
216
- // rung to that is aligning it to a fiction.
217
- base.transcodeVideo = false;
218
- base.timeline = new Timeline({ boundaries: base.timeline?.boundaries ?? [], cutGrid: "uniform" });
219
-
220
- assert.equal(manager.buildMasterPlaylist(BASE_ID), null);
221
- });
222
-
223
- test("the session's own height resolves to the session itself", async (t) => {
224
- const { manager, dirPath } = await managerWithBase();
225
- t.after(async () => {
226
- await manager.disposeAll();
227
- await rm(dirPath, { recursive: true, force: true });
228
- });
229
-
230
- assert.deepEqual(
231
- await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
232
- { sessionId: BASE_ID },
233
- "an encoder is already producing this height; making a second one would be a cold start for nothing"
234
- );
235
- });
236
-
237
- test("a height the master does not offer is refused", async (t) => {
238
- const { manager, dirPath } = await managerWithBase();
239
- t.after(async () => {
240
- await manager.disposeAll();
241
- await rm(dirPath, { recursive: true, force: true });
242
- });
243
-
244
- assert.deepEqual(
245
- await manager.resolveVariantFile(BASE_ID, 999, "index.m3u8"),
246
- { sessionId: null },
247
- "honouring an arbitrary height would let a client start encoder runs at will"
248
- );
249
- assert.deepEqual(
250
- await manager.resolveVariantFile(BASE_ID, 540, "master.m3u8"),
251
- { sessionId: null },
252
- "a master under a variant would describe variants of a variant"
253
- );
254
- });
255
-
256
- test("a variant's playlist is answered without starting an encoder for it", async (t) => {
257
- const { manager, base, dirPath } = await managerWithBase();
258
- t.after(async () => {
259
- await manager.disposeAll();
260
- await rm(dirPath, { recursive: true, force: true });
261
- });
262
-
263
- const resolved = await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
264
-
265
- assert.deepEqual(
266
- resolved,
267
- { sessionId: BASE_ID },
268
- "every variant of a file has the same media playlist — that is what makes them interchangeable"
269
- );
270
- assert.equal(
271
- base.file.stepHeights.size,
272
- 0,
273
- "the player fetches a level's playlist to decide with, and may never switch to it"
274
- );
275
- });
276
-
277
- test("a segment request hands the encoder to the variant the viewer moved to", async (t) => {
278
- const { manager, base, dirPath } = await managerWithBase();
279
- t.after(async () => {
280
- await manager.disposeAll();
281
- await rm(dirPath, { recursive: true, force: true });
282
- });
283
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
284
- variant.variantHeight = 540;
285
- // A step of the picture: made as one, same file, and a height of its own.
286
- variant.isStep = true;
287
- manager.sessionsById.set(VARIANT_ID, variant);
288
- base.file.stepHeights.set(540, 540);
289
- // The viewer is a hundred seconds in, and the base is the one encoding.
290
- base.lastRequestedSegment = 25;
291
- const encoder = fakeEncoder();
292
- startRunOn(base, { process: encoder });
293
- const served = await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4");
294
-
295
- assert.equal(served.sessionId, VARIANT_ID, "the file must be served from the variant, not the base");
296
- assert.equal(base.activeVariantId, VARIANT_ID, "the variant the viewer is watching is the active one");
297
- assert.equal(
298
- encoder.signals.join(","),
299
- "SIGTERM",
300
- "the rung nobody is watching must not go on using the host's one encoder"
301
- );
302
- assert.equal([...base.runs][0]?.process ?? null, null, "a deliberate stop must not read as a run that died");
303
- assert.equal(
304
- variant.seekTarget,
305
- 24,
306
- "a segment request steers nothing, so the variant has to be pointed at the viewer explicitly " +
307
- "(one segment back, for the preceding keyframe)"
308
- );
309
- });
310
-
311
- test("a rung is placed where the player asked it for, not where the other rung had read to", async (t) => {
312
- const { manager, base, dirPath } = await managerWithBase();
313
- t.after(async () => {
314
- await manager.disposeAll();
315
- await rm(dirPath, { recursive: true, force: true });
316
- });
317
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
318
- variant.variantHeight = 540;
319
- // A step of the picture: made as one, same file, and a height of its own.
320
- variant.isStep = true;
321
- manager.sessionsById.set(VARIANT_ID, variant);
322
- base.file.stepHeights.set(540, 540);
323
- startRunOn(base, { process: fakeEncoder() });
324
- // The rung being left had read fourteen segments further than the picture had
325
- // played an encoder running at several times realtime fills the buffer far
326
- // ahead. Measured 2026-08-11: 56 s of gap, and using the read head placed the
327
- // new run past everything the player then asked for, which no request could
328
- // ever be answered from.
329
- base.lastRequestedSegment = 70;
330
- base.furthestViewerSeconds = 280;
331
-
332
- await manager.resolveVariantFile(BASE_ID, 540, "segment-00056.mp4");
333
-
334
- assert.equal(
335
- variant.seekTarget,
336
- 55,
337
- "the segment the player asked this rung for is where it must begin (one back for the keyframe)"
338
- );
339
- });
340
-
341
- test("warming a rung prepares it without taking the encoder from the one on screen", async (t) => {
342
- const { manager, base, dirPath } = await managerWithBase();
343
- t.after(async () => {
344
- await manager.disposeAll();
345
- await rm(dirPath, { recursive: true, force: true });
346
- });
347
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
348
- variant.variantHeight = 540;
349
- // A step of the picture: made as one, same file, and a height of its own.
350
- variant.isStep = true;
351
- manager.sessionsById.set(VARIANT_ID, variant);
352
- base.file.stepHeights.set(540, 540);
353
- const encoder = fakeEncoder();
354
- startRunOn(base, { process: encoder });
355
- const prepared = await manager.prepareVariant(BASE_ID, 540, 240);
356
-
357
- assert.deepEqual(
358
- prepared,
359
- { sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
360
- "the caller is told which segment to wait for — 240 s on a four-second grid"
361
- );
362
- assert.equal(variant.seekTarget, 59, "the rung is pointed at the switch position, one back for the keyframe");
363
- assert.equal(base.activeVariantId, undefined, "nothing has switched yet");
364
- assert.equal([...base.runs][0]?.process, encoder, "the picture on screen keeps its encoder until the player actually moves");
365
- assert.deepEqual(encoder.signals, [], "stopping it here is what would put the spinner back");
366
- });
367
-
368
- test("a rung warmed at the playhead survives the switch that lands just ahead of it", async (t) => {
369
- const { manager, base, dirPath } = await managerWithBase();
370
- t.after(async () => {
371
- await manager.disposeAll();
372
- await rm(dirPath, { recursive: true, force: true });
373
- });
374
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
375
- variant.variantHeight = 540;
376
- // A step of the picture: made as one, same file, and a height of its own.
377
- variant.isStep = true;
378
- manager.sessionsById.set(VARIANT_ID, variant);
379
- base.file.stepHeights.set(540, 540);
380
- startRunOn(base, { process: fakeEncoder() });
381
- // Warmed AT THE PLAYHEAD (240 s = segment #60), which is what the browser
382
- // sends from server 0.10.0 onwards, and the run is alive and has produced a
383
- // few segments past it.
384
- await manager.prepareVariant(BASE_ID, 540, 240);
385
-
386
- startRunOn(variant, { from: 59, process: fakeEncoder() });
387
- variant.progress = { ...variant.progress, processedSeconds: 268 };
388
- variant.seekTarget = null;
389
- variant.seekSettleTimer = null;
390
-
391
- // hls.js flushes from the fragment after the one holding
392
- // `currentTime + fetchdelay`, so its first request for the new rung is the
393
- // playhead plus up to one fragment here #61 against a run that began at
394
- // #59. Warming at the END OF THE BUFFER instead put the run tens of seconds
395
- // AHEAD of this request, which the proxy then read as a seek backwards:
396
- // measured 2026-08-14, that killed a run holding 21.8 s of encoded output.
397
- await manager.resolveVariantFile(BASE_ID, 540, "segment-00061.mp4");
398
-
399
- assert.equal(base.activeVariantId, VARIANT_ID, "the viewer has moved to this rung");
400
- assert.equal(
401
- variant.seekTarget,
402
- null,
403
- "the request is inside the warmed run, so nothing is repositioned and the warm-up is kept"
404
- );
405
- assert.equal([...variant.runs][0].from, 59, "the run still begins where it was warmed");
406
- });
407
-
408
- test("a rung warmed PAST the switch is repositioned, which is what warming late costs", async (t) => {
409
- const { manager, base, dirPath } = await managerWithBase();
410
- t.after(async () => {
411
- await manager.disposeAll();
412
- await rm(dirPath, { recursive: true, force: true });
413
- });
414
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
415
- variant.variantHeight = 540;
416
- // A step of the picture: made as one, same file, and a height of its own.
417
- variant.isStep = true;
418
- manager.sessionsById.set(VARIANT_ID, variant);
419
- base.file.stepHeights.set(540, 540);
420
- startRunOn(base, { process: fakeEncoder() });
421
- // The same session, warmed where the BUFFER ended rather than where the
422
- // picture was — 60 s further on, which is an ordinary cushion. This is what
423
- // server 0.9.3 sent and 0.11.0 stopped sending.
424
- await manager.prepareVariant(BASE_ID, 540, 300);
425
- startRunOn(variant, { from: 74, process: fakeEncoder() });
426
- variant.progress = { ...variant.progress, processedSeconds: 310 };
427
- variant.seekTarget = null;
428
- variant.seekSettleTimer = null;
429
-
430
- // hls.js still lands near the playhead, so the request is far BEHIND the
431
- // warmed run: the proxy reads it as a seek backwards and starts again, and
432
- // everything the warm-up produced is thrown away. Measured in the field
433
- // 2026-08-14 as 21.8 s of encoded output destroyed by the act of using it.
434
- await manager.resolveVariantFile(BASE_ID, 540, "segment-00061.mp4");
435
-
436
- assert.equal(variant.seekTarget, 60, "the run is moved back to where the player actually asked");
437
- });
438
-
439
- test("the rung on screen fetching its own segments does not cancel a warm-up", async (t) => {
440
- const { manager, base, dirPath } = await managerWithBase();
441
- t.after(async () => {
442
- await manager.disposeAll();
443
- await rm(dirPath, { recursive: true, force: true });
444
- });
445
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
446
- variant.variantHeight = 540;
447
- // A step of the picture: made as one, same file, and a height of its own.
448
- variant.isStep = true;
449
- manager.sessionsById.set(VARIANT_ID, variant);
450
- base.file.stepHeights.set(540, 540);
451
- const warmedEncoder = fakeEncoder();
452
- startRunOn(variant, { process: warmedEncoder });
453
- startRunOn(base, { process: fakeEncoder() });
454
- await manager.prepareVariant(BASE_ID, 540, 100);
455
-
456
- // The viewer has not moved: the rung they are watching goes on asking for its
457
- // own segments, every few seconds, for as long as they watch.
458
- await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4");
459
- await manager.resolveVariantFile(BASE_ID, 812, "segment-00027.mp4");
460
-
461
- // Kept per viewer, and this one is the unnamed viewer of a transport that
462
- // carries no consumer id.
463
- assert.equal(
464
- base.viewers.get("")?.warmingVariantId ?? null,
465
- VARIANT_ID,
466
- "the rung being prepared is still being prepared"
467
- );
468
- assert.deepEqual(
469
- warmedEncoder.signals,
470
- [],
471
- "cancelling it here left the viewer waiting out the whole warm-up for a segment nobody was making"
472
- );
473
- });
474
-
475
- test("warming the height the base itself serves still points it at the switch", async (t) => {
476
- const { manager, base, dirPath } = await managerWithBase();
477
- t.after(async () => {
478
- await manager.disposeAll();
479
- await rm(dirPath, { recursive: true, force: true });
480
- });
481
- // The viewer is on another rung; the base is parked where they left it, with
482
- // its encoder stopped. Warming its height must bring it back.
483
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
484
- variant.variantHeight = 540;
485
- manager.sessionsById.set(VARIANT_ID, variant);
486
- base.file.stepHeights.set(540, 540);
487
- base.activeVariantId = VARIANT_ID;
488
- base.runs = new Set();
489
-
490
- await manager.prepareVariant(BASE_ID, 812, 400);
491
-
492
- // 400 s falls on the boundary between #99 and #100, and a run starts one
493
- // segment back so the player has the preceding keyframe.
494
- assert.equal(
495
- base.seekTarget,
496
- 98,
497
- "the base is parked at the start, so warming its height must reposition it like any other rung"
498
- );
499
- });
500
-
501
- test("the viewer's position is kept current by the segments they ask for", async (t) => {
502
- const { manager, base, dirPath } = await managerWithBase();
503
- t.after(async () => {
504
- await manager.disposeAll();
505
- await rm(dirPath, { recursive: true, force: true });
506
- });
507
- // A seek an hour ago is the only thing that ever wrote this field, and
508
- // playback reports no position at all. Read as it stood, a quality change
509
- // would place the new variant's encode run back at the seek — and since a
510
- // segment request steers nothing, the segments the player then asks for would
511
- // never be produced by anyone.
512
- base.furthestViewerSeconds = 40;
513
-
514
- await manager.getFileStream(BASE_ID, "segment-00090.mp4", { requestSeq: 1 });
515
-
516
- assert.equal(
517
- base.furthestViewerSeconds,
518
- 360,
519
- "a request for segment #90 of a four-second grid says where the viewer is now"
520
- );
521
- });
522
-
523
- test("a playlist or an init segment does not move the encoder", async (t) => {
524
- const { manager, base, dirPath } = await managerWithBase();
525
- t.after(async () => {
526
- await manager.disposeAll();
527
- await rm(dirPath, { recursive: true, force: true });
528
- });
529
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
530
- manager.sessionsById.set(VARIANT_ID, variant);
531
- base.file.stepHeights.set(540, 540);
532
- startRunOn(base, { process: fakeEncoder() });
533
- base.file.stepHeights.set(540, 540);
534
- await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
535
- await manager.resolveVariantFile(BASE_ID, 540, "init.mp4");
536
-
537
- assert.notEqual(
538
- base.activeVariantId,
539
- VARIANT_ID,
540
- "hls.js fetches a level's playlist and init to decide with, and may never switch to it"
541
- );
542
- assert.ok([...base.runs][0]?.process, "the stream on screen must keep its encoder while the player is only looking");
543
- });
544
-
545
- test("the name of a variant is fixed, whatever its encode is later set to", async (t) => {
546
- const { manager, base, dirPath } = await managerWithBase();
547
- t.after(async () => {
548
- await manager.disposeAll();
549
- await rm(dirPath, { recursive: true, force: true });
550
- });
551
- // The player fetched the master once and addresses this variant as 812p for
552
- // the rest of the session, so the name must not follow the encode. Nothing in
553
- // the proxy changes `encodeHeight` mid-session any more a change of size is
554
- // a change of variant now — but the name and the encode are still two
555
- // different things, and the addressing depends on their staying so.
556
- assert.equal(manager.liveOutputs.variantHeightOf(base), 812);
557
- base.encodeHeight = 540;
558
-
559
- assert.equal(
560
- manager.liveOutputs.variantHeightOf(base),
561
- 812,
562
- "the name stays; renaming it would leave the player addressing a variant nobody answers for"
563
- );
564
- assert.deepEqual(
565
- await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
566
- { sessionId: BASE_ID },
567
- "a second session at a height the host has already failed to manage is the opposite of what a step is for"
568
- );
569
- });
570
-
571
- test("the cut grid follows the grid asked for, not who produces the frames", () => {
572
- // Why a segment from one encoder can stand where another's would have: both
573
- // are cut at the same times. Which times is a property of the SESSION — the
574
- // even grid, or the source's own keyframes — and it must not be re-derived
575
- // from whether the video is copied, because a variant of a copied stream is
576
- // re-encoded and still has to land on the copy's cuts.
577
- const shape = { durationSeconds: 100, segDur: SEGMENT_SECONDS, startTime: 0 };
578
- const keyframeTimes = [0, 3.1, 9.7, 14.2, 21.5, 40, 61.25];
579
-
580
- const even = computeCutGrid({ ...shape, useKeyframeGrid: false, keyframeTimes });
581
- assert.equal(even.boundaries[1], SEGMENT_SECONDS, "the even grid ignores the source's keyframes");
582
- assert.equal(even.boundaries.at(-1), 100);
583
-
584
- const source = computeCutGrid({ ...shape, useKeyframeGrid: true, keyframeTimes });
585
- assert.deepEqual(
586
- source.boundaries,
587
- [0, 9.7, 14.2, 21.5, 40, 61.25, 100],
588
- "the source's own keyframes, kept only where they are at least a segment apart"
589
- );
590
-
591
- // The one that matters: a copy and a re-encoded rung of it, given the same
592
- // grid, produce the SAME table. Segment N then covers the same span in both,
593
- // which is what lets one stand where the other would have.
594
- assert.deepEqual(
595
- computeCutGrid({ ...shape, useKeyframeGrid: true, keyframeTimes }).boundaries,
596
- source.boundaries,
597
- "a variant inherits the grid, so its boundaries are the same values"
598
- );
599
- // And with no index there is nothing to align to — the even grid, whoever asks.
600
- assert.deepEqual(
601
- computeCutGrid({ ...shape, useKeyframeGrid: true, keyframeTimes: null }).boundaries,
602
- even.boundaries,
603
- "no keyframes means no keyframe grid, however the caller asks"
604
- );
605
- });
606
-
607
- test("a boundary carries the keyframe it is, exactly as the container stated it", () => {
608
- // The fault that stopped a viewing on 2026-09-05. A boundary is kept on the
609
- // player's clock, `keyframe - startTime`, rounded; the seek used to add the
610
- // start time back and look the result up in the container's list by value.
611
- // The round trip is lossy — 26.234 - 0.083 + 0.083 is 26.233999999999998 —
612
- // and "the keyframe at or before that" is then the PREVIOUS one, a whole
613
- // keyframe interval earlier.
614
- const startTime = 0.083;
615
- const keyframeTimes = [];
616
- for (let index = 0; index < 60; index += 1) {
617
- keyframeTimes.push(Number((startTime + index * 8.717).toFixed(3)));
618
- }
619
- const grid = computeCutGrid({
620
- useKeyframeGrid: true, durationSeconds: 500, segDur: SEGMENT_SECONDS, keyframeTimes, startTime
621
- });
622
-
623
- assert.equal(grid.sourceTimes[0], startTime, "the first cut is the start of the file");
624
- assert.equal(grid.boundaries.length, grid.sourceTimes.length, "an index must name both clocks");
625
- for (let index = 1; index < grid.boundaries.length - 1; index += 1) {
626
- assert.ok(
627
- keyframeTimes.includes(grid.sourceTimes[index]),
628
- `cut #${index} carries ${grid.sourceTimes[index]}, which the container never stated`
629
- );
630
- }
631
-
632
- // What the round trip does, so the reason this table exists cannot be
633
- // mistaken for caution: at least one boundary fails to find itself.
634
- const lost = grid.boundaries
635
- .slice(1, -1)
636
- .filter((published, at) => nearestKeyframeAtOrBefore(keyframeTimes, published + startTime)
637
- !== grid.sourceTimes[at + 1]);
638
- assert.ok(
639
- lost.length > 0,
640
- "the search agreed everywhere, so this fixture no longer reproduces the fault it was built for"
641
- );
642
- });
643
-
644
- test("a run on the keyframe grid is given no trim to apply", () => {
645
- // The second half of the same fault. That trim is an output-side `-ss`, and
646
- // beside `-copyts` it moves the whole run backwards by its own amount:
647
- // measured 2026-09-06 on a 5 s keyframe interval, a run landing at 15 s and
648
- // asked to trim to the cut at 20 s produced its first file starting at 10 s.
649
- // Every cut of the run inherits it while the numbering, fixed at spawn, does
650
- // not so the files are named for times they do not hold.
651
- const startTime = 0.083;
652
- const keyframeTimes = [];
653
- for (let index = 0; index < 60; index += 1) {
654
- keyframeTimes.push(Number((startTime + index * 8.717).toFixed(3)));
655
- }
656
- const grid = computeCutGrid({
657
- useKeyframeGrid: true, durationSeconds: 500, segDur: SEGMENT_SECONDS, keyframeTimes, startTime
658
- });
659
- const timeline = new Timeline({
660
- boundaries: grid.boundaries, sourceTimes: grid.sourceTimes, cutGrid: "keyframe"
661
- });
662
-
663
- for (let index = 1; index < grid.boundaries.length - 1; index += 1) {
664
- const { args } = buildRunCommand({
665
- file: { keyframeTimes },
666
- inputFile: { startTime },
667
- audioFile: { startTime },
668
- inputUrl: "http://127.0.0.1/stream",
669
- audioInputUrl: "",
670
- outputDir: ".",
671
- timeline,
672
- segmentFormat: fmp4Format,
673
- transcodeVideo: false,
674
- transcodeAudio: true,
675
- audioOnly: false,
676
- audioSeparate: false,
677
- audioSourceTrackIndex: 0,
678
- rateCapKbps: 0,
679
- startIndex: index,
680
- endIndex: index,
681
- videoEncoder: { name: "libx264" },
682
- segmentDurationSec: SEGMENT_SECONDS
683
- });
684
- // Exactly one `-ss`, and it is the input seek: the trim is not there to be
685
- // inherited by the cuts.
686
- assert.equal(
687
- args.filter((one) => one === "-ss").length,
688
- 1,
689
- `run at #${index} was given a trim beside -copyts`
690
- );
691
- const seek = Number(args[args.indexOf("-ss") + 1]);
692
- assert.ok(
693
- Math.abs(seek - grid.sourceTimes[index]) < 0.2,
694
- `run at #${index} seeks to ${seek}, not to its own keyframe ${grid.sourceTimes[index]}`
695
- );
696
- }
697
- });
698
-
699
- test("a rung served by copy stays offered while a re-encoded rung is on screen", async (t) => {
700
- const { manager, base, dirPath } = await managerWithBase();
701
- t.after(async () => {
702
- await manager.disposeAll();
703
- await rm(dirPath, { recursive: true, force: true });
704
- });
705
- // The field case of 2026-08-15: a 1080p source served by COPY, the viewer on
706
- // 240p, and a host too weak to re-encode anything above it.
707
- base.transcodeVideo = false;
708
- base.encodeHeight = 1080;
709
- base.variantHeight = 1080;
710
- // Enough to re-encode 240p (1.67x combined) and nowhere near enough for
711
- // 720p (0.45x) — the field's own shape, where the rung the viewer picked was
712
- // offered and everything between it and the copy was not.
713
- manager.softwarePresetBenchmark = [{ preset: "ultrafast", pixelsPerSec: 12_000_000 }];
714
- manager.decodeCostModel = { pixelTerm: 0.00793, bitrateTerm: 0, constantTerm: 0 };
715
- // What decoding this source costs comes from the file's own facts, stated in
716
- // the fixture: 49.766 Mpx/s at 8 Mbit/s.
717
- assert.ok(base.file.decode, "the fixture must state enough for a decode cost to exist");
718
-
719
- const watching = fakeSession({ id: VARIANT_ID, encodeHeight: 240, dirPath });
720
- watching.variantHeight = 240;
721
- watching.transcodeVideo = true;
722
- // The rung knows the source as well as the base does, because it IS the same
723
- // file. Without that it prices nothing at all every height comes back
724
- // "sustainable" for want of a measurement and the assertion below would
725
- // hold for the wrong reason.
726
- watching.file = base.file;
727
- // A step of the picture: same file, and made as a step.
728
- watching.isStep = true;
729
- manager.sessionsById.set(VARIANT_ID, watching);
730
- base.file.stepHeights.set(240, 240);
731
- base.activeVariantId = VARIANT_ID;
732
-
733
- const offered = manager.offeredHeights(watching);
734
-
735
- assert.ok(
736
- offered.includes(1080),
737
- "the height the source is COPIED at costs no encoder, so no measurement of this host can withdraw it"
738
- );
739
- assert.deepEqual(
740
- offered,
741
- manager.offeredHeights(base),
742
- "one answer for the family: a rung asked while watching another must not disagree with the base"
743
- );
744
- });
745
-
746
- test("a separately published audio track starts where the picture is, from the reported buffer", async (t) => {
747
- const { manager, base, dirPath } = await managerWithBase();
748
- t.after(async () => {
749
- await manager.disposeAll();
750
- await rm(dirPath, { recursive: true, force: true });
751
- });
752
- base.audioSeparate = true;
753
- // Served up to 140 s, and the browser says it holds 40 s ahead of the
754
- // picture — so the viewer is at 100 s, and that, less a segment of margin,
755
- // is where the track has to begin.
756
- base.furthestViewerSeconds = 140;
757
- viewerOf(base, "viewer").netReport = {
758
- linkMbps: 20,
759
- bufferedAheadSec: 40,
760
- positionSeconds: null,
761
- at: Date.now()
762
- };
763
- manager.getCachedAudioTracks = () => [
764
- { index: 0, language: "rus", title: "", isDefault: true },
765
- { index: 1, language: "eng", title: "", isDefault: false }
766
- ];
767
- const created = [];
768
- manager.createOrGetSession = async (params) => {
769
- created.push(params);
770
- const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
771
- rendition.audioOnly = true;
772
- return { sessionId: VARIANT_ID, session: rendition };
773
- };
774
-
775
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
776
-
777
- assert.equal(created.length, 1, "the track's own session was made");
778
- assert.equal(
779
- created[0].startPositionSeconds,
780
- 96,
781
- "140 s served, less the 40 s the player holds, less one segment of margin"
782
- );
783
- });
784
-
785
- test("with two viewers the audio track starts at the EARLIEST picture, not the read head", async (t) => {
786
- const { manager, base, dirPath } = await managerWithBase();
787
- t.after(async () => {
788
- await manager.disposeAll();
789
- await rm(dirPath, { recursive: true, force: true });
790
- });
791
- base.audioSeparate = true;
792
- // A copied picture is one session shared by both of them. The read head is
793
- // the furthest request of EITHER, so it belongs to the viewer in front.
794
- base.furthestViewerSeconds = 140;
795
- viewerOf(base, "ahead").netReport = {
796
- linkMbps: 20,
797
- bufferedAheadSec: 40,
798
- positionSeconds: 100,
799
- at: Date.now()
800
- };
801
- viewerOf(base, "behind").netReport = {
802
- linkMbps: 20,
803
- bufferedAheadSec: 8,
804
- positionSeconds: 40,
805
- at: Date.now()
806
- };
807
- manager.getCachedAudioTracks = () => [
808
- { index: 0, language: "rus", title: "", isDefault: true },
809
- { index: 1, language: "eng", title: "", isDefault: false }
810
- ];
811
- const created = [];
812
- manager.createOrGetSession = async (params) => {
813
- created.push(params);
814
- const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
815
- rendition.audioOnly = true;
816
- return { sessionId: VARIANT_ID, session: rendition };
817
- };
818
-
819
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
820
-
821
- assert.equal(
822
- created[0].startPositionSeconds,
823
- 36,
824
- "the viewer at 40 s, less one segment of margin — a run starting at the leader " +
825
- "has nothing to give the one behind them"
826
- );
827
- });
828
-
829
- test("a position past the read head is clamped rather than acted on", async (t) => {
830
- const { manager, base, dirPath } = await managerWithBase();
831
- t.after(async () => {
832
- await manager.disposeAll();
833
- await rm(dirPath, { recursive: true, force: true });
834
- });
835
- base.audioSeparate = true;
836
- base.furthestViewerSeconds = 140;
837
- // Reports and requests race; a position claiming to be past everything that
838
- // has been asked for would start the run where no request can reach it.
839
- viewerOf(base, "viewer").netReport = {
840
- linkMbps: 20,
841
- bufferedAheadSec: 40,
842
- positionSeconds: 900,
843
- at: Date.now()
844
- };
845
- manager.getCachedAudioTracks = () => [
846
- { index: 0, language: "rus", title: "", isDefault: true },
847
- { index: 1, language: "eng", title: "", isDefault: false }
848
- ];
849
- const created = [];
850
- manager.createOrGetSession = async (params) => {
851
- created.push(params);
852
- const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
853
- rendition.audioOnly = true;
854
- return { sessionId: VARIANT_ID, session: rendition };
855
- };
856
-
857
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
858
-
859
- assert.equal(
860
- created[0].startPositionSeconds,
861
- 136,
862
- "clamped to the read head, less one segment of margin"
863
- );
864
- });
865
-
866
- test("a stale buffer report is not used to place an audio track", async (t) => {
867
- const { manager, base, dirPath } = await managerWithBase();
868
- t.after(async () => {
869
- await manager.disposeAll();
870
- await rm(dirPath, { recursive: true, force: true });
871
- });
872
- base.audioSeparate = true;
873
- base.furthestViewerSeconds = 300;
874
- // Sent a minute ago: the viewer may have seeked anywhere since, so neither
875
- // the buffer nor the position in it says where they are now.
876
- viewerOf(base, "viewer").netReport = {
877
- linkMbps: 20,
878
- bufferedAheadSec: 5,
879
- positionSeconds: 250,
880
- at: Date.now() - 60_000
881
- };
882
- manager.getCachedAudioTracks = () => [
883
- { index: 0, language: "rus", title: "", isDefault: true },
884
- { index: 1, language: "eng", title: "", isDefault: false }
885
- ];
886
- const created = [];
887
- manager.createOrGetSession = async (params) => {
888
- created.push(params);
889
- const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
890
- rendition.audioOnly = true;
891
- return { sessionId: VARIANT_ID, session: rendition };
892
- };
893
-
894
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
895
-
896
- assert.equal(
897
- created[0].startPositionSeconds,
898
- 176,
899
- "the whole look-ahead is subtracted instead — it cannot leave the run ahead of the viewer"
900
- );
901
- });
902
-
903
- test("an audio track is prepared at the position the switch will land on", async (t) => {
904
- const { manager, base, dirPath } = await managerWithBase();
905
- t.after(async () => {
906
- await manager.disposeAll();
907
- await rm(dirPath, { recursive: true, force: true });
908
- });
909
- base.audioSeparate = true;
910
- manager.getCachedAudioTracks = () => [
911
- { index: 0, language: "rus", title: "", isDefault: true },
912
- { index: 1, language: "eng", title: "", isDefault: false }
913
- ];
914
- const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
915
- rendition.audioOnly = true;
916
- rendition.audioTrackIndex = 1;
917
- rendition.transcodeAudio = true;
918
- // A soundtrack of THIS picture: the same file, published on its own. Found by
919
- // what it is — there is no list of ids to put it on.
920
- rendition.file = base.file;
921
- startRunOn(rendition, { process: fakeEncoder() });
922
- manager.sessionsById.set(VARIANT_ID, rendition);
923
-
924
- const prepared = await manager.prepareAudioTrack(BASE_ID, 1, 240);
925
-
926
- assert.deepEqual(
927
- prepared,
928
- { sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
929
- "the caller is told which segment to wait for — 240 s on a four-second grid"
930
- );
931
- assert.equal(
932
- rendition.seekTarget,
933
- 59,
934
- "and the track is pointed at the switch position, one back for the preceding keyframe"
935
- );
936
- });
937
-
938
- test("a reading from a soundtrack is priced as one", () => {
939
- // The fault this pins: an audio rendition reached no learner at all. One
940
- // guard refused renditions a reading, and the only call that would have
941
- // priced one sat behind a second guard its caller had already made — so a
942
- // soundtrack ran for the whole film and was charged at nothing, which is the
943
- // half of roadmap item 6 that was left owing.
944
- assert.equal(costKindForSession({ audioOnly: true, transcodeVideo: false }), "audio");
945
- assert.equal(
946
- costKindForSession({ audioOnly: true, transcodeVideo: true }),
947
- "audio",
948
- "a rendition carries no picture, whatever the flag it inherited says"
949
- );
950
- assert.equal(costKindForSession({ transcodeVideo: true }), "decode");
951
- assert.equal(costKindForSession({ transcodeVideo: false }), "copy");
952
- });
953
-
954
- test("a quality step being warmed is not refused by its own cost", async (t) => {
955
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "warm-cost-"));
956
- // The addon host's own figures, so the arithmetic below is the field's and
957
- // not an invention. Without a benchmark the check returns every height
958
- // untouched and a test over it would pass while proving nothing.
959
- const manager = new HlsSessionManager({
960
- enabled: true,
961
- ffmpegBin: "ffmpeg",
962
- localBindHost: "127.0.0.1",
963
- localPort: 9090,
964
- softwarePresetBenchmark: [
965
- { preset: "fast", pixelsPerSec: 17.0e6 },
966
- { preset: "ultrafast", pixelsPerSec: 67.5e6 }
967
- ],
968
- decodeCostModel: { pixelTerm: 0.007742, bitrateTerm: 0, constantTerm: 0 }
969
- });
970
- t.after(async () => {
971
- await manager.disposeAll();
972
- await rm(dirPath, { recursive: true, force: true });
973
- });
974
- // A copied 1080p picture, and a 240p step warmed beside it for a switch —
975
- // which is what every quality change does, two encoders on purpose.
976
- const base = fakeSession({ id: BASE_ID, encodeHeight: 0, dirPath, transcodeVideo: false });
977
- base.variantHeight = 1080;
978
- // What this source costs to decode comes from the file's own facts, stated in
979
- // the fixture: 1080p24 at 8 Mbit/s, the field file. Without them nothing can
980
- // be priced and every height comes back offerable for want of a measurement,
981
- // which would make the assertion hold for the wrong reason.
982
- assert.ok(base.file.decode, "the fixture must state enough for a decode cost to exist");
983
- const warming = fakeSession({ id: VARIANT_ID, encodeHeight: 240, dirPath });
984
- warming.variantHeight = 240;
985
- // Two sessions of ONE file share its facts, which is the whole point of the
986
- // file being an object: a step warmed beside the picture is not a second file.
987
- warming.file = base.file;
988
- base.file.stepHeights.set(240, 240);
989
- // A step of the picture: same file, and a height of its own.
990
- // Running, and running well: it says of itself that it holds twice realtime,
991
- // i.e. half a second of work per second of video.
992
- startRunOn(warming, { process: fakeEncoder() });
993
- warming.lastAloneSpeed = 2;
994
- manager.sessionsById.set(BASE_ID, base);
995
- manager.sessionsById.set(VARIANT_ID, warming);
996
-
997
- const offered = manager.offeredHeights(base);
998
-
999
- assert.ok(
1000
- offered.includes(240),
1001
- "charged its own cost while being judged, the step the viewer just asked for is dropped from the " +
1002
- "offer by the act of warming it and its next segment 404s on a stream that is playing"
1003
- );
1004
- });
1005
-
1006
- test("the master survives a live offer that has collapsed to one rung", async (t) => {
1007
- // The field case of 2026-08-18, in the smallest form that reproduces it: a
1008
- // host too slow for any re-encoded rung, and a swarm whose interruptions
1009
- // demand far more than realtime. The live offer then holds only the height an
1010
- // encoder is already producing — and until this test existed, that made
1011
- // `buildMasterPlaylist` answer null and the route answer 404 to a session
1012
- // that had just published the address.
1013
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "quality-variants-collapse-"));
1014
- const manager = new HlsSessionManager({
1015
- enabled: true,
1016
- ffmpegBin: "ffmpeg",
1017
- localBindHost: "127.0.0.1",
1018
- localPort: 9090,
1019
- // A megapixel a second: every rung below the source costs more than the
1020
- // machine has.
1021
- softwarePresetBenchmark: [{ preset: "veryfast", pixelsPerSec: 1_000_000 }],
1022
- decodeCostModel: { pixelTerm: 0.01, bitrateTerm: 0, constantTerm: 0 }
1023
- });
1024
- const base = fakeSession({ id: BASE_ID, encodeHeight: 812, dirPath });
1025
- // A thicker source than the fixture's, stated as the file's own bitrate
1026
- // rather than as a cost object the file now derives.
1027
- base.file.learn({ width: 1920, height: 1080, fps: 24, bitrateKbps: 10_000 });
1028
- // What this file's own reader measured: a step must run at eight times
1029
- // realtime to survive this swarm.
1030
- base.supplyFigures = { requiredSpeed: 8 };
1031
- manager.sessionsById.set(BASE_ID, base);
1032
- t.after(async () => {
1033
- await manager.disposeAll();
1034
- await rm(dirPath, { recursive: true, force: true });
1035
- });
1036
-
1037
- assert.deepEqual(
1038
- manager.offeredHeights(base),
1039
- [812],
1040
- "the live judgement is unchanged: nothing but the running height is worth offering"
1041
- );
1042
-
1043
- const master = manager.buildMasterPlaylist(BASE_ID);
1044
-
1045
- assert.ok(master, "the master is a published document, not a live figure");
1046
- const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
1047
- assert.deepEqual(
1048
- heights,
1049
- [1080, 812, 720, 540, 480, 360, 240],
1050
- "every rung that can be spliced onto this cut grid stays addressable"
1051
- );
1052
- });
1053
-
1054
- test("two heights that clamp onto one picture share a single encoder", async (t) => {
1055
- const { manager, base, dirPath } = await managerWithBase();
1056
- const spawnedDirs = [];
1057
- t.after(async () => {
1058
- await manager.disposeAll();
1059
- await Promise.all(spawnedDirs.map((dir) => rm(dir, { recursive: true, force: true })));
1060
- await rm(dirPath, { recursive: true, force: true });
1061
- });
1062
-
1063
- // A host that can hold exactly one rung. Whatever height is asked for, the
1064
- // clamp inside createOrGetSession lands the encode on 240p — which is what a
1065
- // CM4 did on 2026-08-28, turning a 360p and a 540p request into two more
1066
- // ffmpeg processes making the same 426x240 picture as the 240p one.
1067
- const madeIds = [VARIANT_ID, SECOND_VARIANT_ID];
1068
- const created = [];
1069
- manager.createOrGetSession = async (params) => {
1070
- const id = madeIds[created.length];
1071
- created.push(params);
1072
- const variantDir = await mkdtemp(path.join(os.tmpdir(), "quality-variants-clamped-"));
1073
- spawnedDirs.push(variantDir);
1074
- const variant = fakeSession({ id, encodeHeight: 240, dirPath: variantDir });
1075
- variant.consumers = new Set([params.consumerId]);
1076
- manager.sessionsById.set(id, variant);
1077
- return variant;
1078
- };
1079
-
1080
- const asked360 = await manager.resolveVariantSession(BASE_ID, 360);
1081
- const asked540 = await manager.resolveVariantSession(BASE_ID, 540);
1082
-
1083
- assert.equal(asked360.id, VARIANT_ID, "the first request made the encoder");
1084
- assert.equal(
1085
- asked540.id,
1086
- VARIANT_ID,
1087
- "and the second is served by it, because it produces the very same picture"
1088
- );
1089
- assert.equal(
1090
- manager.sessionsById.has(SECOND_VARIANT_ID),
1091
- false,
1092
- "the duplicate was let go as soon as its size was known"
1093
- );
1094
- assert.equal(
1095
- base.file.stepHeights.get(540),
1096
- 240,
1097
- "540p is recorded as answered with the 240p the machine can hold"
1098
- );
1099
-
1100
- const askedAgain = await manager.resolveVariantSession(BASE_ID, 540);
1101
-
1102
- assert.equal(askedAgain.id, VARIANT_ID);
1103
- assert.equal(created.length, 2, "the third request created nothing at all");
1104
- });
1105
-
1106
- test("rungs that really do differ keep their own encoders", async (t) => {
1107
- const { manager, base, dirPath } = await managerWithBase();
1108
- const spawnedDirs = [];
1109
- t.after(async () => {
1110
- await manager.disposeAll();
1111
- await Promise.all(spawnedDirs.map((dir) => rm(dir, { recursive: true, force: true })));
1112
- await rm(dirPath, { recursive: true, force: true });
1113
- });
1114
-
1115
- // A host with room: each request is encoded at the height it named, so
1116
- // nothing may be merged. The sharing above must not become "one encoder for
1117
- // every rung".
1118
- const madeIds = [VARIANT_ID, SECOND_VARIANT_ID];
1119
- const created = [];
1120
- manager.createOrGetSession = async (params) => {
1121
- const id = madeIds[created.length];
1122
- created.push(params);
1123
- const variantDir = await mkdtemp(path.join(os.tmpdir(), "quality-variants-distinct-"));
1124
- spawnedDirs.push(variantDir);
1125
- const variant = fakeSession({ id, encodeHeight: params.targetHeight, dirPath: variantDir });
1126
- variant.consumers = new Set([params.consumerId]);
1127
- manager.sessionsById.set(id, variant);
1128
- return variant;
1129
- };
1130
-
1131
- const asked360 = await manager.resolveVariantSession(BASE_ID, 360);
1132
- const asked540 = await manager.resolveVariantSession(BASE_ID, 540);
1133
-
1134
- assert.equal(asked360.id, VARIANT_ID);
1135
- assert.equal(asked540.id, SECOND_VARIANT_ID, "two different pictures, two encoders");
1136
- assert.equal(base.file.stepHeights.get(360), 360);
1137
- assert.equal(base.file.stepHeights.get(540), 540);
1138
- });
1139
-
1140
- test("a rung is never served from the COPY, whatever height the copy happens to be", async (t) => {
1141
- const { manager, base, dirPath } = await managerWithBase();
1142
- const spawnedDirs = [];
1143
- t.after(async () => {
1144
- await manager.disposeAll();
1145
- await Promise.all(spawnedDirs.map((dir) => rm(dir, { recursive: true, force: true })));
1146
- await rm(dirPath, { recursive: true, force: true });
1147
- });
1148
-
1149
- // The base is a COPY at 240p — no encoder behind it, and it is the one rung
1150
- // this host can always serve. Handing it to a request for a re-encoded 360p
1151
- // would give away exactly that, and a viewer stranded on a rung the machine
1152
- // cannot hold would have nowhere left to return to.
1153
- base.transcodeVideo = false;
1154
- base.encodeHeight = 240;
1155
- base.variantHeight = 240;
1156
- const created = [];
1157
- manager.createOrGetSession = async (params) => {
1158
- created.push(params);
1159
- const variantDir = await mkdtemp(path.join(os.tmpdir(), "quality-variants-copy-"));
1160
- spawnedDirs.push(variantDir);
1161
- const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 240, dirPath: variantDir });
1162
- variant.consumers = new Set([params.consumerId]);
1163
- manager.sessionsById.set(VARIANT_ID, variant);
1164
- return variant;
1165
- };
1166
-
1167
- const asked = await manager.resolveVariantSession(BASE_ID, 360);
1168
-
1169
- assert.equal(asked.id, VARIANT_ID, "the re-encoded rung is its own session, not the copy");
1170
- });
1171
-
1172
- test("a file opened at a position starts its sound THERE, not a look-ahead earlier", async (t) => {
1173
- const { manager, base, dirPath } = await managerWithBase();
1174
- t.after(async () => {
1175
- await manager.disposeAll();
1176
- await rm(dirPath, { recursive: true, force: true });
1177
- });
1178
- base.audioSeparate = true;
1179
- // The state at the instant a page is opened at a position: nothing seeked,
1180
- // no segment served, no report from anybody. The read head is then not a
1181
- // request edge — it is where the session was made — and a browser that has
1182
- // just opened holds no buffer at all.
1183
- base.furthestViewerSeconds = null;
1184
- base.lastRequestedSegment = null;
1185
- base.viewers.clear();
1186
- base.progress.startPositionSeconds = 588;
1187
- manager.getCachedAudioTracks = () => [
1188
- { index: 0, language: "rus", title: "", isDefault: true },
1189
- { index: 1, language: "eng", title: "", isDefault: false }
1190
- ];
1191
- const created = [];
1192
- manager.createOrGetSession = async (params) => {
1193
- created.push(params);
1194
- const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
1195
- rendition.audioOnly = true;
1196
- return { sessionId: VARIANT_ID, session: rendition };
1197
- };
1198
-
1199
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
1200
-
1201
- // Field 2026-08-31: this answered 460 for a page opened at 588 — the whole
1202
- // 120 s look-ahead subtracted from a buffer that did not exist — and the
1203
- // segment the viewer needed took 38.8 s to appear against the picture's 8.4 s.
1204
- assert.equal(
1205
- created[0].startPositionSeconds,
1206
- 584,
1207
- "where the viewer opened, less one segment of margin, and nothing else"
1208
- );
1209
- });
1
+ /**
2
+ * @file Quality variants: the master playlist, and what happens when the viewer
3
+ * moves between rungs.
4
+ *
5
+ * What makes a mid-stream change of quality possible at all is that every
6
+ * variant is cut at the SAME times, so a segment produced by one encoder can be
7
+ * appended where another encoder's would have gone. The last test here pins
8
+ * that property at its source; the rest cover the wiring that a route reaches —
9
+ * a module test that imports a function directly cannot see a caller that never
10
+ * calls it (2.9.124).
11
+ */
12
+
13
+ import test from "node:test";
14
+ import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
15
+ import assert from "node:assert/strict";
16
+ import { SourceFile } from "../services/source/SourceFile.js";
17
+ import { Timeline } from "../services/output/Timeline.js";
18
+ import { mkdtemp, rm } from "node:fs/promises";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import {
22
+ costKindForSession,
23
+ HlsSessionManager
24
+ } from "../services/hls-session-manager.js";
25
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
26
+ import { computeCutGrid } from "../services/output/cut-grid.js";
27
+ import { buildRunCommand, nearestKeyframeAtOrBefore } from "../services/encode/run-command.js";
28
+ import { Output } from "../services/output/Output.js";
29
+ import { viewerOf } from "../services/viewer/Viewer.js";
30
+
31
+ const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
32
+ const VARIANT_ID = "11111111-2222-3333-4444-555555555555";
33
+ const SECOND_VARIANT_ID = "99999999-8888-7777-6666-555555555555";
34
+ const SEGMENT_SECONDS = 4;
35
+
36
+ /**
37
+ * A session shaped like a live one, without the ffmpeg run behind it.
38
+ *
39
+ * @param {{ id: string, encodeHeight: number, dirPath: string, transcodeVideo?: boolean }} params
40
+ * @returns {object}
41
+ */
42
+ function fakeSession({ id, encodeHeight, dirPath, transcodeVideo = true }) {
43
+ return {
44
+ id,
45
+ dirPath,
46
+ // Where this file is cut, held by the file. A fixture that stated it
47
+ // on the session was describing what production no longer does.
48
+ timeline: new Timeline({
49
+ boundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
50
+ cutGrid: "uniform"
51
+ }),
52
+ state: "ready",
53
+ // The file's own facts, which is where the size and what decoding it costs
54
+ // both come from: 1080p24 at 8 Mbit/s, the field file of 2026-08-17. A
55
+ // fixture that assigned a cost object instead was stating an answer the
56
+ // file now derives, so nothing it said reached the arithmetic.
57
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }).learn({
58
+ width: 1920,
59
+ height: 1080,
60
+ fps: 24,
61
+ bitrateKbps: 8000
62
+ }),
63
+ // An ordinary session reads its own file, and its sound is inside it. The
64
+ // three differ only for a soundtrack shipped as a file of its own.
65
+ get inputFile() { return this.file; },
66
+ get audioFile() { return this.file; },
67
+ startedAt: Date.now(),
68
+ createEntryMs: Date.now(),
69
+ lastAccessedAt: Date.now(),
70
+ ffmpeg: null,
71
+ lastError: "",
72
+ consumers: new Set(),
73
+ segmentFormat: fmp4Format,
74
+ transcodeVideo,
75
+ transcodeAudio: true,
76
+ audioTrackIndex: 0,
77
+ // The shape this output is encoded AS, decided once for the output rather
78
+ // than once per session.
79
+ output: new Output({ encodeWidth: 0, encodeHeight, outputFps: 24, softwarePreset: null, applyTonemap: false }),
80
+ encodeRunGeneration: 0,
81
+ failedStartAt: -1,
82
+ failedStartCount: 0,
83
+ waitEpoch: 0,
84
+ viewers: new Map(),
85
+ usesExplicitCuts: false,
86
+ useSyntheticPlaylist: true,
87
+ playlistText: "#EXTM3U\n",
88
+ segmentCount: 100,
89
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
90
+ };
91
+ }
92
+
93
+
94
+ /**
95
+ * @returns {Promise<{ manager: HlsSessionManager, base: object, dirPath: string }>}
96
+ */
97
+ async function managerWithBase() {
98
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "quality-variants-"));
99
+ const manager = new HlsSessionManager({
100
+ enabled: true,
101
+ ffmpegBin: "ffmpeg",
102
+ localBindHost: "127.0.0.1",
103
+ localPort: 9090
104
+ });
105
+ // 812p is what a viewport-sized budget actually produces — deliberately not a
106
+ // ladder rung, because that is the case the master has to carry.
107
+ const base = fakeSession({ id: BASE_ID, encodeHeight: 812, dirPath });
108
+ manager.sessionsById.set(BASE_ID, base);
109
+ return { manager, base, dirPath };
110
+ }
111
+
112
+ test("the master offers every rung, the session's own height among them", async (t) => {
113
+ const { manager, dirPath } = await managerWithBase();
114
+ t.after(async () => {
115
+ await manager.disposeAll();
116
+ await rm(dirPath, { recursive: true, force: true });
117
+ });
118
+
119
+ const master = manager.buildMasterPlaylist(BASE_ID);
120
+
121
+ assert.ok(master, "a re-encoded 1080p source has rungs to choose between");
122
+ const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
123
+ assert.deepEqual(
124
+ heights,
125
+ [1080, 812, 720, 540, 480, 360, 240],
126
+ "the source height, the height already being encoded, and the rungs below it, largest first"
127
+ );
128
+ assert.match(master, /^#EXT-X-VERSION:7$/m, "the version the segment format requires");
129
+ assert.match(master, /RESOLUTION=1280x720/, "each variant states the size it decodes to");
130
+ assert.ok(
131
+ !master.includes("2160"),
132
+ "a rung above the source would be upscaling — invented detail at a higher cost than the source"
133
+ );
134
+ });
135
+
136
+ test("audio is published once for the file, and every rung points at it", async (t) => {
137
+ const { manager, base, dirPath } = await managerWithBase();
138
+ t.after(async () => {
139
+ await manager.disposeAll();
140
+ await rm(dirPath, { recursive: true, force: true });
141
+ });
142
+ // The inventory the plan already probed — the same list the browser's audio
143
+ // menu is built from.
144
+ manager.getCachedAudioTracks = () => [
145
+ { index: 0, language: "rus", title: "Дубляж", isDefault: true },
146
+ { index: 1, language: "eng", title: "", isDefault: false }
147
+ ];
148
+ // Settled at creation in production; set here directly, since this test
149
+ // builds its session by hand.
150
+ base.audioSeparate = true;
151
+ base.audioTrackIndex = 1;
152
+
153
+ const master = manager.buildMasterPlaylist(BASE_ID);
154
+
155
+ assert.match(
156
+ master,
157
+ /#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Дубляж",LANGUAGE="ru",AUTOSELECT=YES,DEFAULT=NO,URI="a\/0\/index\.m3u8"/,
158
+ "a track with a title is named by it"
159
+ );
160
+ assert.match(
161
+ master,
162
+ /#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="eng",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=YES,URI="a\/1\/index\.m3u8"/,
163
+ "the track the session was created with is the default one, and an untitled track is named by its language"
164
+ );
165
+ const streams = [...master.matchAll(/^#EXT-X-STREAM-INF:.*$/gm)].map((match) => match[0]);
166
+ assert.equal(streams.length, 7, "every rung");
167
+ assert.ok(
168
+ streams.every((line) => line.includes('AUDIO="aud"')),
169
+ "each rung plays with the shared audio rather than carrying its own"
170
+ );
171
+ });
172
+
173
+ test("a session that did not ask for renditions gets audio in its stream, as before", async (t) => {
174
+ const { manager, dirPath } = await managerWithBase();
175
+ t.after(async () => {
176
+ await manager.disposeAll();
177
+ await rm(dirPath, { recursive: true, force: true });
178
+ });
179
+ manager.getCachedAudioTracks = () => [{ index: 0, language: "rus", title: "", isDefault: true }];
180
+
181
+ const master = manager.buildMasterPlaylist(BASE_ID);
182
+
183
+ assert.ok(!master.includes("EXT-X-MEDIA"), "a browser that does not know about renditions is not sent any");
184
+ assert.ok(!master.includes("AUDIO="), "and its rungs still carry their own audio");
185
+ });
186
+
187
+ test("a copied video is offered variants when its cut grid is real", async (t) => {
188
+ const { manager, base, dirPath } = await managerWithBase();
189
+ t.after(async () => {
190
+ await manager.disposeAll();
191
+ await rm(dirPath, { recursive: true, force: true });
192
+ });
193
+ // A copy is cut at the source's own keyframes — it has no other choice. A
194
+ // re-encoded rung CAN be cut there too, by being told those times, and then
195
+ // its segments cover the same spans and can stand in the copy's place.
196
+ base.transcodeVideo = false;
197
+ base.timeline = new Timeline({ boundaries: base.timeline?.boundaries ?? [], cutGrid: "keyframe" });
198
+
199
+ const master = manager.buildMasterPlaylist(BASE_ID);
200
+
201
+ assert.ok(master, "the obstacle was never the encoder, it was the cut points");
202
+ assert.match(master, /^v\/1080\/index\.m3u8$/m, "the copy itself is the top rung — no encoder, no cost");
203
+ assert.match(master, /^v\/540\/index\.m3u8$/m);
204
+ });
205
+
206
+ test("a copied video with no readable keyframe index is offered nothing", async (t) => {
207
+ const { manager, base, dirPath } = await managerWithBase();
208
+ t.after(async () => {
209
+ await manager.disposeAll();
210
+ await rm(dirPath, { recursive: true, force: true });
211
+ });
212
+ // Its playlist claims an even grid that ffmpeg does not cut on. Aligning a
213
+ // rung to that is aligning it to a fiction.
214
+ base.transcodeVideo = false;
215
+ base.timeline = new Timeline({ boundaries: base.timeline?.boundaries ?? [], cutGrid: "uniform" });
216
+
217
+ assert.equal(manager.buildMasterPlaylist(BASE_ID), null);
218
+ });
219
+
220
+ test("the session's own height resolves to the session itself", async (t) => {
221
+ const { manager, dirPath } = await managerWithBase();
222
+ t.after(async () => {
223
+ await manager.disposeAll();
224
+ await rm(dirPath, { recursive: true, force: true });
225
+ });
226
+
227
+ assert.deepEqual(
228
+ await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
229
+ { sessionId: BASE_ID },
230
+ "an encoder is already producing this height; making a second one would be a cold start for nothing"
231
+ );
232
+ });
233
+
234
+ test("a height the master does not offer is refused", async (t) => {
235
+ const { manager, dirPath } = await managerWithBase();
236
+ t.after(async () => {
237
+ await manager.disposeAll();
238
+ await rm(dirPath, { recursive: true, force: true });
239
+ });
240
+
241
+ assert.deepEqual(
242
+ await manager.resolveVariantFile(BASE_ID, 999, "index.m3u8"),
243
+ { sessionId: null },
244
+ "honouring an arbitrary height would let a client start encoder runs at will"
245
+ );
246
+ assert.deepEqual(
247
+ await manager.resolveVariantFile(BASE_ID, 540, "master.m3u8"),
248
+ { sessionId: null },
249
+ "a master under a variant would describe variants of a variant"
250
+ );
251
+ });
252
+
253
+ test("a variant's playlist is answered without starting an encoder for it", async (t) => {
254
+ const { manager, base, dirPath } = await managerWithBase();
255
+ t.after(async () => {
256
+ await manager.disposeAll();
257
+ await rm(dirPath, { recursive: true, force: true });
258
+ });
259
+
260
+ const resolved = await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
261
+
262
+ assert.deepEqual(
263
+ resolved,
264
+ { sessionId: BASE_ID },
265
+ "every variant of a file has the same media playlist — that is what makes them interchangeable"
266
+ );
267
+ assert.equal(
268
+ base.file.stepHeights.size,
269
+ 0,
270
+ "the player fetches a level's playlist to decide with, and may never switch to it"
271
+ );
272
+ });
273
+
274
+ test("a segment request hands the encoder to the variant the viewer moved to", async (t) => {
275
+ const { manager, base, dirPath } = await managerWithBase();
276
+ t.after(async () => {
277
+ await manager.disposeAll();
278
+ await rm(dirPath, { recursive: true, force: true });
279
+ });
280
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
281
+ variant.variantHeight = 540;
282
+ // A step of the picture: made as one, same file, and a height of its own.
283
+ variant.isStep = true;
284
+ manager.sessionsById.set(VARIANT_ID, variant);
285
+ base.file.stepHeights.set(540, 540);
286
+ // The viewer is a hundred seconds in, and the base is the one encoding.
287
+ base.lastRequestedSegment = 25;
288
+ const encoder = fakeEncoder();
289
+ startRunOn(base, { process: encoder });
290
+ const served = await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4");
291
+
292
+ assert.equal(served.sessionId, VARIANT_ID, "the file must be served from the variant, not the base");
293
+ assert.equal(base.activeVariantId, VARIANT_ID, "the variant the viewer is watching is the active one");
294
+ assert.equal(
295
+ viewerOf(variant, "").positionSeconds(),
296
+ 100,
297
+ "a segment request steers nothing, so where this person stands on the rung is stated outright — " +
298
+ "segment 25 of a four-second grid"
299
+ );
300
+ // AND THE RUNG THEY CAME OFF IS PRODUCING FOR NOBODY, which is one fact and
301
+ // not an act. It used to be stopped from this path by hand, and the plan —
302
+ // handed the whole film's priority map started it again on the very next
303
+ // pass, which this viewer's own move had just triggered.
304
+ assert.equal(
305
+ manager.liveOutputs.watchedBy(base, viewerOf(base, "")),
306
+ false,
307
+ "the picture they stepped off is nobody's now"
308
+ );
309
+ assert.equal(
310
+ manager.liveOutputs.watchedBy(variant, viewerOf(variant, "")),
311
+ true,
312
+ "and the rung they moved to is theirs"
313
+ );
314
+ assert.deepEqual(encoder.signals, [], "stopping it is the plan's, from that fact, and not this path's");
315
+ });
316
+
317
+ test("a rung is placed where the player asked it for, not where the other rung had read to", async (t) => {
318
+ const { manager, base, dirPath } = await managerWithBase();
319
+ t.after(async () => {
320
+ await manager.disposeAll();
321
+ await rm(dirPath, { recursive: true, force: true });
322
+ });
323
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
324
+ variant.variantHeight = 540;
325
+ // A step of the picture: made as one, same file, and a height of its own.
326
+ variant.isStep = true;
327
+ manager.sessionsById.set(VARIANT_ID, variant);
328
+ base.file.stepHeights.set(540, 540);
329
+ startRunOn(base, { process: fakeEncoder() });
330
+ // The rung being left had read fourteen segments further than the picture had
331
+ // played — an encoder running at several times realtime fills the buffer far
332
+ // ahead. Measured 2026-08-11: 56 s of gap, and using the read head placed the
333
+ // new run past everything the player then asked for, which no request could
334
+ // ever be answered from.
335
+ base.lastRequestedSegment = 70;
336
+ base.furthestViewerSeconds = 280;
337
+
338
+ await manager.resolveVariantFile(BASE_ID, 540, "segment-00056.mp4");
339
+
340
+ assert.equal(
341
+ viewerOf(variant, "").positionSeconds(),
342
+ 224,
343
+ "the segment the player asked this rung for is where this person is — 56 on a four-second grid, " +
344
+ "not the read head fourteen segments further on"
345
+ );
346
+ });
347
+
348
+ test("warming a rung prepares it without taking the encoder from the one on screen", async (t) => {
349
+ const { manager, base, dirPath } = await managerWithBase();
350
+ t.after(async () => {
351
+ await manager.disposeAll();
352
+ await rm(dirPath, { recursive: true, force: true });
353
+ });
354
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
355
+ variant.variantHeight = 540;
356
+ // A step of the picture: made as one, same file, and a height of its own.
357
+ variant.isStep = true;
358
+ manager.sessionsById.set(VARIANT_ID, variant);
359
+ base.file.stepHeights.set(540, 540);
360
+ const encoder = fakeEncoder();
361
+ startRunOn(base, { process: encoder });
362
+ const prepared = await manager.prepareVariant(BASE_ID, 540, 240);
363
+
364
+ assert.deepEqual(
365
+ prepared,
366
+ { sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
367
+ "the caller is told which segment to wait for — 240 s on a four-second grid"
368
+ );
369
+ assert.equal(
370
+ viewerOf(variant, "").positionSeconds(),
371
+ 240,
372
+ "the rung being warmed is told where this person is, which is what buys it an encoder"
373
+ );
374
+ assert.equal(base.activeVariantId, undefined, "nothing has switched yet");
375
+ assert.equal([...base.runs][0]?.process, encoder, "the picture on screen keeps its encoder until the player actually moves");
376
+ assert.deepEqual(encoder.signals, [], "stopping it here is what would put the spinner back");
377
+ });
378
+
379
+ test("a rung warmed at the playhead survives the switch that lands just ahead of it", async (t) => {
380
+ const { manager, base, dirPath } = await managerWithBase();
381
+ t.after(async () => {
382
+ await manager.disposeAll();
383
+ await rm(dirPath, { recursive: true, force: true });
384
+ });
385
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
386
+ variant.variantHeight = 540;
387
+ // A step of the picture: made as one, same file, and a height of its own.
388
+ variant.isStep = true;
389
+ manager.sessionsById.set(VARIANT_ID, variant);
390
+ base.file.stepHeights.set(540, 540);
391
+ startRunOn(base, { process: fakeEncoder() });
392
+ // Warmed AT THE PLAYHEAD (240 s = segment #60), which is what the browser
393
+ // sends from server 0.10.0 onwards, and the run is alive and has produced a
394
+ // few segments past it.
395
+ await manager.prepareVariant(BASE_ID, 540, 240);
396
+
397
+ startRunOn(variant, { from: 59, process: fakeEncoder() });
398
+ variant.progress = { ...variant.progress, processedSeconds: 268 };
399
+ variant.seekTarget = null;
400
+ variant.seekSettleTimer = null;
401
+
402
+ // hls.js flushes from the fragment after the one holding
403
+ // `currentTime + fetchdelay`, so its first request for the new rung is the
404
+ // playhead plus up to one fragment — here #61 against a run that began at
405
+ // #59. Warming at the END OF THE BUFFER instead put the run tens of seconds
406
+ // AHEAD of this request, which the proxy then read as a seek backwards:
407
+ // measured 2026-08-14, that killed a run holding 21.8 s of encoded output.
408
+ await manager.resolveVariantFile(BASE_ID, 540, "segment-00061.mp4");
409
+
410
+ assert.equal(base.activeVariantId, VARIANT_ID, "the viewer has moved to this rung");
411
+ assert.equal(
412
+ variant.seekTarget,
413
+ null,
414
+ "the request is inside the warmed run, so nothing is repositioned and the warm-up is kept"
415
+ );
416
+ assert.equal([...variant.runs][0].from, 59, "the run still begins where it was warmed");
417
+ });
418
+
419
+ test("a rung warmed PAST the switch is repositioned, which is what warming late costs", async (t) => {
420
+ const { manager, base, dirPath } = await managerWithBase();
421
+ t.after(async () => {
422
+ await manager.disposeAll();
423
+ await rm(dirPath, { recursive: true, force: true });
424
+ });
425
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
426
+ variant.variantHeight = 540;
427
+ // A step of the picture: made as one, same file, and a height of its own.
428
+ variant.isStep = true;
429
+ manager.sessionsById.set(VARIANT_ID, variant);
430
+ base.file.stepHeights.set(540, 540);
431
+ startRunOn(base, { process: fakeEncoder() });
432
+ // The same session, warmed where the BUFFER ended rather than where the
433
+ // picture was 60 s further on, which is an ordinary cushion. This is what
434
+ // server 0.9.3 sent and 0.11.0 stopped sending.
435
+ await manager.prepareVariant(BASE_ID, 540, 300);
436
+ startRunOn(variant, { from: 74, process: fakeEncoder() });
437
+ variant.progress = { ...variant.progress, processedSeconds: 310 };
438
+
439
+ // hls.js still lands near the playhead, so the request is far BEHIND the
440
+ // warmed run: the proxy reads it as a seek backwards and starts again, and
441
+ // everything the warm-up produced is thrown away. Measured in the field
442
+ // 2026-08-14 as 21.8 s of encoded output destroyed by the act of using it.
443
+ await manager.resolveVariantFile(BASE_ID, 540, "segment-00061.mp4");
444
+
445
+ assert.equal(
446
+ viewerOf(variant, "").positionSeconds(),
447
+ 244,
448
+ "where the player actually asked, not where the warm-up had run to"
449
+ );
450
+ });
451
+
452
+ test("the rung on screen fetching its own segments does not cancel a warm-up", async (t) => {
453
+ const { manager, base, dirPath } = await managerWithBase();
454
+ t.after(async () => {
455
+ await manager.disposeAll();
456
+ await rm(dirPath, { recursive: true, force: true });
457
+ });
458
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
459
+ variant.variantHeight = 540;
460
+ // A step of the picture: made as one, same file, and a height of its own.
461
+ variant.isStep = true;
462
+ manager.sessionsById.set(VARIANT_ID, variant);
463
+ base.file.stepHeights.set(540, 540);
464
+ const warmedEncoder = fakeEncoder();
465
+ startRunOn(variant, { process: warmedEncoder });
466
+ startRunOn(base, { process: fakeEncoder() });
467
+ await manager.prepareVariant(BASE_ID, 540, 100);
468
+
469
+ // The viewer has not moved: the rung they are watching goes on asking for its
470
+ // own segments, every few seconds, for as long as they watch.
471
+ await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4");
472
+ await manager.resolveVariantFile(BASE_ID, 812, "segment-00027.mp4");
473
+
474
+ // Kept per viewer, and this one is the unnamed viewer of a transport that
475
+ // carries no consumer id.
476
+ assert.equal(
477
+ base.viewers.get("")?.warmingVariantId ?? null,
478
+ VARIANT_ID,
479
+ "the rung being prepared is still being prepared"
480
+ );
481
+ assert.deepEqual(
482
+ warmedEncoder.signals,
483
+ [],
484
+ "cancelling it here left the viewer waiting out the whole warm-up for a segment nobody was making"
485
+ );
486
+ });
487
+
488
+ test("warming the height the base itself serves still points it at the switch", async (t) => {
489
+ const { manager, base, dirPath } = await managerWithBase();
490
+ t.after(async () => {
491
+ await manager.disposeAll();
492
+ await rm(dirPath, { recursive: true, force: true });
493
+ });
494
+ // The viewer is on another rung; the base is parked where they left it, with
495
+ // its encoder stopped. Warming its height must bring it back.
496
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
497
+ variant.variantHeight = 540;
498
+ manager.sessionsById.set(VARIANT_ID, variant);
499
+ base.file.stepHeights.set(540, 540);
500
+ base.activeVariantId = VARIANT_ID;
501
+ base.runs = new Set();
502
+
503
+ await manager.prepareVariant(BASE_ID, 812, 400);
504
+
505
+ // 400 s falls on the boundary between #99 and #100, and a run starts one
506
+ // segment back so the player has the preceding keyframe.
507
+ assert.equal(
508
+ viewerOf(base, "").positionSeconds(),
509
+ 396,
510
+ "the base is parked where they left it, so warming its height says where they are like any other rung"
511
+ );
512
+ });
513
+
514
+ test("the viewer's position is kept current by the segments they ask for", async (t) => {
515
+ const { manager, base, dirPath } = await managerWithBase();
516
+ t.after(async () => {
517
+ await manager.disposeAll();
518
+ await rm(dirPath, { recursive: true, force: true });
519
+ });
520
+ // A seek an hour ago is the only thing that ever wrote this field, and
521
+ // playback reports no position at all. Read as it stood, a quality change
522
+ // would place the new variant's encode run back at the seek — and since a
523
+ // segment request steers nothing, the segments the player then asks for would
524
+ // never be produced by anyone.
525
+ base.furthestViewerSeconds = 40;
526
+
527
+ await manager.getFileStream(BASE_ID, "segment-00090.mp4", { requestSeq: 1 });
528
+
529
+ assert.equal(
530
+ base.furthestViewerSeconds,
531
+ 360,
532
+ "a request for segment #90 of a four-second grid says where the viewer is now"
533
+ );
534
+ });
535
+
536
+ test("a playlist or an init segment does not move the encoder", async (t) => {
537
+ const { manager, base, dirPath } = await managerWithBase();
538
+ t.after(async () => {
539
+ await manager.disposeAll();
540
+ await rm(dirPath, { recursive: true, force: true });
541
+ });
542
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
543
+ manager.sessionsById.set(VARIANT_ID, variant);
544
+ base.file.stepHeights.set(540, 540);
545
+ startRunOn(base, { process: fakeEncoder() });
546
+ base.file.stepHeights.set(540, 540);
547
+ await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
548
+ await manager.resolveVariantFile(BASE_ID, 540, "init.mp4");
549
+
550
+ assert.notEqual(
551
+ base.activeVariantId,
552
+ VARIANT_ID,
553
+ "hls.js fetches a level's playlist and init to decide with, and may never switch to it"
554
+ );
555
+ assert.ok([...base.runs][0]?.process, "the stream on screen must keep its encoder while the player is only looking");
556
+ });
557
+
558
+ test("the name of a variant is fixed, whatever its encode is later set to", async (t) => {
559
+ const { manager, base, dirPath } = await managerWithBase();
560
+ t.after(async () => {
561
+ await manager.disposeAll();
562
+ await rm(dirPath, { recursive: true, force: true });
563
+ });
564
+ // The player fetched the master once and addresses this variant as 812p for
565
+ // the rest of the session, so the name must not follow the encode. Nothing in
566
+ // the proxy changes `encodeHeight` mid-session any more — a change of size is
567
+ // a change of variant now but the name and the encode are still two
568
+ // different things, and the addressing depends on their staying so.
569
+ assert.equal(manager.liveOutputs.variantHeightOf(base), 812);
570
+ base.encodeHeight = 540;
571
+
572
+ assert.equal(
573
+ manager.liveOutputs.variantHeightOf(base),
574
+ 812,
575
+ "the name stays; renaming it would leave the player addressing a variant nobody answers for"
576
+ );
577
+ assert.deepEqual(
578
+ await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
579
+ { sessionId: BASE_ID },
580
+ "a second session at a height the host has already failed to manage is the opposite of what a step is for"
581
+ );
582
+ });
583
+
584
+ test("the cut grid follows the grid asked for, not who produces the frames", () => {
585
+ // Why a segment from one encoder can stand where another's would have: both
586
+ // are cut at the same times. Which times is a property of the SESSION — the
587
+ // even grid, or the source's own keyframes — and it must not be re-derived
588
+ // from whether the video is copied, because a variant of a copied stream is
589
+ // re-encoded and still has to land on the copy's cuts.
590
+ const shape = { durationSeconds: 100, segDur: SEGMENT_SECONDS, startTime: 0 };
591
+ const keyframeTimes = [0, 3.1, 9.7, 14.2, 21.5, 40, 61.25];
592
+
593
+ const even = computeCutGrid({ ...shape, useKeyframeGrid: false, keyframeTimes });
594
+ assert.equal(even.boundaries[1], SEGMENT_SECONDS, "the even grid ignores the source's keyframes");
595
+ assert.equal(even.boundaries.at(-1), 100);
596
+
597
+ const source = computeCutGrid({ ...shape, useKeyframeGrid: true, keyframeTimes });
598
+ assert.deepEqual(
599
+ source.boundaries,
600
+ [0, 9.7, 14.2, 21.5, 40, 61.25, 100],
601
+ "the source's own keyframes, kept only where they are at least a segment apart"
602
+ );
603
+
604
+ // The one that matters: a copy and a re-encoded rung of it, given the same
605
+ // grid, produce the SAME table. Segment N then covers the same span in both,
606
+ // which is what lets one stand where the other would have.
607
+ assert.deepEqual(
608
+ computeCutGrid({ ...shape, useKeyframeGrid: true, keyframeTimes }).boundaries,
609
+ source.boundaries,
610
+ "a variant inherits the grid, so its boundaries are the same values"
611
+ );
612
+ // And with no index there is nothing to align to — the even grid, whoever asks.
613
+ assert.deepEqual(
614
+ computeCutGrid({ ...shape, useKeyframeGrid: true, keyframeTimes: null }).boundaries,
615
+ even.boundaries,
616
+ "no keyframes means no keyframe grid, however the caller asks"
617
+ );
618
+ });
619
+
620
+ test("a boundary carries the keyframe it is, exactly as the container stated it", () => {
621
+ // The fault that stopped a viewing on 2026-09-05. A boundary is kept on the
622
+ // player's clock, `keyframe - startTime`, rounded; the seek used to add the
623
+ // start time back and look the result up in the container's list by value.
624
+ // The round trip is lossy — 26.234 - 0.083 + 0.083 is 26.233999999999998
625
+ // and "the keyframe at or before that" is then the PREVIOUS one, a whole
626
+ // keyframe interval earlier.
627
+ const startTime = 0.083;
628
+ const keyframeTimes = [];
629
+ for (let index = 0; index < 60; index += 1) {
630
+ keyframeTimes.push(Number((startTime + index * 8.717).toFixed(3)));
631
+ }
632
+ const grid = computeCutGrid({
633
+ useKeyframeGrid: true, durationSeconds: 500, segDur: SEGMENT_SECONDS, keyframeTimes, startTime
634
+ });
635
+
636
+ assert.equal(grid.sourceTimes[0], startTime, "the first cut is the start of the file");
637
+ assert.equal(grid.boundaries.length, grid.sourceTimes.length, "an index must name both clocks");
638
+ for (let index = 1; index < grid.boundaries.length - 1; index += 1) {
639
+ assert.ok(
640
+ keyframeTimes.includes(grid.sourceTimes[index]),
641
+ `cut #${index} carries ${grid.sourceTimes[index]}, which the container never stated`
642
+ );
643
+ }
644
+
645
+ // What the round trip does, so the reason this table exists cannot be
646
+ // mistaken for caution: at least one boundary fails to find itself.
647
+ const lost = grid.boundaries
648
+ .slice(1, -1)
649
+ .filter((published, at) => nearestKeyframeAtOrBefore(keyframeTimes, published + startTime)
650
+ !== grid.sourceTimes[at + 1]);
651
+ assert.ok(
652
+ lost.length > 0,
653
+ "the search agreed everywhere, so this fixture no longer reproduces the fault it was built for"
654
+ );
655
+ });
656
+
657
+ test("a run on the keyframe grid is given no trim to apply", () => {
658
+ // The second half of the same fault. That trim is an output-side `-ss`, and
659
+ // beside `-copyts` it moves the whole run backwards by its own amount:
660
+ // measured 2026-09-06 on a 5 s keyframe interval, a run landing at 15 s and
661
+ // asked to trim to the cut at 20 s produced its first file starting at 10 s.
662
+ // Every cut of the run inherits it while the numbering, fixed at spawn, does
663
+ // not so the files are named for times they do not hold.
664
+ const startTime = 0.083;
665
+ const keyframeTimes = [];
666
+ for (let index = 0; index < 60; index += 1) {
667
+ keyframeTimes.push(Number((startTime + index * 8.717).toFixed(3)));
668
+ }
669
+ const grid = computeCutGrid({
670
+ useKeyframeGrid: true, durationSeconds: 500, segDur: SEGMENT_SECONDS, keyframeTimes, startTime
671
+ });
672
+ const timeline = new Timeline({
673
+ boundaries: grid.boundaries, sourceTimes: grid.sourceTimes, cutGrid: "keyframe"
674
+ });
675
+
676
+ for (let index = 1; index < grid.boundaries.length - 1; index += 1) {
677
+ const { args } = buildRunCommand({
678
+ file: { keyframeTimes },
679
+ inputFile: { startTime },
680
+ audioFile: { startTime },
681
+ inputUrl: "http://127.0.0.1/stream",
682
+ audioInputUrl: "",
683
+ outputDir: ".",
684
+ timeline,
685
+ segmentFormat: fmp4Format,
686
+ transcodeVideo: false,
687
+ transcodeAudio: true,
688
+ audioOnly: false,
689
+ audioSeparate: false,
690
+ audioSourceTrackIndex: 0,
691
+ rateCapKbps: 0,
692
+ startIndex: index,
693
+ endIndex: index,
694
+ videoEncoder: { name: "libx264" },
695
+ segmentDurationSec: SEGMENT_SECONDS
696
+ });
697
+ // Exactly one `-ss`, and it is the input seek: the trim is not there to be
698
+ // inherited by the cuts.
699
+ assert.equal(
700
+ args.filter((one) => one === "-ss").length,
701
+ 1,
702
+ `run at #${index} was given a trim beside -copyts`
703
+ );
704
+ const seek = Number(args[args.indexOf("-ss") + 1]);
705
+ assert.ok(
706
+ Math.abs(seek - grid.sourceTimes[index]) < 0.2,
707
+ `run at #${index} seeks to ${seek}, not to its own keyframe ${grid.sourceTimes[index]}`
708
+ );
709
+ }
710
+ });
711
+
712
+ test("a rung served by copy stays offered while a re-encoded rung is on screen", async (t) => {
713
+ const { manager, base, dirPath } = await managerWithBase();
714
+ t.after(async () => {
715
+ await manager.disposeAll();
716
+ await rm(dirPath, { recursive: true, force: true });
717
+ });
718
+ // The field case of 2026-08-15: a 1080p source served by COPY, the viewer on
719
+ // 240p, and a host too weak to re-encode anything above it.
720
+ base.transcodeVideo = false;
721
+ base.encodeHeight = 1080;
722
+ base.variantHeight = 1080;
723
+ // Enough to re-encode 240p (1.67x combined) and nowhere near enough for
724
+ // 720p (0.45x) the field's own shape, where the rung the viewer picked was
725
+ // offered and everything between it and the copy was not.
726
+ manager.softwarePresetBenchmark = [{ preset: "ultrafast", pixelsPerSec: 12_000_000 }];
727
+ manager.decodeCostModel = { pixelTerm: 0.00793, bitrateTerm: 0, constantTerm: 0 };
728
+ // What decoding this source costs comes from the file's own facts, stated in
729
+ // the fixture: 49.766 Mpx/s at 8 Mbit/s.
730
+ assert.ok(base.file.decode, "the fixture must state enough for a decode cost to exist");
731
+
732
+ const watching = fakeSession({ id: VARIANT_ID, encodeHeight: 240, dirPath });
733
+ watching.variantHeight = 240;
734
+ watching.transcodeVideo = true;
735
+ // The rung knows the source as well as the base does, because it IS the same
736
+ // file. Without that it prices nothing at all — every height comes back
737
+ // "sustainable" for want of a measurement and the assertion below would
738
+ // hold for the wrong reason.
739
+ watching.file = base.file;
740
+ // A step of the picture: same file, and made as a step.
741
+ watching.isStep = true;
742
+ manager.sessionsById.set(VARIANT_ID, watching);
743
+ base.file.stepHeights.set(240, 240);
744
+ base.activeVariantId = VARIANT_ID;
745
+
746
+ const offered = manager.offeredHeights(watching);
747
+
748
+ assert.ok(
749
+ offered.includes(1080),
750
+ "the height the source is COPIED at costs no encoder, so no measurement of this host can withdraw it"
751
+ );
752
+ assert.deepEqual(
753
+ offered,
754
+ manager.offeredHeights(base),
755
+ "one answer for the family: a rung asked while watching another must not disagree with the base"
756
+ );
757
+ });
758
+
759
+ test("a separately published audio track starts where the picture is, from the reported buffer", async (t) => {
760
+ const { manager, base, dirPath } = await managerWithBase();
761
+ t.after(async () => {
762
+ await manager.disposeAll();
763
+ await rm(dirPath, { recursive: true, force: true });
764
+ });
765
+ base.audioSeparate = true;
766
+ // Served up to 140 s, and the browser says it holds 40 s ahead of the
767
+ // picture so the viewer is at 100 s, and that, less a segment of margin,
768
+ // is where the track has to begin.
769
+ base.furthestViewerSeconds = 140;
770
+ viewerOf(base, "viewer").netReport = {
771
+ linkMbps: 20,
772
+ bufferedAheadSec: 40,
773
+ positionSeconds: null,
774
+ at: Date.now()
775
+ };
776
+ manager.getCachedAudioTracks = () => [
777
+ { index: 0, language: "rus", title: "", isDefault: true },
778
+ { index: 1, language: "eng", title: "", isDefault: false }
779
+ ];
780
+ const created = [];
781
+ manager.createOrGetSession = async (params) => {
782
+ created.push(params);
783
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
784
+ rendition.audioOnly = true;
785
+ return { sessionId: VARIANT_ID, session: rendition };
786
+ };
787
+
788
+ await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
789
+
790
+ assert.equal(created.length, 1, "the track's own session was made");
791
+ assert.equal(
792
+ created[0].startPositionSeconds,
793
+ 96,
794
+ "140 s served, less the 40 s the player holds, less one segment of margin"
795
+ );
796
+ });
797
+
798
+ test("with two viewers the audio track starts at the EARLIEST picture, not the read head", async (t) => {
799
+ const { manager, base, dirPath } = await managerWithBase();
800
+ t.after(async () => {
801
+ await manager.disposeAll();
802
+ await rm(dirPath, { recursive: true, force: true });
803
+ });
804
+ base.audioSeparate = true;
805
+ // A copied picture is one session shared by both of them. The read head is
806
+ // the furthest request of EITHER, so it belongs to the viewer in front.
807
+ base.furthestViewerSeconds = 140;
808
+ viewerOf(base, "ahead").netReport = {
809
+ linkMbps: 20,
810
+ bufferedAheadSec: 40,
811
+ positionSeconds: 100,
812
+ at: Date.now()
813
+ };
814
+ viewerOf(base, "behind").netReport = {
815
+ linkMbps: 20,
816
+ bufferedAheadSec: 8,
817
+ positionSeconds: 40,
818
+ at: Date.now()
819
+ };
820
+ manager.getCachedAudioTracks = () => [
821
+ { index: 0, language: "rus", title: "", isDefault: true },
822
+ { index: 1, language: "eng", title: "", isDefault: false }
823
+ ];
824
+ const created = [];
825
+ manager.createOrGetSession = async (params) => {
826
+ created.push(params);
827
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
828
+ rendition.audioOnly = true;
829
+ return { sessionId: VARIANT_ID, session: rendition };
830
+ };
831
+
832
+ await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
833
+
834
+ assert.equal(
835
+ created[0].startPositionSeconds,
836
+ 36,
837
+ "the viewer at 40 s, less one segment of margin — a run starting at the leader " +
838
+ "has nothing to give the one behind them"
839
+ );
840
+ });
841
+
842
+ test("a position past the read head is clamped rather than acted on", async (t) => {
843
+ const { manager, base, dirPath } = await managerWithBase();
844
+ t.after(async () => {
845
+ await manager.disposeAll();
846
+ await rm(dirPath, { recursive: true, force: true });
847
+ });
848
+ base.audioSeparate = true;
849
+ base.furthestViewerSeconds = 140;
850
+ // Reports and requests race; a position claiming to be past everything that
851
+ // has been asked for would start the run where no request can reach it.
852
+ viewerOf(base, "viewer").netReport = {
853
+ linkMbps: 20,
854
+ bufferedAheadSec: 40,
855
+ positionSeconds: 900,
856
+ at: Date.now()
857
+ };
858
+ manager.getCachedAudioTracks = () => [
859
+ { index: 0, language: "rus", title: "", isDefault: true },
860
+ { index: 1, language: "eng", title: "", isDefault: false }
861
+ ];
862
+ const created = [];
863
+ manager.createOrGetSession = async (params) => {
864
+ created.push(params);
865
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
866
+ rendition.audioOnly = true;
867
+ return { sessionId: VARIANT_ID, session: rendition };
868
+ };
869
+
870
+ await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
871
+
872
+ assert.equal(
873
+ created[0].startPositionSeconds,
874
+ 136,
875
+ "clamped to the read head, less one segment of margin"
876
+ );
877
+ });
878
+
879
+ test("a stale buffer report is not used to place an audio track", async (t) => {
880
+ const { manager, base, dirPath } = await managerWithBase();
881
+ t.after(async () => {
882
+ await manager.disposeAll();
883
+ await rm(dirPath, { recursive: true, force: true });
884
+ });
885
+ base.audioSeparate = true;
886
+ base.furthestViewerSeconds = 300;
887
+ // Sent a minute ago: the viewer may have seeked anywhere since, so neither
888
+ // the buffer nor the position in it says where they are now.
889
+ viewerOf(base, "viewer").netReport = {
890
+ linkMbps: 20,
891
+ bufferedAheadSec: 5,
892
+ positionSeconds: 250,
893
+ at: Date.now() - 60_000
894
+ };
895
+ manager.getCachedAudioTracks = () => [
896
+ { index: 0, language: "rus", title: "", isDefault: true },
897
+ { index: 1, language: "eng", title: "", isDefault: false }
898
+ ];
899
+ const created = [];
900
+ manager.createOrGetSession = async (params) => {
901
+ created.push(params);
902
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
903
+ rendition.audioOnly = true;
904
+ return { sessionId: VARIANT_ID, session: rendition };
905
+ };
906
+
907
+ await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
908
+
909
+ assert.equal(
910
+ created[0].startPositionSeconds,
911
+ 176,
912
+ "the whole look-ahead is subtracted instead it cannot leave the run ahead of the viewer"
913
+ );
914
+ });
915
+
916
+ test("an audio track is prepared at the position the switch will land on", async (t) => {
917
+ const { manager, base, dirPath } = await managerWithBase();
918
+ t.after(async () => {
919
+ await manager.disposeAll();
920
+ await rm(dirPath, { recursive: true, force: true });
921
+ });
922
+ base.audioSeparate = true;
923
+ manager.getCachedAudioTracks = () => [
924
+ { index: 0, language: "rus", title: "", isDefault: true },
925
+ { index: 1, language: "eng", title: "", isDefault: false }
926
+ ];
927
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
928
+ rendition.audioOnly = true;
929
+ rendition.audioTrackIndex = 1;
930
+ rendition.transcodeAudio = true;
931
+ // A soundtrack of THIS picture: the same file, published on its own. Found by
932
+ // what it is — there is no list of ids to put it on.
933
+ rendition.file = base.file;
934
+ startRunOn(rendition, { process: fakeEncoder() });
935
+ manager.sessionsById.set(VARIANT_ID, rendition);
936
+
937
+ const prepared = await manager.prepareAudioTrack(BASE_ID, 1, 240);
938
+
939
+ assert.deepEqual(
940
+ prepared,
941
+ { sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
942
+ "the caller is told which segment to wait for 240 s on a four-second grid"
943
+ );
944
+ assert.equal(
945
+ viewerOf(rendition, "").positionSeconds(),
946
+ 240,
947
+ "and the track is told where this person is, which is what the switch will land on"
948
+ );
949
+ });
950
+
951
+ test("a reading from a soundtrack is priced as one", () => {
952
+ // The fault this pins: an audio rendition reached no learner at all. One
953
+ // guard refused renditions a reading, and the only call that would have
954
+ // priced one sat behind a second guard its caller had already made so a
955
+ // soundtrack ran for the whole film and was charged at nothing, which is the
956
+ // half of roadmap item 6 that was left owing.
957
+ assert.equal(costKindForSession({ audioOnly: true, transcodeVideo: false }), "audio");
958
+ assert.equal(
959
+ costKindForSession({ audioOnly: true, transcodeVideo: true }),
960
+ "audio",
961
+ "a rendition carries no picture, whatever the flag it inherited says"
962
+ );
963
+ assert.equal(costKindForSession({ transcodeVideo: true }), "decode");
964
+ assert.equal(costKindForSession({ transcodeVideo: false }), "copy");
965
+ });
966
+
967
+ test("a quality step being warmed is not refused by its own cost", async (t) => {
968
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "warm-cost-"));
969
+ // The addon host's own figures, so the arithmetic below is the field's and
970
+ // not an invention. Without a benchmark the check returns every height
971
+ // untouched and a test over it would pass while proving nothing.
972
+ const manager = new HlsSessionManager({
973
+ enabled: true,
974
+ ffmpegBin: "ffmpeg",
975
+ localBindHost: "127.0.0.1",
976
+ localPort: 9090,
977
+ softwarePresetBenchmark: [
978
+ { preset: "fast", pixelsPerSec: 17.0e6 },
979
+ { preset: "ultrafast", pixelsPerSec: 67.5e6 }
980
+ ],
981
+ decodeCostModel: { pixelTerm: 0.007742, bitrateTerm: 0, constantTerm: 0 }
982
+ });
983
+ t.after(async () => {
984
+ await manager.disposeAll();
985
+ await rm(dirPath, { recursive: true, force: true });
986
+ });
987
+ // A copied 1080p picture, and a 240p step warmed beside it for a switch —
988
+ // which is what every quality change does, two encoders on purpose.
989
+ const base = fakeSession({ id: BASE_ID, encodeHeight: 0, dirPath, transcodeVideo: false });
990
+ base.variantHeight = 1080;
991
+ // What this source costs to decode comes from the file's own facts, stated in
992
+ // the fixture: 1080p24 at 8 Mbit/s, the field file. Without them nothing can
993
+ // be priced and every height comes back offerable for want of a measurement,
994
+ // which would make the assertion hold for the wrong reason.
995
+ assert.ok(base.file.decode, "the fixture must state enough for a decode cost to exist");
996
+ const warming = fakeSession({ id: VARIANT_ID, encodeHeight: 240, dirPath });
997
+ warming.variantHeight = 240;
998
+ // Two sessions of ONE file share its facts, which is the whole point of the
999
+ // file being an object: a step warmed beside the picture is not a second file.
1000
+ warming.file = base.file;
1001
+ base.file.stepHeights.set(240, 240);
1002
+ // A step of the picture: same file, and a height of its own.
1003
+ // Running, and running well: it says of itself that it holds twice realtime,
1004
+ // i.e. half a second of work per second of video.
1005
+ startRunOn(warming, { process: fakeEncoder() });
1006
+ warming.lastAloneSpeed = 2;
1007
+ manager.sessionsById.set(BASE_ID, base);
1008
+ manager.sessionsById.set(VARIANT_ID, warming);
1009
+
1010
+ const offered = manager.offeredHeights(base);
1011
+
1012
+ assert.ok(
1013
+ offered.includes(240),
1014
+ "charged its own cost while being judged, the step the viewer just asked for is dropped from the " +
1015
+ "offer by the act of warming it — and its next segment 404s on a stream that is playing"
1016
+ );
1017
+ });
1018
+
1019
+ test("the master survives a live offer that has collapsed to one rung", async (t) => {
1020
+ // The field case of 2026-08-18, in the smallest form that reproduces it: a
1021
+ // host too slow for any re-encoded rung, and a swarm whose interruptions
1022
+ // demand far more than realtime. The live offer then holds only the height an
1023
+ // encoder is already producing — and until this test existed, that made
1024
+ // `buildMasterPlaylist` answer null and the route answer 404 to a session
1025
+ // that had just published the address.
1026
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "quality-variants-collapse-"));
1027
+ const manager = new HlsSessionManager({
1028
+ enabled: true,
1029
+ ffmpegBin: "ffmpeg",
1030
+ localBindHost: "127.0.0.1",
1031
+ localPort: 9090,
1032
+ // A megapixel a second: every rung below the source costs more than the
1033
+ // machine has.
1034
+ softwarePresetBenchmark: [{ preset: "veryfast", pixelsPerSec: 1_000_000 }],
1035
+ decodeCostModel: { pixelTerm: 0.01, bitrateTerm: 0, constantTerm: 0 }
1036
+ });
1037
+ const base = fakeSession({ id: BASE_ID, encodeHeight: 812, dirPath });
1038
+ // A thicker source than the fixture's, stated as the file's own bitrate
1039
+ // rather than as a cost object the file now derives.
1040
+ base.file.learn({ width: 1920, height: 1080, fps: 24, bitrateKbps: 10_000 });
1041
+ // What this file's own reader measured: a step must run at eight times
1042
+ // realtime to survive this swarm.
1043
+ base.supplyFigures = { requiredSpeed: 8 };
1044
+ manager.sessionsById.set(BASE_ID, base);
1045
+ t.after(async () => {
1046
+ await manager.disposeAll();
1047
+ await rm(dirPath, { recursive: true, force: true });
1048
+ });
1049
+
1050
+ assert.deepEqual(
1051
+ manager.offeredHeights(base),
1052
+ [812],
1053
+ "the live judgement is unchanged: nothing but the running height is worth offering"
1054
+ );
1055
+
1056
+ const master = manager.buildMasterPlaylist(BASE_ID);
1057
+
1058
+ assert.ok(master, "the master is a published document, not a live figure");
1059
+ const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
1060
+ assert.deepEqual(
1061
+ heights,
1062
+ [1080, 812, 720, 540, 480, 360, 240],
1063
+ "every rung that can be spliced onto this cut grid stays addressable"
1064
+ );
1065
+ });
1066
+
1067
+ test("two heights that clamp onto one picture share a single encoder", async (t) => {
1068
+ const { manager, base, dirPath } = await managerWithBase();
1069
+ const spawnedDirs = [];
1070
+ t.after(async () => {
1071
+ await manager.disposeAll();
1072
+ await Promise.all(spawnedDirs.map((dir) => rm(dir, { recursive: true, force: true })));
1073
+ await rm(dirPath, { recursive: true, force: true });
1074
+ });
1075
+
1076
+ // A host that can hold exactly one rung. Whatever height is asked for, the
1077
+ // clamp inside createOrGetSession lands the encode on 240p — which is what a
1078
+ // CM4 did on 2026-08-28, turning a 360p and a 540p request into two more
1079
+ // ffmpeg processes making the same 426x240 picture as the 240p one.
1080
+ const madeIds = [VARIANT_ID, SECOND_VARIANT_ID];
1081
+ const created = [];
1082
+ manager.createOrGetSession = async (params) => {
1083
+ const id = madeIds[created.length];
1084
+ created.push(params);
1085
+ const variantDir = await mkdtemp(path.join(os.tmpdir(), "quality-variants-clamped-"));
1086
+ spawnedDirs.push(variantDir);
1087
+ const variant = fakeSession({ id, encodeHeight: 240, dirPath: variantDir });
1088
+ variant.consumers = new Set([params.consumerId]);
1089
+ manager.sessionsById.set(id, variant);
1090
+ return variant;
1091
+ };
1092
+
1093
+ const asked360 = await manager.resolveVariantSession(BASE_ID, 360);
1094
+ const asked540 = await manager.resolveVariantSession(BASE_ID, 540);
1095
+
1096
+ assert.equal(asked360.id, VARIANT_ID, "the first request made the encoder");
1097
+ assert.equal(
1098
+ asked540.id,
1099
+ VARIANT_ID,
1100
+ "and the second is served by it, because it produces the very same picture"
1101
+ );
1102
+ assert.equal(
1103
+ manager.sessionsById.has(SECOND_VARIANT_ID),
1104
+ false,
1105
+ "the duplicate was let go as soon as its size was known"
1106
+ );
1107
+ assert.equal(
1108
+ base.file.stepHeights.get(540),
1109
+ 240,
1110
+ "540p is recorded as answered with the 240p the machine can hold"
1111
+ );
1112
+
1113
+ const askedAgain = await manager.resolveVariantSession(BASE_ID, 540);
1114
+
1115
+ assert.equal(askedAgain.id, VARIANT_ID);
1116
+ assert.equal(created.length, 2, "the third request created nothing at all");
1117
+ });
1118
+
1119
+ test("rungs that really do differ keep their own encoders", async (t) => {
1120
+ const { manager, base, dirPath } = await managerWithBase();
1121
+ const spawnedDirs = [];
1122
+ t.after(async () => {
1123
+ await manager.disposeAll();
1124
+ await Promise.all(spawnedDirs.map((dir) => rm(dir, { recursive: true, force: true })));
1125
+ await rm(dirPath, { recursive: true, force: true });
1126
+ });
1127
+
1128
+ // A host with room: each request is encoded at the height it named, so
1129
+ // nothing may be merged. The sharing above must not become "one encoder for
1130
+ // every rung".
1131
+ const madeIds = [VARIANT_ID, SECOND_VARIANT_ID];
1132
+ const created = [];
1133
+ manager.createOrGetSession = async (params) => {
1134
+ const id = madeIds[created.length];
1135
+ created.push(params);
1136
+ const variantDir = await mkdtemp(path.join(os.tmpdir(), "quality-variants-distinct-"));
1137
+ spawnedDirs.push(variantDir);
1138
+ const variant = fakeSession({ id, encodeHeight: params.targetHeight, dirPath: variantDir });
1139
+ variant.consumers = new Set([params.consumerId]);
1140
+ manager.sessionsById.set(id, variant);
1141
+ return variant;
1142
+ };
1143
+
1144
+ const asked360 = await manager.resolveVariantSession(BASE_ID, 360);
1145
+ const asked540 = await manager.resolveVariantSession(BASE_ID, 540);
1146
+
1147
+ assert.equal(asked360.id, VARIANT_ID);
1148
+ assert.equal(asked540.id, SECOND_VARIANT_ID, "two different pictures, two encoders");
1149
+ assert.equal(base.file.stepHeights.get(360), 360);
1150
+ assert.equal(base.file.stepHeights.get(540), 540);
1151
+ });
1152
+
1153
+ test("a rung is never served from the COPY, whatever height the copy happens to be", async (t) => {
1154
+ const { manager, base, dirPath } = await managerWithBase();
1155
+ const spawnedDirs = [];
1156
+ t.after(async () => {
1157
+ await manager.disposeAll();
1158
+ await Promise.all(spawnedDirs.map((dir) => rm(dir, { recursive: true, force: true })));
1159
+ await rm(dirPath, { recursive: true, force: true });
1160
+ });
1161
+
1162
+ // The base is a COPY at 240p — no encoder behind it, and it is the one rung
1163
+ // this host can always serve. Handing it to a request for a re-encoded 360p
1164
+ // would give away exactly that, and a viewer stranded on a rung the machine
1165
+ // cannot hold would have nowhere left to return to.
1166
+ base.transcodeVideo = false;
1167
+ base.encodeHeight = 240;
1168
+ base.variantHeight = 240;
1169
+ const created = [];
1170
+ manager.createOrGetSession = async (params) => {
1171
+ created.push(params);
1172
+ const variantDir = await mkdtemp(path.join(os.tmpdir(), "quality-variants-copy-"));
1173
+ spawnedDirs.push(variantDir);
1174
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 240, dirPath: variantDir });
1175
+ variant.consumers = new Set([params.consumerId]);
1176
+ manager.sessionsById.set(VARIANT_ID, variant);
1177
+ return variant;
1178
+ };
1179
+
1180
+ const asked = await manager.resolveVariantSession(BASE_ID, 360);
1181
+
1182
+ assert.equal(asked.id, VARIANT_ID, "the re-encoded rung is its own session, not the copy");
1183
+ });
1184
+
1185
+ test("a file opened at a position starts its sound THERE, not a look-ahead earlier", async (t) => {
1186
+ const { manager, base, dirPath } = await managerWithBase();
1187
+ t.after(async () => {
1188
+ await manager.disposeAll();
1189
+ await rm(dirPath, { recursive: true, force: true });
1190
+ });
1191
+ base.audioSeparate = true;
1192
+ // The state at the instant a page is opened at a position: nothing seeked,
1193
+ // no segment served, no report from anybody. The read head is then not a
1194
+ // request edge it is where the session was made — and a browser that has
1195
+ // just opened holds no buffer at all.
1196
+ base.furthestViewerSeconds = null;
1197
+ base.lastRequestedSegment = null;
1198
+ base.viewers.clear();
1199
+ base.progress.startPositionSeconds = 588;
1200
+ manager.getCachedAudioTracks = () => [
1201
+ { index: 0, language: "rus", title: "", isDefault: true },
1202
+ { index: 1, language: "eng", title: "", isDefault: false }
1203
+ ];
1204
+ const created = [];
1205
+ manager.createOrGetSession = async (params) => {
1206
+ created.push(params);
1207
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
1208
+ rendition.audioOnly = true;
1209
+ return { sessionId: VARIANT_ID, session: rendition };
1210
+ };
1211
+
1212
+ await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
1213
+
1214
+ // Field 2026-08-31: this answered 460 for a page opened at 588 — the whole
1215
+ // 120 s look-ahead subtracted from a buffer that did not exist — and the
1216
+ // segment the viewer needed took 38.8 s to appear against the picture's 8.4 s.
1217
+ assert.equal(
1218
+ created[0].startPositionSeconds,
1219
+ 584,
1220
+ "where the viewer opened, less one segment of margin, and nothing else"
1221
+ );
1222
+ });