@torrent-tv/proxy 2.89.3 → 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 +12 -0
  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
@@ -24,21 +24,6 @@
24
24
 
25
25
  import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor, nominalKbpsFor } from "../args.js";
26
26
 
27
- /**
28
- * How much of a measured link may be spent on the stream.
29
- *
30
- * The rest is what the link does when it is not being perfect: transport
31
- * framing, retransmission, the other tabs, the moment somebody else in the
32
- * house starts something. A load sized to the whole reading stalls on the
33
- * first of those.
34
- *
35
- * Chosen, not measured, and written as a constant rather than dressed up as a
36
- * measurement. What would replace it is a reading of the transport's own
37
- * overhead and of how far a link's throughput varies over a session, which
38
- * nothing takes.
39
- */
40
- export const LINK_SAFETY = 0.8;
41
-
42
27
  /** How far a figure in a load can be trusted. */
43
28
  export const PEAK_CLASS = Object.freeze({
44
29
  KNOWN: "known",
@@ -279,7 +264,11 @@ export function linkCouldCarry(linkMbps, load) {
279
264
  if (load.peakClass === PEAK_CLASS.UNKNOWN) {
280
265
  return answer(LINK_VERDICT.NO_SAFE_BOUND, false, false);
281
266
  }
282
- if (linkMbps * LINK_SAFETY < load.totalMbps) {
267
+ // The measured link against the whole load, with no share held back. The
268
+ // reading is what the link carried while it carried a piece, and the stream
269
+ // needs that on average; what a link does when it is not perfect — a pause,
270
+ // another tab — is what the viewer's buffer and `drain-threat.js` measure.
271
+ if (linkMbps < load.totalMbps) {
283
272
  return answer(LINK_VERDICT.DOES_NOT_FIT, false, false);
284
273
  }
285
274
  return load.peakClass === PEAK_CLASS.KNOWN
@@ -338,7 +327,6 @@ export function linkAnswerFigures(answer) {
338
327
  return {
339
328
  verdict: answer.verdict,
340
329
  linkMbps: answer.linkMbps,
341
- linkSafety: LINK_SAFETY,
342
330
  videoMbps: answer.load.video?.mbps ?? null,
343
331
  videoClass: answer.load.video?.peakClass ?? null,
344
332
  audioMbps: answer.load.audio?.mbps ?? null,
@@ -11,6 +11,9 @@
11
11
  * sized in seconds of playback by whoever knows the file's byte rate.
12
12
  */
13
13
 
14
+ /** The query parameter naming which run of an output a read feeds. */
15
+ const RUN_PARAMETER = "run";
16
+
14
17
  /**
15
18
  * @param {object} params
16
19
  * @param {{ fileIndex: number, streamUrl: (baseUrl: string, options?: { sessionId?: string }) => URL }} params.picture
@@ -20,15 +23,23 @@
20
23
  * @param {"video-only" | "audio-only" | "muxed" | "empty"} params.carries
21
24
  * @param {boolean} params.audioSeparate - The picture's sound travels as its own output.
22
25
  * @param {string} params.sessionId - Which output this read feeds.
26
+ * @param {number} [params.runToken] - Which run of that output the read feeds,
27
+ * so the time its input waits is charged to that run and not to another one
28
+ * working on the same output.
23
29
  * @param {number} params.readWindowBytes - Zero when no window was measured.
24
30
  * @param {string} params.baseUrl
25
31
  * @returns {{ inputFile: object, audioFile: object, inputUrl: string, audioInputUrl: string }}
26
32
  */
27
- export function encoderInputs({ picture, soundtrack, carries, audioSeparate, sessionId, readWindowBytes, baseUrl }) {
33
+ export function encoderInputs({ picture, soundtrack, carries, audioSeparate, sessionId, runToken, readWindowBytes, baseUrl }) {
28
34
  // A soundtrack shipped as its own file is encoded FROM that file, so a
29
35
  // rendition of it reads nothing else.
30
36
  const inputFile = carries === "audio-only" && soundtrack !== picture ? soundtrack : picture;
31
37
  const input = inputFile.streamUrl(baseUrl, { sessionId });
38
+ const forRun = (url) => {
39
+ if (Number.isInteger(runToken)) url.searchParams.set(RUN_PARAMETER, String(runToken));
40
+ return url;
41
+ };
42
+ forRun(input);
32
43
  if (Number.isFinite(readWindowBytes) && readWindowBytes > 0) {
33
44
  input.searchParams.set("windowBytes", String(readWindowBytes));
34
45
  input.searchParams.set("reader", "playback");
@@ -36,7 +47,7 @@ export function encoderInputs({ picture, soundtrack, carries, audioSeparate, ses
36
47
  // The second input exists only for a picture that carries its sound inside
37
48
  // itself when that sound is in another file.
38
49
  const audioInputUrl = carries !== "audio-only" && !audioSeparate && soundtrack !== inputFile
39
- ? soundtrack.streamUrl(baseUrl, { sessionId }).toString()
50
+ ? forRun(soundtrack.streamUrl(baseUrl, { sessionId })).toString()
40
51
  : "";
41
52
  return { inputFile, audioFile: soundtrack, inputUrl: input.toString(), audioInputUrl };
42
53
  }
@@ -607,7 +607,9 @@ export class SegmentServing {
607
607
  : this.#host.runStartTimeFor(session, this.#host.encodeRuns.earliestStartOf(session) ?? 0);
608
608
  const position = Number(progress?.processedSeconds);
609
609
  const produced = Number.isFinite(position) ? position - runStartSeconds : null;
610
- const speed = progress?.speed || "n/a";
610
+ const measured = this.#host.encodeRuns.liveRunsOf(session)
611
+ .reduce((best, run) => Math.max(best, run.speedX || 0), 0);
612
+ const speed = measured > 0 ? `${measured.toFixed(2)}x` : "n/a";
611
613
  logger.warn(
612
614
  `transcode ${session.id} holding ${fileName}: ${reason} ` +
613
615
  `(runs from #${this.#host.encodeRuns.earliestStartOf(session) ?? "?"}, viewer at #${this.#host.outputTimes.segmentIndexForTime(session, this.#host.viewerSecondsOn(session))}, ` +
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { logger } from "../../utils/logger.js";
11
- import { isOutputName, SEGMENT_CUT_TIME_DELTA_SECONDS } from "../encode/output/index.js";
11
+ import { isOutputName } from "../encode/output/index.js";
12
12
 
13
13
  function isWarmupTimeoutError(error) {
14
14
  if (!(error instanceof Error)) {
@@ -484,7 +484,6 @@ export class ViewerRequests {
484
484
  // segment (the only thing it waits for before playback starts) instead
485
485
  // of a percentage of the whole-file transcode.
486
486
  segmentDurationSec: this.#host.segmentDurationSec,
487
- speed: progress.speed,
488
487
  outputMbps,
489
488
  playbackReadiness,
490
489
  // The height the viewer is WATCHING right now, which is what the menu
@@ -566,11 +565,7 @@ export class ViewerRequests {
566
565
  const timeline = output.timeline;
567
566
  const trackName = output === session ? "video" : "audio";
568
567
  const nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
569
- this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
570
- startSeconds: timeline.publishedStartOf(index),
571
- endSeconds: index < timeline.segmentCount - 1 ?
572
- timeline.publishedStartOf(index + 1) - SEGMENT_CUT_TIME_DELTA_SECONDS : undefined
573
- }));
568
+ this.#host.encodeRuns.finishedMediaRangesOf(output, index));
574
569
  // The page states the offset its player applied to this track, and the
575
570
  // init it applied it to is the session's. Until both are known, the
576
571
  // pieces stay on their own presentation timeline: there is nothing yet
@@ -247,7 +247,10 @@ export function wireOutputs({
247
247
  pathFor: (address) => parts.segmentStore.pathFor(address),
248
248
  initOf: (address) => parts.segmentStore.initOf(address),
249
249
  directoryFor: (address) => parts.segmentStore.directoryFor(address),
250
- publish: (address, makingName, format, coverage) => parts.segmentStore.publish(address, makingName, format, coverage)
250
+ publish: (address, makingName, format, read) => parts.segmentStore.publish(address, makingName, format, read),
251
+ closedBytesOf: (address, makingName) => parts.segmentStore.closedBytesOf(address, makingName),
252
+ mediaRangesOf: (address, index, where) => parts.segmentStore.mediaRangesOf(address, index, where),
253
+ remove: (address, index, because) => parts.segmentStore.remove(address, index, because)
251
254
  };
252
255
  const segmentOutputFiles = {
253
256
  addresses: () => parts.segmentStore.addresses(),
@@ -257,7 +260,8 @@ export function wireOutputs({
257
260
  useFormat: (address, format) => parts.segmentStore.useFormat(address, format)
258
261
  };
259
262
  const segmentPaths = {
260
- pathOf: (address, index) => parts.segmentStore.pathOf(address, index)
263
+ pathOf: (address, index) => parts.segmentStore.pathOf(address, index),
264
+ sizesOf: (address) => parts.segmentStore.sizesOf(address)
261
265
  };
262
266
  /**
263
267
  * Whether everything this output will ever serve is made: every segment of
@@ -303,6 +307,11 @@ export function wireOutputs({
303
307
  // The encoders of this proxy: built where the plan places them, followed while they run, accounted when they end.
304
308
  parts.encodeRuns = new EncodeRuns({
305
309
  logger,
310
+ // What this host is doing with an encode is learned each time a run has
311
+ // measured its processing speed, on every host.
312
+ noteRunSpeedMeasured: (session, run) => {
313
+ void parts.encodeCost.learnFrom(session, run);
314
+ },
306
315
  // What an admitted encode was seen to do, filed under its configuration.
307
316
  observeEncodeEnded: (session) => {
308
317
  if (!parts.localObservations || !session?.spec?.video?.encode) {
@@ -317,7 +326,6 @@ export function wireOutputs({
317
326
  });
318
327
  },
319
328
  viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
320
- noteRunStarting: (output) => parts.quality.noteRunStarting(output),
321
329
  inputOf: (...args) => parts.renditions.inputOf(...args),
322
330
  producedNumbers: (...args) => parts.serving.producedNumbers(...args),
323
331
  servesAudioSeparately: (...args) => parts.renditions.servesAudioSeparately(...args),
@@ -809,11 +817,8 @@ export function wireOutputs({
809
817
  runningEncoders: () => parts.encodeOrchestrator.runningCount(),
810
818
  encodersRunningNow: () => parts.encodeOrchestrator.runningCount(),
811
819
  torrentCostSecFor: (session) => parts.hostLoad.torrentCostSecFor(session),
812
- boundBy: (session) => parts.quality.classifyTranscodeBound(session),
813
820
  runsFor: (session) => parts.encodeRuns.runsOf(session),
814
- stateFor: (session) => parts.encodeRuns.runStateOf(session),
815
- // The run's OWN progress: a speed is a pair of readings of one process.
816
- progressFor: (_session, run) => run?.progress?.snapshot?.() ?? null
821
+ stateFor: (session) => parts.encodeRuns.runStateOf(session)
817
822
  });
818
823
  // WHICH HEIGHTS ARE ON THE MENU, which is the arithmetic above plus three
819
824
  // things that are nothing to do with it: whose answer it is, what may never
@@ -421,33 +421,24 @@ export class SegmentStore {
421
421
  * over with the picture frozen at 319.66 s. `segment-00055.mp4` went the same
422
422
  * way, 211 957 against 2 620 617.
423
423
  *
424
+ * Whether the piece is whole is decided before this, by the encoding
425
+ * (`encode/piece-completeness.js`): the store keeps bytes and what is read
426
+ * from them, and does not judge them.
427
+ *
424
428
  * @param {string} key
425
429
  * @param {string} makingName - What the encoder called it while writing.
426
- * @param {{ servedNameOf?: (name: string) => string | null }} format
430
+ * @param {{ servedNameOf?: (name: string) => string | null, segmentIndexFromName?: (name: string) => number }} format
431
+ * @param {{ mediaRanges?: object | null }} [read] - The piece's media
432
+ * intervals as its format read them from the closed file, kept with it.
427
433
  * @returns {string | null} The served name, or null where nothing was renamed.
428
434
  */
429
- publish(key, makingName, format, { endSeconds } = {}) {
435
+ publish(key, makingName, format, { mediaRanges = null } = {}) {
430
436
  const served = format?.servedNameOf?.(makingName) ?? null;
431
437
  if (!served) {
432
438
  return null;
433
439
  }
434
440
  const dir = path.join(this.#root, directoryNameFor(key));
435
441
  const index = format?.segmentIndexFromName?.(served) ?? -1;
436
- let mediaRanges = null;
437
- if (format?.readMediaRanges && index >= 0) {
438
- try {
439
- mediaRanges = format.readMediaRanges(readFileSync(path.join(dir, makingName)));
440
- const end = format.producedThroughSeconds?.(mediaRanges);
441
- if (end === null) throw new Error("The closed file contains no playable media intervals.");
442
- if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
443
- this.#logger?.warn?.(`segment store: refusing short segment ${index}: produced through ${end}s, cut ${endSeconds}s`);
444
- return null;
445
- }
446
- } catch (error) {
447
- this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
448
- return null;
449
- }
450
- }
451
442
  try {
452
443
  renameSync(path.join(dir, makingName), path.join(dir, served));
453
444
  } catch (error) {
@@ -476,20 +467,32 @@ export class SegmentStore {
476
467
  return served;
477
468
  }
478
469
 
479
- mediaRangesOf(key, index, { startSeconds = 0, endSeconds } = {}) {
470
+ /**
471
+ * The bytes of a file the encoder has closed under its working name, for the
472
+ * encoding to judge before it is published.
473
+ *
474
+ * @param {string} key
475
+ * @param {string} makingName
476
+ * @returns {Buffer}
477
+ */
478
+ closedBytesOf(key, makingName) {
479
+ return readFileSync(path.join(this.#root, directoryNameFor(key), makingName));
480
+ }
481
+
482
+ /**
483
+ * The media intervals a stored piece holds, read once from its bytes and kept
484
+ * with them, or undefined where its format reads none or it is not here.
485
+ *
486
+ * @param {string} key
487
+ * @param {number} index
488
+ * @param {{ startSeconds?: number }} [where] - Where its cut puts it.
489
+ * @returns {object | undefined}
490
+ */
491
+ mediaRangesOf(key, index, { startSeconds = 0 } = {}) {
480
492
  const known = this.#mediaRanges.get(key)?.get(index);
481
493
  const format = this.#formats.get(key);
482
494
  if (!format?.readMediaRanges) return undefined;
483
- const checked = (ranges) => {
484
- const end = format.producedThroughSeconds?.(ranges);
485
- if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
486
- this.#logger?.warn?.(`segment store: removing short cached segment ${index}: produced through ${end}s, cut ${endSeconds}s`);
487
- this.#removeSegment(key, index);
488
- return undefined;
489
- }
490
- return ranges;
491
- };
492
- if (known) return checked(known);
495
+ if (known) return known;
493
496
  const filePath = this.pathOf(key, index);
494
497
  if (!filePath) return undefined;
495
498
  try {
@@ -506,7 +509,7 @@ export class SegmentStore {
506
509
  const byIndex = this.#mediaRanges.get(key) ?? new Map();
507
510
  byIndex.set(index, ranges);
508
511
  this.#mediaRanges.set(key, byIndex);
509
- return checked(ranges);
512
+ return ranges;
510
513
  } catch {
511
514
  return [];
512
515
  }
@@ -1086,6 +1089,20 @@ export class SegmentStore {
1086
1089
  .map(({ key, index }) => ({ key, index }));
1087
1090
  }
1088
1091
 
1092
+ /**
1093
+ * Take one segment off the disk because its owner says it is not usable.
1094
+ *
1095
+ * @param {string} key
1096
+ * @param {number} index
1097
+ * @param {string} because
1098
+ * @returns {void}
1099
+ */
1100
+ remove(key, index, because) {
1101
+ if (this.#removeSegment(key, index) > 0) {
1102
+ this.#logger?.warn?.(`segment store: removed segment ${index} of ${key.slice(0, 60)}: ${because}`);
1103
+ }
1104
+ }
1105
+
1089
1106
  /**
1090
1107
  * Take one segment off the disk.
1091
1108
  *
@@ -141,11 +141,13 @@ export function requiredSpeedFrom(waits) {
141
141
  const lostSec = interruptions
142
142
  .slice(0, last)
143
143
  .reduce((total, one) => total + (one.end - one.start), 0) / 1000;
144
- // A span that is all interruption says the supply delivered nothing at all
145
- // while it was watched. There is no speed that survives that, and saying a
146
- // huge number is less honest than saying it is not a speed question — so the
147
- // largest figure any step is ever compared against is stated and named.
148
- const lostShare = spanSec > 0 ? Math.min(0.99, lostSec / spanSec) : 0;
144
+ // Strictly below one: the span holds at least one gap of positive length
145
+ // (`intervals` above is not empty), and the gaps are the time the supply
146
+ // delivered. So the speed this asks for is finite whatever it measured, and
147
+ // it is not cut down to a figure chosen here: a supply that delivered for a
148
+ // thousandth of the time asks a thousand times realtime, which no re-encode
149
+ // meets and which leaves the copy, as it should.
150
+ const lostShare = spanSec > 0 ? lostSec / spanSec : 0;
149
151
  return {
150
152
  requiredSpeed: 1 / (1 - lostShare),
151
153
  lostShare,
@@ -18,7 +18,7 @@
18
18
 
19
19
  import test from "node:test";
20
20
  import { recordViewerReport } from "../services/viewer/report-intake.js";
21
- import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
21
+ import { fakeProcess as fakeEncoder, measureSpeed, startRunOn } from "./helpers/encode-run.js";
22
22
  import assert from "node:assert/strict";
23
23
  import { SourceFile } from "../services/media/SourceFile.js";
24
24
  import { Timeline } from "../services/encode/output/Timeline.js";
@@ -92,7 +92,7 @@ function fakeSession({ dirPath, transcodeVideo = true, cutGrid = transcodeVideo
92
92
  usesExplicitCuts: false,
93
93
  useSyntheticPlaylist: true,
94
94
  playlistText: "#EXTM3U\n",
95
- progress: { state: "running", processedSeconds: 40, startPositionSeconds: 0, speed: "1.0x" }
95
+ progress: { state: "running", processedSeconds: 40, startPositionSeconds: 0 }
96
96
  };
97
97
  }
98
98
 
@@ -162,7 +162,7 @@ function drainingReports(viewer, linkMbps) {
162
162
  * This viewer's page reports a buffer that grows, on the link named.
163
163
  *
164
164
  * @param {object} viewer
165
- * @param {number} linkMbps
165
+ * @param {number | null} linkMbps - Null when the page has not measured it.
166
166
  * @returns {void}
167
167
  */
168
168
  function fillingReports(viewer, linkMbps) {
@@ -195,12 +195,12 @@ test("a picture that cannot be kept up with is asked for as another VARIANT, and
195
195
  });
196
196
 
197
197
  const sizeBefore = `${session.encodeWidth}x${session.encodeHeight}`;
198
- // Sustained sub-realtime, read as a slope: the run has been slow since well
199
- // before the window, and the reading is from this very run.
200
- qualityStateOf(session).budgetSlowSince = Date.now() - 60_000;
201
- qualityStateOf(session).recentSpeed = { speed: 0.7, at: Date.now(), run: [...session.runs][0] };
198
+ // Below realtime over its own working time, and the viewer's buffer runs
199
+ // dry on its trend although their link carries the stream.
200
+ measureSpeed([...session.runs][0], 0.7);
201
+ drainingReports(manager.viewers.get("viewer"), 80);
202
202
 
203
- await manager.quality.runQualityBudgetOnce();
203
+ await manager.quality.noteViewerReported(session.id, "viewer");
204
204
 
205
205
  assert.equal(
206
206
  `${session.encodeWidth}x${session.encodeHeight}`,
@@ -224,10 +224,10 @@ test("a manual viewer is never sent an automatic quality request", async (t) =>
224
224
  await rm(dirPath, { recursive: true, force: true });
225
225
  });
226
226
  manager.viewers.get("viewer").qualityMode = "manual";
227
- qualityStateOf(session).budgetSlowSince = Date.now() - 60_000;
228
- qualityStateOf(session).recentSpeed = { speed: 0.7, at: Date.now(), run: [...session.runs][0] };
227
+ measureSpeed([...session.runs][0], 0.7);
228
+ drainingReports(manager.viewers.get("viewer"), 80);
229
229
 
230
- await manager.quality.runQualityBudgetOnce();
230
+ await manager.quality.noteViewerReported(session.id, "viewer");
231
231
 
232
232
  assert.equal(manager.viewers.get("viewer").qualityAsk, null);
233
233
  const progress = await manager.viewerRequests.getSessionProgress(BASE_ID, "viewer");
@@ -255,8 +255,8 @@ test("the request reaches the browser in the progress report, and stops once the
255
255
  assert.equal(manager.viewers.get("viewer").qualityAsk, null, "and it is let go of, not merely hidden");
256
256
  });
257
257
 
258
- test("a request the player never follows runs out instead of being repeated for the whole film", async (t) => {
259
- const { manager, dirPath } = await managerWithSession();
258
+ test("a request stands while its conditions hold, and the next report that does not ask for it lets it go", async (t) => {
259
+ const { manager, session, dirPath } = await managerWithSession();
260
260
  t.after(async () => {
261
261
  await manager.lifecycle.disposeAll();
262
262
  // The store closes its watch on the directory as it drops the output, and
@@ -265,14 +265,39 @@ test("a request the player never follows runs out instead of being repeated for
265
265
  await rm(dirPath, { recursive: true, force: true });
266
266
  });
267
267
 
268
- // A viewer on a manual pick ignores every request by design, and so does a
269
- // stream with no variants. Neither is an error; both look the same from here.
268
+ // Asked when their buffer was running dry; no chosen time ends it.
270
269
  manager.viewers.get("viewer").qualityAsk = { height: 480, at: Date.now() - 120_000, reason: "measured" };
270
+ const standing = await manager.viewerRequests.getSessionProgress(BASE_ID, "viewer");
271
+ assert.equal(standing.requestedHeight, 480, "two minutes on, it still stands: nothing has said otherwise");
271
272
 
272
- const progress = await manager.viewerRequests.getSessionProgress(BASE_ID, "viewer");
273
+ // Their next report shows a buffer that grows: what it was asked for is gone.
274
+ // The machine has no room to spare, so nothing else is asked in its place.
275
+ measureSpeed([...session.runs][0], 0.9);
276
+ fillingReports(manager.viewers.get("viewer"), null);
277
+ await manager.quality.noteViewerReported(session.id, "viewer");
273
278
 
279
+ const progress = await manager.viewerRequests.getSessionProgress(BASE_ID, "viewer");
274
280
  assert.equal(progress.requestedHeight, 0);
275
- assert.equal(manager.viewers.get("viewer").qualityAsk, null, "said once and let go");
281
+ assert.equal(manager.viewers.get("viewer").qualityAsk, null, "let go by the judgement that did not repeat it");
282
+ });
283
+
284
+ test("a request the next report asks for again stands", async (t) => {
285
+ const { manager, session, dirPath } = await managerWithSession();
286
+ t.after(async () => {
287
+ await manager.lifecycle.disposeAll();
288
+ await new Promise((resolve) => setImmediate(resolve));
289
+ await rm(dirPath, { recursive: true, force: true });
290
+ });
291
+ measureSpeed([...session.runs][0], 0.7);
292
+ drainingReports(manager.viewers.get("viewer"), 80);
293
+ await manager.quality.noteViewerReported(session.id, "viewer");
294
+ const asked = manager.viewers.get("viewer").qualityAsk;
295
+ assert.ok(asked, "the machine cannot keep up and the buffer runs dry");
296
+
297
+ drainingReports(manager.viewers.get("viewer"), 80);
298
+ await manager.quality.noteViewerReported(session.id, "viewer");
299
+
300
+ assert.equal(manager.viewers.get("viewer").qualityAsk, asked, "the same request, not let go and not made again");
276
301
  });
277
302
 
278
303
  test("a COPIED picture is never asked to slow its encoder, because it has none", async (t) => {
@@ -287,10 +312,10 @@ test("a COPIED picture is never asked to slow its encoder, because it has none",
287
312
 
288
313
  // Whatever this reading says, a copy has no encoder to make cheaper: moving
289
314
  // the viewer to a RE-ENCODED rung costs the machine more, not less.
290
- qualityStateOf(session).budgetSlowSince = Date.now() - 60_000;
291
- qualityStateOf(session).recentSpeed = { speed: 0.4, at: Date.now(), run: [...session.runs][0] };
315
+ measureSpeed([...session.runs][0], 0.4);
316
+ drainingReports(manager.viewers.get("viewer"), 80);
292
317
 
293
- await manager.quality.runQualityBudgetOnce();
318
+ await manager.quality.noteViewerReported(session.id, "viewer");
294
319
 
295
320
  assert.equal(manager.viewers.get("viewer").qualityAsk, null, "the copy path's lever is the viewer's link, not the CPU");
296
321
  });
@@ -467,15 +492,18 @@ test("the way BACK UP exists, one rung at a time", async (t) => {
467
492
  await rm(dirPath, { recursive: true, force: true });
468
493
  });
469
494
 
470
- // The viewer is on 480p and the machine has been ahead of realtime for longer
471
- // than the up window.
495
+ // The viewer is on 480p, the machine makes it ahead of realtime over its own
496
+ // working time, and their buffer grows and holds more than another output
497
+ // takes to be ready. No window has to pass: every term is measured.
472
498
  session.variantHeight = 480;
473
499
  session.encodeWidth = 854;
474
500
  session.encodeHeight = 480;
475
- qualityStateOf(session).recentSpeed = { speed: 2.4, at: Date.now(), run: [...session.runs][0] };
476
- qualityStateOf(session).budgetUpSince = Date.now() - 120_000;
501
+ measureSpeed([...session.runs][0], 2.4);
502
+ // Their link is not measured here: what is under test is the room, and a
503
+ // measured link would also weigh a soundtrack this fixture states no rate for.
504
+ fillingReports(manager.viewers.get("viewer"), null);
477
505
 
478
- await manager.quality.runQualityBudgetOnce();
506
+ await manager.quality.noteViewerReported(session.id, "viewer");
479
507
 
480
508
  assert.ok(manager.viewers.get("viewer").qualityAsk, "for most of this project's life there was no step up at all");
481
509
  assert.equal(
@@ -642,8 +670,9 @@ test("a picture seen larger than the rung on screen is asked one rung up, when t
642
670
  session.variantHeight = 480;
643
671
  session.encodeWidth = 854;
644
672
  session.encodeHeight = 480;
645
- qualityStateOf(session).recentSpeed = { speed: 2.4, at: Date.now(), run: [...session.runs][0] };
673
+ measureSpeed([...session.runs][0], 2.4);
646
674
  manager.viewers.get("viewer").noteVisiblePicture({ width: 1920, height: 1080 });
675
+ fillingReports(manager.viewers.get("viewer"), null);
647
676
 
648
677
  await manager.quality.noteViewerReported(session.id, "viewer");
649
678
 
@@ -696,12 +725,29 @@ test("a step down for the machine goes to the rung the picture seen bounds, wher
696
725
  await rm(dirPath, { recursive: true, force: true });
697
726
  });
698
727
  manager.viewers.get("viewer").noteVisiblePicture({ width: 640, height: 360 });
699
- qualityStateOf(session).budgetSlowSince = Date.now() - 60_000;
700
- qualityStateOf(session).recentSpeed = { speed: 0.7, at: Date.now(), run: [...session.runs][0] };
728
+ measureSpeed([...session.runs][0], 0.7);
729
+ drainingReports(manager.viewers.get("viewer"), 80);
701
730
 
702
- await manager.quality.runQualityBudgetOnce();
731
+ await manager.quality.noteViewerReported(session.id, "viewer");
703
732
 
704
733
  const asked = manager.viewers.get("viewer").qualityAsk?.height;
705
734
  assert.ok(asked, "the machine cannot keep up: a step down is asked");
706
735
  assert.ok(asked <= 360, `not a rung between that the screen cannot show (asked for ${asked}p)`);
707
736
  });
737
+
738
+ test("a machine below realtime whose viewer's buffer is filling moves nothing", async (t) => {
739
+ const { manager, session, dirPath } = await managerWithSession();
740
+ t.after(async () => {
741
+ await manager.lifecycle.disposeAll();
742
+ await new Promise((resolve) => setImmediate(resolve));
743
+ await rm(dirPath, { recursive: true, force: true });
744
+ });
745
+ // What the cushion already holds covers the shortfall: no chosen window of
746
+ // slowness decides, the viewer's own buffer trend does.
747
+ measureSpeed([...session.runs][0], 0.7);
748
+ fillingReports(manager.viewers.get("viewer"), 80);
749
+
750
+ await manager.quality.noteViewerReported(session.id, "viewer");
751
+
752
+ assert.ok(!(manager.viewers.get("viewer").qualityAsk?.height < 720), "no step down while the buffer grows");
753
+ });
@@ -355,7 +355,7 @@ test("the OFFER drops the rungs the host cannot hold, and the master keeps addre
355
355
  useSyntheticPlaylist: true,
356
356
  playlistText: "#EXTM3U\n",
357
357
  segmentCount: 100,
358
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
358
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
359
359
  };
360
360
  manager.outputs.set(session.id, session);
361
361