@torrent-tv/proxy 2.89.2 → 2.89.4

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 (49) hide show
  1. package/AGENTS.md +338 -336
  2. package/CHANGELOG.md +2315 -2299
  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 +46 -29
  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-store.test.js +15 -15
  46. package/test/stream-route.test.js +28 -0
  47. package/test/supply-margin.test.js +13 -0
  48. package/test/two-viewers-one-picture.test.js +1 -1
  49. package/test/viewer-outputs.test.js +1 -1
@@ -10,7 +10,7 @@
10
10
  import test from "node:test";
11
11
  import assert from "node:assert/strict";
12
12
  import { EncodeCost } from "../services/encode/quality/EncodeCost.js";
13
- import { startRunOn } from "./helpers/encode-run.js";
13
+ import { measureSpeed, startRunOn } from "./helpers/encode-run.js";
14
14
  import { outputSpec } from "./helpers/output-spec.js";
15
15
  import { runStateOf } from "../services/encode/encode-run-state.js";
16
16
  import { qualityStateOf } from "../services/encode/quality/OutputQualityState.js";
@@ -39,10 +39,8 @@ function costOn(readings = {}) {
39
39
  runningEncoders: () => readings.runningEncoders?.() ?? 0,
40
40
  encodersRunningNow: () => 0,
41
41
  torrentCostSecFor: () => 0,
42
- boundBy: async () => readings.boundBy ?? "cpu",
43
42
  runsFor: (session) => [...(session.runs ?? [])],
44
- stateFor: (session) => runStateOf(session.runs),
45
- progressFor: (session) => session.progress ?? null
43
+ stateFor: (session) => runStateOf(session.runs)
46
44
  });
47
45
  return { cost, asked: () => asked, host };
48
46
  }
@@ -64,30 +62,28 @@ function sessionProducing(what = {}) {
64
62
  }),
65
63
  file: { key: what.key ?? "torrent:abc:0", name: "film.mkv" },
66
64
  output: { encodeWidth: 0, encodeHeight: 0, outputFps: 25, softwarePreset: null },
67
- progress: { processedSeconds: 0 },
65
+ clock: { at: 1000 },
68
66
  runs: new Set()
69
67
  };
70
- startRunOn(session, { from: 0, speedX: 1 });
68
+ startRunOn(session, { from: 0, clock: session.clock });
71
69
  return session;
72
70
  }
73
71
 
74
72
  /**
75
- * Two readings of one run, far enough apart to be a speed.
73
+ * The session's run measures its speed, and is learned from as the product
74
+ * learns: once the run has a reading.
76
75
  *
77
76
  * @param {EncodeCost} cost
78
77
  * @param {object} session
79
- * @param {number} speedX - Seconds of film produced per second of clock.
78
+ * @param {number} speedX - Seconds of film produced per second of the run's own work.
79
+ * @param {number} [waitedMs] - How long the run's input waits for the swarm
80
+ * between two closed pieces.
80
81
  * @returns {Promise<void>}
81
82
  */
