@torrent-tv/proxy 2.89.3 → 2.89.5

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.
Files changed (50) hide show
  1. package/AGENTS.md +338 -336
  2. package/CHANGELOG.md +2319 -2303
  3. package/docs/encode-architecture.md +55 -19
  4. package/docs/playback-start-forecast.md +13 -5
  5. package/docs/storage-architecture.md +6 -0
  6. package/package.json +1 -1
  7. package/routes/stream/get.js +38 -2
  8. package/server.js +748 -745
  9. package/services/encode/EncodeOrchestrator.js +24 -49
  10. package/services/encode/EncodePlan.js +5 -3
  11. package/services/encode/EncodeRun.js +80 -17
  12. package/services/encode/EncodeRuns.js +1339 -1239
  13. package/services/encode/Renditions.js +2 -1
  14. package/services/encode/RunClock.js +87 -0
  15. package/services/encode/RunProgress.js +1 -6
  16. package/services/encode/encoder-readings.js +26 -28
  17. package/services/encode/piece-completeness.js +46 -0
  18. package/services/encode/quality/EncodeCost.js +1043 -1167
  19. package/services/encode/quality/OutputQualityState.js +1 -5
  20. package/services/encode/quality/QualityController.js +213 -390
  21. package/services/encode/quality/link-budget.js +5 -17
  22. package/services/encode/run-inputs.js +13 -2
  23. package/services/server/SegmentServing.js +3 -1
  24. package/services/server/ViewerRequests.js +2 -7
  25. package/services/server/wire-outputs.js +12 -7
  26. package/services/storage/segment-store/SegmentStore.js +1294 -1255
  27. package/services/torrent/supply-margin.js +7 -5
  28. package/test/auto-quality-step.test.js +75 -29
  29. package/test/decode-cost.test.js +1 -1
  30. package/test/encode-cost.test.js +31 -39
  31. package/test/encode-orchestrator.test.js +15 -8
  32. package/test/encode-plan-viewers.test.js +34 -27
  33. package/test/encode-run.test.js +34 -6
  34. package/test/encoder-readings.test.js +48 -26
  35. package/test/helpers/encode-run.js +113 -9
  36. package/test/link-budget.test.js +4 -5
  37. package/test/manager-surface.test.js +1 -2
  38. package/test/orchestrator-wired.test.js +5 -4
  39. package/test/output-speed.test.js +0 -1
  40. package/test/piece-completeness.test.js +42 -0
  41. package/test/quality-offer.test.js +1 -3
  42. package/test/quality-variants.test.js +1 -1
  43. package/test/run-progress.test.js +2 -3
  44. package/test/same-height-switch.test.js +1 -2
  45. package/test/segment-closed-on-both-branches.test.js +114 -96
  46. package/test/segment-store.test.js +15 -15
  47. package/test/stream-route.test.js +28 -0
  48. package/test/supply-margin.test.js +13 -0
  49. package/test/two-viewers-one-picture.test.js +1 -1
  50. package/test/viewer-outputs.test.js +1 -1
@@ -28,6 +28,7 @@ import test from "node:test";
28
28
  import assert from "node:assert/strict";
29
29
  import { EventEmitter } from "node:events";
30
30
  import { EncodeRun } from "../services/encode/EncodeRun.js";
31
+ import { measureSpeed } from "./helpers/encode-run.js";
31
32
  import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
32
33
  import { EncodeOrchestrator } from "../services/encode/EncodeOrchestrator.js";
33
34
  import { mapForViewer, mergeMaps, runsOf } from "../services/viewer/PriorityMap.js";
@@ -52,10 +53,16 @@ const SEGMENT_SECONDS = 4;
52
53
  const FILM_SECONDS = 4000;
53
54
  const SEGMENTS = FILM_SECONDS / SEGMENT_SECONDS;
54
55
 