82
- async function watchItRun(cost, session, speedX) {
83
- session.progress.processedSeconds = 0;
84
- await cost.learnFrom(session);
85
- // The pair is a DELTA, and the first reading has nothing to be a delta from.
86
- // Backdated rather than waited for: the window a speed may be read over is
87
- // seconds, and a test that sleeps through it measures the clock.
88
- qualityStateOf(session).learnSample.takenAt -= 10_000;
89
- session.progress.processedSeconds = 10 * speedX;
90
- await cost.learnFrom(session);
83
+ async function watchItRun(cost, session, speedX, waitedMs = 0) {
84
+ const run = [...session.runs][0];
85
+ measureSpeed(run, speedX, session.clock, waitedMs);
86
+ await cost.learnFrom(session, run);
91
87
  }
92
88
 
93
89
  test("the host is asked at the moment of the question, not when this was built", () => {
@@ -163,17 +159,16 @@ test("a copy is priced from what it was seen doing", async () => {
163
159
  );
164
160
  });
165
161
 
166
- test("a reading taken while the swarm is short is not filed as the price of this host", async () => {
167
- // A run starved of torrent data reports a speed that measures the swarm. Filed
168
- // as a price it reads as more work per second than the machine has, and every
169
- // quality step is then refused on the download's account.
170
- const { cost } = costOn({ boundBy: "download" });
162
+ test("the time a run's input waited for the swarm is not in the price of this host", async () => {
163
+ // Field 2026-10-01: a copy waited 32.93 s and 43.91 s for two pieces and read
164
+ // 0.21x over the clock. Over its own working time it is the machine's speed.
165
+ const { cost } = costOn();
171
166
  const session = sessionProducing({ transcodeVideo: false });
172
167
 
173
- await watchItRun(cost, session, 0.3);
168
+ await watchItRun(cost, session, 8, 9_000);
174
169
 
175
- assert.equal(cost.copyVersionFor("torrent:abc:0"), 0, "nothing was learned about the machine");
176
- assert.equal(qualityStateOf(session).lastAloneSpeed, undefined, "and nothing claims this rung was seen failing");
170
+ assert.equal(qualityStateOf(session).lastAloneSpeed, 8, "the speed is the run's own, not the swarm's");
171
+ assert.equal(cost.copyVersionFor("torrent:abc:0"), 1, "and it is filed as what copying costs here");
177
172
  });
178
173
 
179
174
  test("a picture's reading taken beside another encoder is not filed", async () => {
@@ -189,30 +184,27 @@ test("a picture's reading taken beside another encoder is not filed", async () =
189
184
  assert.equal(cost.copyVersionFor("torrent:abc:0"), 0);
190
185
  });
191
186
 
192
- test("one reading is not a speed", async () => {
187
+ test("a run that has measured nothing teaches nothing", async () => {
193
188
  const { cost } = costOn();
194
189
  const session = sessionProducing({ transcodeVideo: false });
195
- session.progress.processedSeconds = 40;
190
+ const run = [...session.runs][0];
196
191
 
197
- await cost.learnFrom(session);
192
+ await cost.learnFrom(session, run);
198
193
 
199
- assert.equal(cost.copyVersionFor("torrent:abc:0"), 0, "a delta needs two readings of one run");
200
- assert.ok(qualityStateOf(session).learnSample, "and the first is kept to be the other half of the next");
194
+ assert.equal(run.speedReading, null, "a speed needs two closed pieces of one run");
195
+ assert.equal(cost.copyVersionFor("torrent:abc:0"), 0);
201
196
  });
202
197
 
203
- test("a pair that straddles a restart measures the seek, not the host", async () => {
198
+ test("a restarted run starts its own reckoning, so a seek is never read as speed", async () => {
199
+ // Twenty minutes of film against five seconds of work is a seek, and filed as
200
+ // a price it admits every step there is. The reading belongs to the run that
201
+ // made it, so a new run at another place has none until its own pieces close.
204
202
  const { cost } = costOn();
205
203
  const session = sessionProducing({ transcodeVideo: false });
206
- await cost.learnFrom(session);
207
- qualityStateOf(session).learnSample.takenAt -= 10_000;
208
- // A new run, at another place in the film: twenty minutes of film against five
209
- // seconds of clock is a seek, and filed as a price it admits every step there
210
- // is.
211
204
  session.runs = new Set();
212
- startRunOn(session, { from: 300, speedX: 1 });
213
- session.progress.processedSeconds = 1200;
205
+ const moved = startRunOn(session, { from: 300, clock: session.clock });
214
206
 
215
- await cost.learnFrom(session);
207
+ await cost.learnFrom(session, moved);
216
208
 
217
209
  assert.equal(cost.copyVersionFor("torrent:abc:0"), 0);
218
210
  });
@@ -12,6 +12,7 @@ import test from "node:test";
12
12
  import assert from "node:assert/strict";
13
13
  import { EventEmitter } from "node:events";
14
14
  import { EncodeRun } from "../services/encode/EncodeRun.js";
15
+ import { measureSpeed } from "./helpers/encode-run.js";
15
16
  import { ENCODE_EXIT } from "../services/encode/encode-exit.js";
16
17
  import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
17
18
  import { EncodeOrchestrator } from "../services/encode/EncodeOrchestrator.js";
@@ -33,10 +34,16 @@ const RUN_COSTS = { killCostSec: 0, firstByteWaitSec: 0.12 };
33
34
 
34
35
  const PICTURE = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
35
36
 
37
+ /** What every run of these tests reads as now; a measurement advances it. */
38
+ const clock = { at: 1000 };
39
+
36
40
  class FakeProcess extends EventEmitter {
37
41
  constructor() {
38
42
  super();
39
43
  this.pid = 1;
44
+ // The progress channel and the channel closed pieces are named on.
45
+ this.stdout = new EventEmitter();
46
+ this.stdio = [null, this.stdout, null, new EventEmitter()];
40
47
  this.signals = [];
41
48
  }
42
49
 
@@ -123,7 +130,7 @@ function orchestrator({ maxRuns = 2 } = {}) {
123
130
  buildArgs: () => ["-i", "in", "out"],
124
131
  spawn: () => process_,
125
132
  logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
126
- now: () => 1000,
133
+ now: () => clock.at,
127
134
  onEnded: (ended) => made.noteEnded(ended)
128
135
  });
129
136
  // Filed under the run itself: a run has no name, so a test that has to
@@ -146,7 +153,7 @@ function orchestrator({ maxRuns = 2 } = {}) {
146
153
  buildArgs: () => ["-i", "in", "out"],
147
154
  spawn: () => process_,
148
155
  logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
149
- now: () => 1000,
156
+ now: () => clock.at,
150
157
  onEnded: (ended) => made.noteEnded(ended)
151
158
  });
152
159
  processes.set(run, process_);
@@ -210,7 +217,7 @@ test("an encoder already working covers what it will reach in time", () => {
210
217
  // wanted at all.
211
218
  const { made, buildRun } = orchestrator();
212
219
  const first = buildRun({ from: 0, to: -1 });
213
- first.noteSpeed(8);
220
+ measureSpeed(first, 8, clock);
214
221
  made.adopt(PICTURE, first);
215
222
  made.noteProduced(PICTURE, 0);
216
223
  made.noteProduced(PICTURE, 1);
@@ -240,7 +247,7 @@ test("somebody stopped where no encoder can arrive in time is served, and the sc
240
247
  // seconds anybody spends looking at a spinner — outranks it.
241
248
  const { made, buildRun } = orchestrator();
242
249
  const first = buildRun({ from: 0, to: -1 });
243
- first.noteSpeed(1);
250
+ measureSpeed(first, 1, clock);
244
251
  made.adopt(PICTURE, first);
245
252
  made.noteProduced(PICTURE, 0);
246
253
  made.noteProduced(PICTURE, 1);
@@ -260,7 +267,7 @@ test("two encoders on one output never share a segment number", () => {
260
267
  // what that neighbour makes sooner.
261
268
  const { made, buildRun } = orchestrator();
262
269
  const first = buildRun({ from: 0, to: -1 });
263
- first.noteSpeed(1);
270
+ measureSpeed(first, 1, clock);
264
271
  made.adopt(PICTURE, first);
265
272
  made.noteProduced(PICTURE, 0);
266
273
  wants(made, [{ from: 200, to: 230, withinSeconds: 0 }]);
@@ -294,7 +301,7 @@ test("a second viewer far behind is served, at whatever the score says is soones
294
301
  wants(made, [{ from: 500, to: 530, withinSeconds: 0 }]);
295
302
  made.reconcile();
296
303
  for (const run of made.runsOn(PICTURE)) {
297
- run.noteSpeed(2);
304
+ measureSpeed(run, 2, clock);
298
305
  }
299
306
  wants(made, [
300
307
  { from: 500, to: 530, withinSeconds: 0 },
@@ -359,7 +366,7 @@ test("a run that meets material made elsewhere is moved past it", () => {
359
366
  for (let index = 102; index <= 150; index += 1) {
360
367
  made.coverageOf(PICTURE).markReady(index);
361
368
  }
362
- first.noteSpeed(1);
369
+ measureSpeed(first, 1, clock);
363
370
  made.reconcile();
364
371
  const runs = made.runsOn(PICTURE);
365
372
  assert.equal(runs.length, 1, "one encoder, moved rather than joined by another");
@@ -407,7 +414,7 @@ test("the swarm limits the encoders, whatever the processor allows", () => {
407
414
  const { made, lines } = orchestrator({ maxRuns: 2 });
408
415
  wants(made, [{ from: 100, to: 130 }]);
409
416
  made.reconcile();
410
- made.runsOn(PICTURE)[0].noteSpeed(8);
417
+ measureSpeed(made.runsOn(PICTURE)[0], 8, clock);
411
418
  wants(made, [{ from: 500, to: 530 }]);
412
419
  made.reconcile();
413
420
 
@@ -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
  });