56
+ /** What every run of these tests reads as now; a measurement advances it. */
57
+ const clock = { at: 1000 };
58
+
55
59
  class FakeProcess extends EventEmitter {
56
60
  constructor() {
57
61
  super();
58
62
  this.pid = 1;
63
+ // The progress channel and the channel closed pieces are named on.
64
+ this.stdout = new EventEmitter();
65
+ this.stdio = [null, this.stdout, null, new EventEmitter()];
59
66
  }
60
67
 
61
68
  kill(signal) {
@@ -75,7 +82,7 @@ function orchestrator({ maxRuns = 3 } = {}) {
75
82
  buildArgs: () => ["-i", "in", "out"],
76
83
  spawn: () => new FakeProcess(),
77
84
  logger: { info() {}, warn() {} },
78
- now: () => 1000,
85
+ now: () => clock.at,
79
86
  onEnded: (ended) => made.noteEnded(ended)
80
87
  });
81
88
  made = new EncodeOrchestrator({
@@ -209,7 +216,7 @@ test("one viewer playing: the encoder keeping up buys no second one", () => {
209
216
  watches("one", { atSeconds: 400 });
210
217
  made.reconcile();
211
218
  const [run] = made.runsOn(PICTURE);
212
- run.noteSpeed(6);
219
+ measureSpeed(run, 6, clock);
213
220
  for (let index = 100; index < 130; index += 1) {
214
221
  made.noteProduced(PICTURE, index);
215
222
  }
@@ -225,7 +232,7 @@ test("one viewer seeking far ahead: an encoder is placed there", () => {
225
232
  const { made, watches } = orchestrator();
226
233
  watches("one", { atSeconds: 400 });
227
234
  made.reconcile();
228
- made.runsOn(PICTURE)[0].noteSpeed(6);
235
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
229
236
  made.noteProduced(PICTURE, 100);
230
237
 
231
238
  // The peak JUMPS. Nothing about the old place is wanted now.
@@ -243,7 +250,7 @@ test("one viewer paused: nothing is late, so no encoder is added", () => {
243
250
  const { made, watches } = orchestrator();
244
251
  watches("one", { atSeconds: 400 });
245
252
  made.reconcile();
246
- made.runsOn(PICTURE)[0].noteSpeed(1);
253
+ measureSpeed(made.runsOn(PICTURE)[0], 1, clock);
247
254
  made.noteProduced(PICTURE, 100);
248
255
 
249
256
  watches("one", { atSeconds: 404, playing: false });
@@ -295,7 +302,7 @@ test("two viewers far apart get an encoder each", () => {
295
302
  const { made, watches } = orchestrator();
296
303
  watches("one", { atSeconds: 400 });
297
304
  made.reconcile();
298
- made.runsOn(PICTURE)[0].noteSpeed(6);
305
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
299
306
  made.noteProduced(PICTURE, 100);
300
307
 
301
308
  watches("two", { atSeconds: 3000 });
@@ -314,12 +321,12 @@ test("two viewers: one seeking does not take the other's encoder", () => {
314
321
  // long one takes to reach a number is the whole comparison. Real life
315
322
  // measures it from the first run; a test that stated both viewers before any
316
323
  // run existed would be asking the plan to buy a process on no evidence.
317
- made.runsOn(PICTURE)[0].noteSpeed(6);
324
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
318
325
  made.noteProduced(PICTURE, 100);
319
326
  watches("two", { atSeconds: 3000 });
320
327
  made.reconcile();
321
328
  for (const run of made.runsOn(PICTURE)) {
322
- run.noteSpeed(6);
329
+ measureSpeed(run, 6, clock);
323
330
  }
324
331
  assert.ok(made.runsOn(PICTURE).length >= 2, "one each to begin with");
325
332
 
@@ -342,7 +349,7 @@ test("two viewers: one pausing leaves the other served", () => {
342
349
  watches("two", { atSeconds: 3000 });
343
350
  made.reconcile();
344
351
  for (const run of made.runsOn(PICTURE)) {
345
- run.noteSpeed(1);
352
+ measureSpeed(run, 1, clock);
346
353
  }
347
354
 
348
355
  watches("two", { atSeconds: 3000, playing: false });
@@ -359,7 +366,7 @@ test("two viewers: the one who leaves takes nothing from the one who stays", ()
359
366
  watches("two", { atSeconds: 3000 });
360
367
  made.reconcile();
361
368
  for (const run of made.runsOn(PICTURE)) {
362
- run.noteSpeed(1);
369
+ measureSpeed(run, 1, clock);
363
370
  }
364
371
 
365
372
  leaves("two");
@@ -379,7 +386,7 @@ test("three viewers far apart get an encoder each when the machine affords it",
379
386
  watches("three", { atSeconds: 3600 });
380
387
  made.reconcile();
381
388
  for (const run of made.runsOn(PICTURE)) {
382
- run.noteSpeed(6);
389
+ measureSpeed(run, 6, clock);
383
390
  }
384
391
  made.reconcile();
385
392
 
@@ -402,7 +409,7 @@ test("three viewers, a machine that affords two: the budget binds, not the map",
402
409
  watches("three", { atSeconds: 3600 });
403
410
  made.reconcile();
404
411
  for (const run of made.runsOn(PICTURE)) {
405
- run.noteSpeed(1);
412
+ measureSpeed(run, 1, clock);
406
413
  }
407
414
  made.reconcile();
408
415
 
@@ -415,13 +422,13 @@ test("three viewers: one seeks onto another, and the two of them share", () => {
415
422
  const { made, watches } = orchestrator({ maxRuns: 3 });
416
423
  watches("one", { atSeconds: 400 });
417
424
  made.reconcile();
418
- made.runsOn(PICTURE)[0].noteSpeed(6);
425
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
419
426
  made.noteProduced(PICTURE, 100);
420
427
  watches("two", { atSeconds: 2000 });
421
428
  watches("three", { atSeconds: 3600 });
422
429
  made.reconcile();
423
430
  for (const run of made.runsOn(PICTURE)) {
424
- run.noteSpeed(6);
431
+ measureSpeed(run, 6, clock);
425
432
  }
426
433
  made.reconcile();
427
434
  assert.equal(made.runsOn(PICTURE).length, 3);
@@ -438,7 +445,7 @@ test("three viewers: all paused, and no encoder is added for any of them", () =>
438
445
  const { made, watches } = orchestrator({ maxRuns: 3 });
439
446
  watches("one", { atSeconds: 400 });
440
447
  made.reconcile();
441
- made.runsOn(PICTURE)[0].noteSpeed(1);
448
+ measureSpeed(made.runsOn(PICTURE)[0], 1, clock);
442
449
  const before = made.runsOn(PICTURE).length;
443
450
 
444
451
  watches("one", { atSeconds: 400, playing: false });
@@ -461,7 +468,7 @@ test("the plan is a function of the state: the same state twice gives the same a
461
468
  watches("two", { atSeconds: 2000 });
462
469
  made.reconcile();
463
470
  for (const run of made.runsOn(PICTURE)) {
464
- run.noteSpeed(1);
471
+ measureSpeed(run, 1, clock);
465
472
  }
466
473
  made.reconcile();
467
474
  const first = placements(made);
@@ -483,7 +490,7 @@ test("exactly realtime arrives exactly on time, and no second encoder is bought"
483
490
  const { made, watches } = orchestrator({ maxRuns: 3 });
484
491
  watches("one", { atSeconds: 400 });
485
492
  made.reconcile();
486
- made.runsOn(PICTURE)[0].noteSpeed(1);
493
+ measureSpeed(made.runsOn(PICTURE)[0], 1, clock);
487
494
  made.noteProduced(PICTURE, 100);
488
495
  made.reconcile();
489
496
 
@@ -507,7 +514,7 @@ test("below realtime, whether a second encoder helps is arithmetic, and here it
507
514
  const { made, watches, leaves } = orchestrator({ maxRuns: 3 });
508
515
  watches("one", { atSeconds: 400 });
509
516
  made.reconcile();
510
- made.runsOn(PICTURE)[0].noteSpeed(0.5);
517
+ measureSpeed(made.runsOn(PICTURE)[0], 0.5, clock);
511
518
  made.noteProduced(PICTURE, 100);
512
519
  made.reconcile();
513
520
 
@@ -531,7 +538,7 @@ test("two viewers arriving together on a cold output get one encoder, then are m
531
538
  assert.ok(made.runsOn(PICTURE).length >= 1, "at least one, until something is measured");
532
539
  assert.equal(onTheStretchOf(made, 100), 1, "and not two on the same place");
533
540
 
534
- made.runsOn(PICTURE)[0].noteSpeed(6);
541
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
535
542
  made.noteProduced(PICTURE, 100);
536
543
  made.reconcile();
537
544
  assert.ok(placements(made).includes(750), "and now the far one is served too");
@@ -542,7 +549,7 @@ test("an encoder comfortably faster than realtime is left to do the whole stretc
542
549
  const { made, watches } = orchestrator({ maxRuns: 3 });
543
550
  watches("one", { atSeconds: 400 });
544
551
  made.reconcile();
545
- made.runsOn(PICTURE)[0].noteSpeed(6);
552
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
546
553
  made.noteProduced(PICTURE, 100);
547
554
  made.reconcile();
548
555
 
@@ -561,7 +568,7 @@ test("a swarm that feeds one encoder moves it to whoever is late, rather than se
561
568
  const [run] = made.runsOn(PICTURE);
562
569
  // 6x against a swarm charging 0.25 s per second of film: one encoder already
563
570
  // takes one and a half of what is delivered, so the budget is one.
564
- run.noteSpeed(6);
571
+ measureSpeed(run, 6, clock);
565
572
  made.refetchSecPerFilmSecond = () => 0.25;
566
573
  made.noteProduced(PICTURE, 100);
567
574
 
@@ -583,7 +590,7 @@ test("one viewer seeking back into film that exists is served from it, with no e
583
590
  const { made, watches } = orchestrator();
584
591
  watches("one", { atSeconds: 400 });
585
592
  made.reconcile();
586
- made.runsOn(PICTURE)[0].noteSpeed(6);
593
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
587
594
  for (let index = 100; index <= 160; index += 1) {
588
595
  made.noteProduced(PICTURE, index);
589
596
  }
@@ -616,7 +623,7 @@ test("one viewer seeking back into film nobody has gets an encoder there", () =>
616
623
  const { made, watches } = orchestrator();
617
624
  watches("one", { atSeconds: 3000 });
618
625
  made.reconcile();
619
- made.runsOn(PICTURE)[0].noteSpeed(6);
626
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
620
627
  made.noteProduced(PICTURE, 750);
621
628
 
622
629
  watches("one", { atSeconds: 400 });
@@ -631,14 +638,14 @@ test("two viewers: one seeks back onto film the other already had made", () => {
631
638
  const { made, watches } = orchestrator({ maxRuns: 3 });
632
639
  watches("one", { atSeconds: 400 });
633
640
  made.reconcile();
634
- made.runsOn(PICTURE)[0].noteSpeed(6);
641
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
635
642
  for (let index = 100; index <= 200; index += 1) {
636
643
  made.noteProduced(PICTURE, index);
637
644
  }
638
645
  watches("two", { atSeconds: 3000 });
639
646
  made.reconcile();
640
647
  for (const run of made.runsOn(PICTURE)) {
641
- run.noteSpeed(6);
648
+ measureSpeed(run, 6, clock);
642
649
  }
643
650
  const before = made.runsOn(PICTURE).length;
644
651
 
@@ -662,7 +669,7 @@ test("three viewers: one forward, one back, one paused", () => {
662
669
  watches("three", { atSeconds: 3600 });
663
670
  made.reconcile();
664
671
  for (const run of made.runsOn(PICTURE)) {
665
- run.noteSpeed(6);
672
+ measureSpeed(run, 6, clock);
666
673
  }
667
674
  made.reconcile();
668
675
 
@@ -690,7 +697,7 @@ test("a viewer scrubbing back and forth does not accumulate encoders", () => {
690
697
  const { made, watches } = orchestrator({ maxRuns: 3 });
691
698
  watches("one", { atSeconds: 400 });
692
699
  made.reconcile();
693
- made.runsOn(PICTURE)[0].noteSpeed(6);
700
+ measureSpeed(made.runsOn(PICTURE)[0], 6, clock);
694
701
  made.noteProduced(PICTURE, 100);
695
702
  made.reconcile();
696
703
  const settled = placements(made);
@@ -698,7 +705,7 @@ test("a viewer scrubbing back and forth does not accumulate encoders", () => {
698
705
  watches("one", { atSeconds: 2000 });
699
706
  made.reconcile();
700
707
  for (const run of made.runsOn(PICTURE)) {
701
- run.noteSpeed(6);
708
+ measureSpeed(run, 6, clock);
702
709
  }
703
710
  watches("one", { atSeconds: 404 });
704
711
  made.reconcile();
@@ -146,13 +146,41 @@ test("a process that could not be started ends like any other failure", () => {
146
146
  assert.match(ends[0].because, /ENOENT/);
147
147
  });
148
148
 
149
- test("only a measured speed is kept", () => {
150
- const { run } = makeRun();
149
+ test("its speed is film made between closed pieces over its own working time", () => {
150
+ const clock = { at: 1000 };
151
+ const process_ = new FakeProcess();
152
+ process_.stdout = new EventEmitter();
153
+ process_.stdio = [null, process_.stdout, null, new EventEmitter()];
154
+ const readings = [];
155
+ const run = new EncodeRun({
156
+ address: "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy",
157
+ encoder: new SoftwareEncoder(),
158
+ from: 0,
159
+ to: 9,
160
+ buildArgs: () => [],
161
+ spawn: () => process_,
162
+ logger: { info() {}, warn() {} },
163
+ now: () => clock.at,
164
+ onSpeedMeasured: (reading) => readings.push(reading)
165
+ });
151
166
  assert.equal(run.speedX, 0, "nothing measured yet");
152
- run.noteSpeed(0);
153
- assert.equal(run.speedX, 0);
154
- run.noteSpeed(2.5);
155
- assert.equal(run.speedX, 2.5);
167
+ const report = (seconds) => process_.stdout.emit("data", `out_time_ms=${seconds * 1_000_000}\n`);
168
+ const closed = (name) => process_.stdio[3].emit("data", `${name}\n`);
169
+ report(0);
170
+ closed("a");
171
+ clock.at += 1000;
172
+ report(4);
173
+ closed("b"); // "a" is published: the reckoning starts here
174
+ assert.equal(run.speedX, 0, "one closed piece measures nothing");
175
+ // The input waits for the swarm for 30 s; that is not the run's work.
176
+ run.inputWaitBegins();
177
+ clock.at += 30_000;
178
+ run.inputWaitEnds();
179
+ clock.at += 2000;
180
+ report(12);
181
+ closed("c"); // "b" is published
182
+ assert.equal(run.speedX, 4, "8 s of film over 2 s of work");
183
+ assert.deepEqual(readings, [{ speed: 4, at: 34_000 }], "told once, with when");
156
184
  });
157
185
 
158
186
  test("a segment number behind its own start is not its to claim", () => {
@@ -1,46 +1,68 @@
1
1
  /**
2
- * @file What two readings of a running encoder say about its speed.
2
+ * @file What two work samples of a running encoder say about its speed.
3
3
  *
4
- * The case this exists for is a COPY. ffmpeg's own cumulative `speed=` counts
5
- * the seconds the look-ahead cap keeps the encoder stopped, and a copy is
6
- * stopped for most of its life — reaching the cap in about fifteen seconds and
7
- * then waiting a minute. Read cumulatively, a copy running at 8x reports 1.6x,
8
- * which filed as the price of copying would refuse rungs on a measurement of a
9
- * pause.
4
+ * The cases this exists for: a copy whose cumulative speed counts the time it
5
+ * was stopped, and a run whose input waited for the swarm (field 2026-10-01: a
6
+ * copy read 0.21x after waiting 32.93 s and 43.91 s for two pieces). A work
7
+ * sample states the run's own working time, so neither enters the speed.
10
8
  */
11
9
 
12
10
  import assert from "node:assert/strict";
13
11
  import test from "node:test";
14
12
 
15
- import { speedFromReadings } from "../services/encode/encoder-readings.js";
13
+ import { RunClock } from "../services/encode/RunClock.js";
14
+ import { speedFromWork } from "../services/encode/encoder-readings.js";
16
15
 
17
- const at = (seconds, processedSeconds) => ({ takenAt: seconds * 1000, processedSeconds });
16
+ const sample = (at, producedSeconds, workingMs) => ({ at, producedSeconds, workingMs });
18
17
 
19
- test("a copy producing eight seconds of video per second reads as eight", () => {
20
- assert.equal(speedFromReadings(at(10, 100), at(15, 140), 3), 8);
18
+ test("film made over the run's own work is the speed", () => {
19
+ assert.equal(speedFromWork(sample(10_000, 100, 5_000), sample(15_000, 140, 10_000)), 8);
21
20
  });
22
21
 
23
- test("a stretch too short to divide by says nothing", () => {
24
- assert.equal(speedFromReadings(at(10, 100), at(11, 108), 3), null);
22
+ test("a stretch with nothing made says nothing", () => {
23
+ assert.equal(speedFromWork(sample(10_000, 100, 5_000), sample(20_000, 100, 15_000)), null);
25
24
  });
26
25
 
27
- test("a run that produced nothing between the readings says nothing", () => {
28
- // What a repositioned run looks like before it reaches its new start.
29
- assert.equal(speedFromReadings(at(10, 100), at(20, 100), 3), null);
26
+ test("a run that went backwards says nothing rather than a negative speed", () => {
27
+ assert.equal(speedFromWork(sample(10_000, 200, 5_000), sample(20_000, 100, 15_000)), null);
30
28
  });
31
29
 
32
- test("a run that went BACKWARDS says nothing rather than a negative speed", () => {
33
- assert.equal(speedFromReadings(at(10, 200), at(20, 100), 3), null);
30
+ test("a missing sample is not an answer", () => {
31
+ assert.equal(speedFromWork(null, sample(20_000, 100, 1_000)), null);
32
+ assert.equal(speedFromWork(sample(10_000, 100, 1_000), null), null);
34
33
  });
35
34
 
36
- test("a missing reading is not an answer", () => {
37
- assert.equal(speedFromReadings(null, at(20, 100), 3), null);
38
- assert.equal(speedFromReadings(at(10, 100), null, 3), null);
35
+ test("waiting for the input and being stopped are not working time", () => {
36
+ let now = 0;
37
+ const clock = new RunClock({ now: () => now });
38
+ now = 2_000;
39
+ clock.inputWaitBegins();
40
+ now = 34_930; // the field's 32.93 s wait for one piece
41
+ clock.inputWaitEnds();
42
+ now = 36_000;
43
+ clock.stopped();
44
+ now = 50_000;
45
+ clock.continued();
46
+ now = 51_000;
47
+ assert.equal(clock.workingMs(), 2_000 + 1_070 + 1_000);
39
48
  });
40
49
 
41
- test("the pair is judged on wall clock, so a pause between them is the caller's problem", () => {
42
- // Deliberate: this function cannot see a pause. The session drops its
43
- // previous reading whenever the encoder is stopped or restarted, which is
44
- // what makes every surviving pair an uninterrupted stretch.
45
- assert.equal(speedFromReadings(at(0, 0), at(60, 60), 3), 1);
50
+ test("two inputs waiting at once, or a wait while stopped, are idle once", () => {
51
+ let now = 0;
52
+ const clock = new RunClock({ now: () => now });
53
+ clock.inputWaitBegins();
54
+ now = 1_000;
55
+ clock.inputWaitBegins();
56
+ clock.stopped();
57
+ now = 3_000;
58
+ clock.inputWaitEnds();
59
+ clock.continued();
60
+ now = 4_000;
61
+ clock.inputWaitEnds();
62
+ now = 5_000;
63
+ assert.equal(clock.workingMs(), 1_000);
64
+ // An ongoing wait is idle up to the moment asked.
65
+ clock.inputWaitBegins();
66
+ now = 6_000;
67
+ assert.equal(clock.workingMs(), 1_000);
46
68
  });
@@ -15,6 +15,79 @@
15
15
 
16
16
  import { EncodeRun } from "../../services/encode/EncodeRun.js";
17
17
 
18
+ /** The working time between two closed pieces of a test measurement. */
19
+ const MEASURED_STEP_MS = 1000;
20
+
21
+ /** Closed pieces in a test measurement: the last two give the reading. */
22
+ const MEASURED_STEPS = 3;
23
+
24
+ /**
25
+ * The clock each run built by `startRunOn` reads: standing while a measurement
26
+ * is said, the wall clock otherwise. @type {WeakMap<EncodeRun, { at: number | null }>}
27
+ */
28
+ const wallClocks = new WeakMap();
29
+
30
+ /**
31
+ * Make a run measure a speed the way it does in the product: progress reports
32
+ * and the names of closed pieces on the encoder's own channels, one second of
33
+ * the run's own work apart. A closed piece is published when the next name
34
+ * arrives, and each publication is one reading against the one before, so the
35
+ * last of three steps reads exactly `speedX` whatever the run measured before.
36
+ *
37
+ * @param {EncodeRun} run - Its process exposes `stdout` and `stdio[3]`.
38
+ * @param {number} speedX - Film seconds per second of the run's work.
39
+ * @param {{ at: number } | null} [clock] - What the run's `now` reads; advanced
40
+ * here. Absent for a run built by `startRunOn` without one: its clock is
41
+ * stood just behind the wall clock for the measurement and released after,
42
+ * so the reading is dated now.
43
+ * @param {number} [waitedMs] - How long the run's input waits for the swarm in
44
+ * each step, on top of its second of work.
45
+ * @returns {void}
46
+ */
47
+ export function measureSpeed(run, speedX, clock = null, waitedMs = 0) {
48
+ if (clock === null) {
49
+ const wall = wallClocks.get(run);
50
+ if (!wall) {
51
+ throw new TypeError("measureSpeed needs the clock the run reads");
52
+ }
53
+ // Never behind the run's start or its own last reading: a step back in
54
+ // time is no work.
55
+ wall.at = Math.max(
56
+ Date.now() - (MEASURED_STEP_MS + waitedMs) * MEASURED_STEPS,
57
+ run.startedAt,
58
+ run.speedReading?.at ?? Number.NEGATIVE_INFINITY
59
+ );
60
+ measureSpeed(run, speedX, wall, waitedMs);
61
+ wall.at = null;
62
+ return;
63
+ }
64
+ const { stdout, stdio } = run.process;
65
+ let produced = run.progress.processedSeconds - run.progress.startPositionSeconds;
66
+ for (let step = 0; step < MEASURED_STEPS; step += 1) {
67
+ if (waitedMs > 0) {
68
+ run.inputWaitBegins();
69
+ clock.at += waitedMs;
70
+ run.inputWaitEnds();
71
+ }
72
+ clock.at += MEASURED_STEP_MS;
73
+ produced += (speedX * MEASURED_STEP_MS) / 1000;
74
+ say(stdout, `out_time_ms=${Math.round(produced * 1_000_000)}\n`);
75
+ say(stdio[3], `measured-${step}\n`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {{ write?: (text: string) => void, emit?: (event: string, text: string) => void }} channel
81
+ * @param {string} text
82
+ */
83
+ function say(channel, text) {
84
+ if (typeof channel.write === "function") {
85
+ channel.write(text);
86
+ } else {
87
+ channel.emit("data", text);
88
+ }
89
+ }
90
+
18
91
  /**
19
92
  * A child process that records what was done to it.
20
93
  *
@@ -26,6 +99,8 @@ import { EncodeRun } from "../../services/encode/EncodeRun.js";
26
99
  export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
27
100
  /** @type {Map<string, (...args: unknown[]) => void>} */
28
101
  const listeners = new Map();
102
+ const stdout = channel();
103
+ const closedPieces = channel();
29
104
  return {
30
105
  pid,
31
106
  killed: false,
@@ -33,8 +108,10 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
33
108
  signalCode: null,
34
109
  /** Every signal it was sent, in order. */
35
110
  signals: [],
36
- stdout: { on() {} },
111
+ stdout,
37
112
  stderr: { on() {} },
113
+ // The descriptor the encoder names its closed pieces on (`-segment_list pipe:3`).
114
+ stdio: [null, stdout, null, closedPieces],
38
115
  on(event, handler) {
39
116
  const kept = listeners.get(event) ?? [];
40
117
  kept.push(handler);
@@ -71,6 +148,24 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
71
148
  };
72
149
  }
73
150
 
151
+ /**
152
+ * A readable side of a pipe that a test writes into.
153
+ *
154
+ * @returns {{ on: (event: string, handler: (chunk: string) => void) => void, write: (text: string) => void }}
155
+ */
156
+ function channel() {
157
+ /** @type {((chunk: string) => void)[]} */
158
+ const handlers = [];
159
+ return {
160
+ on(event, handler) {
161
+ if (event === "data") handlers.push(handler);
162
+ },
163
+ write(text) {
164
+ for (const handler of handlers) handler(text);
165
+ }
166
+ };
167
+ }
168
+
74
169
  /** A logger that says nothing, for tests that are not about the log. */
75
170
  export const silentLogger = { info() {}, warn() {}, error() {} };
76
171
 
@@ -100,14 +195,17 @@ export function startRunOn(session, options = {}) {
100
195
  producing = true,
101
196
  lastSegmentIndex = null,
102
197
  usesExplicitCuts = false,
103
- // WHAT FFMPEG HAS REPORTED IT IS RUNNING AT. It says so on its progress
104
- // channel from the first seconds, before any piece closes — so a run can be
105
- // starting and already have a speed, and every real one does. Left at zero,
106
- // the plan has no speed to compute an arrival from, every arrangement of
107
- // encoders is equally hopeless, they all tie, and the encoder is taken away
108
- // for changing nothing.
109
- speedX = 0
198
+ // THE SPEED IT HAS MEASURED: film made over its own working time between
199
+ // two closed pieces. Left at zero, the plan has no speed to compute an
200
+ // arrival from, every arrangement of encoders is equally hopeless, they all
201
+ // tie, and the encoder is taken away for changing nothing.
202
+ speedX = 0,
203
+ // What the run reads as now, for a test that drives time itself. Absent,
204
+ // the run reads the wall clock, which stands still only while a
205
+ // measurement is said.
206
+ clock: given = null
110
207
  } = options;
208
+ const clock = given ?? { at: null };
111
209
  const run = new EncodeRun({
112
210
  address: session.outputKey ?? session.id ?? "output",
113
211
  encoder: { name: "libx264", kind: "software" },
@@ -118,9 +216,15 @@ export function startRunOn(session, options = {}) {
118
216
  logger: silentLogger,
119
217
  lastSegmentIndex: () => lastSegmentIndex,
120
218
  usesExplicitCuts,
219
+ now: () => clock.at ?? Date.now(),
121
220
  because: "a test asked for it"
122
221
  });
123
- run.noteSpeed(speedX);
222
+ if (given === null) {
223
+ wallClocks.set(run, clock);
224
+ }
225
+ if (speedX > 0) {
226
+ measureSpeed(run, speedX, given);
227
+ }
124
228
  if (producing) {
125
229
  // What moves a run out of starting is its first segment, in the product as
126
230
  // here — so a run that is producing has made one, and its head stands one
@@ -9,7 +9,6 @@ import test from "node:test";
9
9
  import assert from "node:assert/strict";
10
10
  import {
11
11
  admissionRank,
12
- LINK_SAFETY,
13
12
  LINK_VERDICT,
14
13
  linkCouldCarry,
15
14
  loadOf,
@@ -67,11 +66,11 @@ test("a load is as trustworthy as its least trustworthy part", () => {
67
66
  assert.ok(Math.abs(loadOf(known(3), known(0.128)).totalMbps - 3.128) < 1e-9, "the sound is added to the picture");
68
67
  });
69
68
 
70
- test("the sound is counted before the link's usable share is applied", () => {
71
- // The picture alone fits the usable share exactly; with the soundtrack the
72
- // viewer hears, it does not.
69
+ test("the sound is counted with the picture against the whole measured link", () => {
70
+ // The picture alone fits the link exactly; with the soundtrack the viewer
71
+ // hears, it does not. No share of the link is held back.
73
72
  const link = 10;
74
- const picture = known(link * LINK_SAFETY);
73
+ const picture = known(link);
75
74
  assert.equal(linkCouldCarry(link, loadOf(picture, null)).verdict, LINK_VERDICT.FITS, "without sound it would pass");
76
75
  assert.equal(
77
76
  linkCouldCarry(link, loadOf(picture, known(AUDIO_TRANSCODE_KBPS / 1000))).verdict,
@@ -142,7 +142,7 @@ function fakeSession({ id = SESSION_ID } = {}) {
142
142
  useSyntheticPlaylist: true,
143
143
  playlistText: "#EXTM3U\n",
144
144
  segmentCount: 10,
145
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
145
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
146
146
  };
147
147
  }
148
148
 
@@ -182,7 +182,6 @@ test("the progress report keeps every figure it carries today", async () => {
182
182
  "percent",
183
183
  "remainingSeconds",
184
184
  "segmentDurationSec",
185
- "speed",
186
185
  "currentHeight",
187
186
  "offeredHeights",
188
187
  "requestedHeight",
@@ -18,7 +18,7 @@ import { wireOutputs } from "../services/server/wire-outputs.js";
18
18
  import { SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
19
19
  import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
20
20
  import { qualityStateOf } from "../services/encode/quality/OutputQualityState.js";
21
- import { startRunOn } from "./helpers/encode-run.js";
21
+ import { measureSpeed, startRunOn } from "./helpers/encode-run.js";
22
22
  import { outputSpec } from "./helpers/output-spec.js";
23
23
 
24
24
  const KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
@@ -65,9 +65,10 @@ function sessionOn({ manager, id, dirPath, encodeStartIndex = 0, runEndIndex = -
65
65
  const run = startRunOn(session, { from: encodeStartIndex, to: runEndIndex });
66
66
  manager.encodeOrchestrator.adopt(KEY, run);
67
67
  session.testRun = run;
68
- // A speed is a reading taken FROM a run, so it names the run it came from.
69
- qualityStateOf(session).recentSpeed = speed > 0 ? { speed, at: Date.now(), run } : null;
70
- run.noteSpeed(speed);
68
+ // A speed is the run's own reading, measured from its closed pieces.
69
+ if (speed > 0) {
70
+ measureSpeed(run, speed);
71
+ }
71
72
  }
72
73
  return session;
73
74
  }
@@ -25,7 +25,6 @@ function costOf(sessions, host = {}) {
25
25
  return new EncodeCost({
26
26
  runsFor: () => [],
27
27
  stateFor: () => "IDLE",
28
- progressFor: () => null,
29
28
  outputs,
30
29
  host: () => ({
31
30
  benchmark: host.benchmark ?? null,