@torrent-tv/proxy 2.94.2 → 2.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/assets/calibration/NOTICE.md +25 -0
  2. package/assets/calibration/cal-av1-1080-hi.mp4 +0 -0
  3. package/assets/calibration/cal-av1-1080-lo.mp4 +0 -0
  4. package/assets/calibration/cal-av1-480-hi.mp4 +0 -0
  5. package/assets/calibration/cal-av1-480-lo.mp4 +0 -0
  6. package/assets/calibration/cal-mpeg2-1080-hi.mp4 +0 -0
  7. package/assets/calibration/cal-mpeg2-1080-lo.mp4 +0 -0
  8. package/assets/calibration/cal-mpeg2-480-hi.mp4 +0 -0
  9. package/assets/calibration/cal-mpeg2-480-lo.mp4 +0 -0
  10. package/assets/calibration/cal-mpeg4-1080-hi.mp4 +0 -0
  11. package/assets/calibration/cal-mpeg4-1080-lo.mp4 +0 -0
  12. package/assets/calibration/cal-mpeg4-480-hi.mp4 +0 -0
  13. package/assets/calibration/cal-mpeg4-480-lo.mp4 +0 -0
  14. package/package.json +1 -1
  15. package/server.js +0 -2
  16. package/services/encode/EncodeOrchestrator.js +10 -0
  17. package/services/encode/LocalObservations.js +11 -2
  18. package/services/encode/OutputOpening.js +3 -6
  19. package/services/encode/SoftwareEncoder.js +2 -2
  20. package/services/encode/available-share.js +5 -1
  21. package/services/encode/calibration.js +15 -2
  22. package/services/encode/decode-cost-fit.js +12 -0
  23. package/services/encode/host-load.js +34 -0
  24. package/services/encode/hwaccel.js +139 -125
  25. package/services/encode/quality/ColdStarts.js +78 -0
  26. package/services/encode/quality/EncodeCost.js +119 -47
  27. package/services/encode/quality/OutputQualityState.js +2 -0
  28. package/services/encode/quality/QualityController.js +9 -5
  29. package/services/media/container/h264-configuration.js +7 -14
  30. package/services/media/playback-planner.js +10 -20
  31. package/services/server/SegmentServing.js +3 -3
  32. package/services/server/ViewerRequests.js +1 -10
  33. package/services/server/wire-outputs.js +14 -19
  34. package/services/encode/quality/HostTimings.js +0 -249
@@ -79,15 +79,97 @@ export class EncodeCost {
79
79
  #decodeCost = new Map();
80
80
 
81
81
  /**
82
- * What each height was last predicted to do, kept so a session started at
83
- * that height can be compared against the prediction once it runs.
82
+ * What this output is predicted to do on this machine, in the mode it is
83
+ * actually encoded in, kept on the output so that once it runs the field says
84
+ * what the prediction was worth (torrent-tv/meta#3).
84
85
  *
85
- * @type {Map<number, number | null> | null}
86
+ * Computed here, for this output, and not taken from the last offer. Two
87
+ * faults came from taking it from there, both read from the field log of
88
+ * 2026-10-07. The offer prices a height at the CHEAPEST mode, because a
89
+ * height may be offered when any mode can hold it, while the budget then
90
+ * encodes at the best mode that clears the bar: an XviD 360p step was
91
+ * predicted 7.27x at `ultrafast` and measured 1.2-3.4x at `fast`, a ratio of
92
+ * 0.16-0.47 that said nothing about the arithmetic. And the last offer is one
93
+ * answer for the whole process, so an output could be given the figure of
94
+ * another film's offer.
95
+ *
96
+ * The offer's own figure for the height is kept beside it, so the line says
97
+ * both what the offer decided on and what this run was expected to do.
98
+ *
99
+ * @param {object} session
100
+ * @returns {void}
86
101
  */
87
- lastPredictedByHeight = null;
102
+ notePredictionFor(session) {
103
+ const state = qualityStateOf(session);
104
+ state.predictedSpeedWhenOffered = null;
105
+ state.predictedMode = null;
106
+ state.offeredSpeedAtCheapestMode = null;
107
+ const encode = session?.spec?.video?.encode ?? null;
108
+ const benchmark = this.#host().benchmark;
109
+ if (!session?.spec?.transcodesVideo || !encode || !Array.isArray(benchmark) || benchmark.length === 0) {
110
+ return;
111
+ }
112
+ const entry = modeEntryFor(benchmark, encode.preset);
113
+ const priced = {
114
+ source: session.file?.decode ?? null,
115
+ width: encode.width,
116
+ height: encode.height,
117
+ fps: Number(encode.fps) || TRANSCODE_FPS,
118
+ observedDecodeCostSec: null,
119
+ // Everything else this output's family has running. The output itself has
120
+ // no process yet, so it is not in the total.
121
+ concurrentCostSec: this.committedCostOf(session)
122
+ };
123
+ state.offeredSpeedAtCheapestMode = this.#speedOnThisMachine({ ...priced, benchmark });
124
+ if (entry !== null) {
125
+ state.predictedMode = entry.preset;
126
+ state.predictedSpeedWhenOffered = this.#speedOnThisMachine({ ...priced, benchmark: [entry] });
127
+ }
128
+ }
88
129
 
89
- notePredictionFor(session, height) {
90
- qualityStateOf(session).predictedSpeedWhenOffered = this.lastPredictedByHeight?.get(height) ?? null;
130
+ /**
131
+ * The speed an encode is predicted to run at on this machine: the startup
132
+ * measurement of the cheapest mode in `benchmark` at this size, the decode of
133
+ * this source, the work already running beside it, how badly running together
134
+ * goes here, and the share of the machine nobody has been charged for.
135
+ *
136
+ * The one calculation both the offer and the per-output prediction use, so
137
+ * that a ratio between a run and its prediction measures the arithmetic and
138
+ * not a difference between two copies of it.
139
+ *
140
+ * @param {{ benchmark: object[], source: object | null, width: number, height: number, fps: number, observedDecodeCostSec: number | null, concurrentCostSec: number }} params
141
+ * @returns {number | null}
142
+ */
143
+ #speedOnThisMachine({ benchmark, source, width, height, fps, observedDecodeCostSec, concurrentCostSec }) {
144
+ const { speed } = canSustainOutput({
145
+ benchmark,
146
+ decodeModel: this.#host().decodeModel,
147
+ source,
148
+ outputPixelsPerSec: width * height * fps,
149
+ observedDecodeCostSec,
150
+ concurrentCostSec,
151
+ frame: { width, height }
152
+ });
153
+ // The benchmark behind that figure was taken on a QUIET host — one
154
+ // ffmpeg and nothing else. The machine a step will actually run on is
155
+ // also running the kernel, the container and whatever else its owner
156
+ // does, and on the addon host that was measured at 99 % busy with a
157
+ // quarter of it unattributed. Only the unattributed part is charged
158
+ // here: our own encoders are already in `concurrentCostSec` and the
159
+ // proxy's own work is already priced per megabyte moved.
160
+ // Two corrections, and they are different facts about the machine. The
161
+ // availability share removes work nobody has been charged for; the
162
+ // contention penalty says what OUR OWN second job costs, because the
163
+ // budget adds independent prices and this host does not behave that way
164
+ // — the same work measured 2.6× dearer beside one encoder and 3.7×
165
+ // beside two (2026-08-18). `concurrentCostSec` already counts what is
166
+ // committed; this multiplies by how badly running at all together goes.
167
+ const othersRunning = concurrentCostSec > 0 ? this.#encodersRunningNow() : 0;
168
+ const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
169
+ return correctForAvailability(
170
+ speed === null ? null : speed / penalty,
171
+ this.#host().availability
172
+ );
91
173
  }
92
174
 
93
175
  // The last refusal printed. The offer is recomputed on the path that serves
@@ -635,12 +717,6 @@ export class EncodeCost {
635
717
  const kept = [];
636
718
  /** @type {string[]} */
637
719
  const dropped = [];
638
- // What each height was predicted to do on THIS machine, kept so a session
639
- // started at that height can be compared against it once it runs. The
640
- // manager holds the last answer, because the offer is computed on the path
641
- // that serves every request while a session is created elsewhere.
642
- /** @type {Map<number, number | null>} */
643
- const predictedByHeight = new Map();
644
720
  for (const height of heights) {
645
721
  // A rung this session has actually been seen running below realtime is
646
722
  // withdrawn on that evidence, whatever the prediction says. This is the
@@ -707,40 +783,15 @@ export class EncodeCost {
707
783
  0,
708
784
  concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
709
785
  );
710
- const { speed } = canSustainOutput({
786
+ const onThisMachine = this.#speedOnThisMachine({
711
787
  benchmark,
712
- decodeModel: this.#host().decodeModel,
713
788
  source,
714
- outputPixelsPerSec: width * height * fps,
789
+ width,
790
+ height,
791
+ fps,
715
792
  observedDecodeCostSec,
716
- concurrentCostSec: concurrentBesideThis,
717
- frame: { width, height }
793
+ concurrentCostSec: concurrentBesideThis
718
794
  });
719
- // The benchmark behind that figure was taken on a QUIET host — one
720
- // ffmpeg and nothing else. The machine a step will actually run on is
721
- // also running the kernel, the container and whatever else its owner
722
- // does, and on the addon host that was measured at 99 % busy with a
723
- // quarter of it unattributed. Only the unattributed part is charged
724
- // here: our own encoders are already in `concurrentBesideThis` and the
725
- // proxy's own work is already priced per megabyte moved.
726
- // Two corrections, and they are different facts about the machine. The
727
- // availability share removes work nobody has been charged for; the
728
- // contention penalty says what OUR OWN second job costs, because the
729
- // budget adds independent prices and this host does not behave that way
730
- // — the same work measured 2.6× dearer beside one encoder and 3.7×
731
- // beside two (2026-08-18). `concurrentBesideThis` already counts what is
732
- // committed; this multiplies by how badly running at all together goes.
733
- const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
734
- const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
735
- const onThisMachine = correctForAvailability(
736
- speed === null ? null : speed / penalty,
737
- this.#host().availability
738
- );
739
- // Kept against the step's own session, so that when it runs the field
740
- // says what the prediction was worth. Without this the only comparison
741
- // available is between two figures written minutes apart in different
742
- // lines of the log.
743
- predictedByHeight.set(height, onThisMachine);
744
795
  if (onThisMachine !== null && onThisMachine >= bar) {
745
796
  kept.push(height);
746
797
  continue;
@@ -768,9 +819,7 @@ export class EncodeCost {
768
819
  this.#lastOfferLine = line;
769
820
  logger.info(line);
770
821
  }
771
- this.lastPredictedByHeight = predictedByHeight;
772
822
  } else {
773
- this.lastPredictedByHeight = predictedByHeight;
774
823
  this.#lastOfferLine = "";
775
824
  }
776
825
  return kept;
@@ -859,9 +908,12 @@ export class EncodeCost {
859
908
  if (!Number.isFinite(lastSaid) || Math.abs(ratio - lastSaid) > 0.1) {
860
909
  state.lastPredictionRatio = ratio;
861
910
  logger.info(
862
- `prediction ${session.id.slice(0, 8)} ${session.output.encodeHeight || "source"}p: ` +
863
- `predicted ${state.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
864
- `(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)`
911
+ `prediction ${session.id.slice(0, 8)} ${session.output.encodeHeight || "source"}p ` +
912
+ `"${state.predictedMode}": predicted ${state.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
913
+ `(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)` +
914
+ (Number.isFinite(state.offeredSpeedAtCheapestMode)
915
+ ? `; offered on ${state.offeredSpeedAtCheapestMode.toFixed(2)}x at the cheapest mode`
916
+ : "")
865
917
  );
866
918
  }
867
919
  }
@@ -1041,3 +1093,23 @@ export class EncodeCost {
1041
1093
  );
1042
1094
  }
1043
1095
  }
1096
+
1097
+ /**
1098
+ * The startup measurement of the mode an output is encoded in.
1099
+ *
1100
+ * A mode is filed under its setting, or under the encoder's own name where the
1101
+ * encoder has one setting (`calibration.js`), so an output that names no setting
1102
+ * is matched to the one mode there is. Null when the output's setting was not
1103
+ * measured: its prediction is then unknown rather than borrowed from another
1104
+ * mode.
1105
+ *
1106
+ * @param {Array<{ preset: string }>} benchmark
1107
+ * @param {string | null | undefined} preset
1108
+ * @returns {{ preset: string } | null}
1109
+ */
1110
+ export function modeEntryFor(benchmark, preset) {
1111
+ if (typeof preset === "string" && preset.length > 0) {
1112
+ return benchmark.find((entry) => entry.preset === preset) ?? null;
1113
+ }
1114
+ return benchmark.length === 1 ? benchmark[0] : null;
1115
+ }
@@ -12,6 +12,8 @@ export function qualityStateOf(output) {
12
12
  if (!state) {
13
13
  state = {
14
14
  predictedSpeedWhenOffered: null,
15
+ predictedMode: null,
16
+ offeredSpeedAtCheapestMode: null,
15
17
  lastPredictionRatio: null,
16
18
  lastAloneSpeed: undefined,
17
19
  saidNoVariants: undefined,
@@ -51,7 +51,7 @@ export class QualityController {
51
51
  #affirmed = new Set();
52
52
 
53
53
  /**
54
- * @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `expectedFirstSegmentMs`
54
+ * @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `computedPreparationSec`
55
55
  * @param {SegmentPathLookup} host.segmentPaths - Lookup for completed
56
56
  * segment paths needed by quality decisions.
57
57
  */
@@ -103,16 +103,20 @@ export class QualityController {
103
103
  /**
104
104
  * How long another output of the mode on this viewer's screen takes to be
105
105
  * ready here, in seconds: as observed on this host (roadmap item 97, step
106
- * 14), and otherwise this host's time to a first segment. Null when neither
107
- * has been measured.
106
+ * 14), and otherwise computed from this host's measured wait for a first
107
+ * output and this output's own measured speed (torrent-tv/meta#3). Null
108
+ * when neither can be said.
108
109
  *
109
110
  * @param {HlsSession} session
110
111
  * @returns {number | null}
111
112
  */
112
113
  #secondsToReady(session) {
113
114
  const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
114
- const expectedMs = Number.isFinite(observedMs) ? observedMs : (this.#host.expectedFirstSegmentMs?.() ?? null);
115
- return Number.isFinite(expectedMs) && expectedMs >= 0 ? expectedMs / 1000 : null;
115
+ if (Number.isFinite(observedMs) && observedMs >= 0) {
116
+ return observedMs / 1000;
117
+ }
118
+ const computed = this.#host.computedPreparationSec?.(session) ?? null;
119
+ return Number.isFinite(computed) && computed >= 0 ? computed : null;
116
120
  }
117
121
 
118
122
  /**
@@ -21,16 +21,7 @@ export function h264Configuration(avcc) {
21
21
 
22
22
  function readSps(nal, includeOrder = false) {
23
23
  if ((nal[0] & 31) !== 7 || nal[0] & 128) throw new Error("AVC configuration entry is not an SPS.");
24
- const bytes = [];
25
- for (let at = 1, zeroes = 0; at < nal.length; at++) {
26
- const byte = nal[at];
27
- if (zeroes >= 2 && byte === 3) {
28
- if (at + 1 >= nal.length || nal[at + 1] > 3) throw new Error("AVC emulation prevention byte is invalid.");
29
- zeroes = 0; continue;
30
- }
31
- bytes.push(byte); zeroes = byte === 0 ? zeroes + 1 : 0;
32
- }
33
- const bits = new Bits(Buffer.from(bytes));
24
+ const bits = new Bits(unescapeNal(nal));
34
25
  const profile = bits.uint(8), constraints = bits.uint(8), level = bits.uint(8);
35
26
  bits.ue();
36
27
  let chroma = 1, separate = 0, depth = 8;
@@ -131,14 +122,16 @@ export function avcOrderParameters(sps, pps) {
131
122
  return { ...facts.pictureOrder, bottomFieldOrderPresent: bits.uint(1) === 1 };
132
123
  }
133
124
 
125
+ /**
126
+ * Remove every emulation prevention byte: a 0x03 after two zero bytes, whatever follows it. H.264 §7.4.1
127
+ * forbids 0x000003 before a byte above 0x03, but encoders write it (LostFilm's LostCoder) and decoders drop
128
+ * it (ffmpeg's ff_h2645_extract_rbsp), so refusing it refuses files every player plays.
129
+ */
134
130
  export function unescapeNal(nal) {
135
131
  const bytes = [];
136
132
  for (let at = 1, zeroes = 0; at < nal.length; at++) {
137
133
  const byte = nal[at];
138
- if (zeroes >= 2 && byte === 3) {
139
- if (at + 1 >= nal.length || nal[at + 1] > 3) throw new Error("AVC escape byte is invalid.");
140
- zeroes = 0; continue;
141
- }
134
+ if (zeroes >= 2 && byte === 3) { zeroes = 0; continue; }
142
135
  bytes.push(byte);
143
136
  zeroes = byte === 0 ? zeroes + 1 : 0;
144
137
  }
@@ -200,12 +200,6 @@ export function createPlaybackPlanner({
200
200
  localBaseUrl,
201
201
  sourceRegistry,
202
202
  torrentPool,
203
- // Optional. Reports what this host typically takes to produce a session's
204
- // first segment. The browser needs it for the gap between "the file is
205
- // downloaded" and "a segment exists": until now it assumed the pipeline
206
- // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
207
- expectedFirstSegmentMs,
208
- expectedSessionCreateMs,
209
203
  // What the torrent holds beside this picture — its dubs, its subtitle files,
210
204
  // its contact sheets — grouped by whoever knows what a torrent contains. This
211
205
  // layer answers about ONE file and must not read a torrent's file list.
@@ -480,13 +474,11 @@ export function createPlaybackPlanner({
480
474
  return { audioTracks: plan.audioTracks, pending: pendingIndexes.size > 0 };
481
475
  }
482
476
 
483
- function withHostTimings(plan) {
477
+ function withLiveFigures(plan) {
484
478
  const withOffer = {
485
479
  ...plan,
486
- expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
487
- expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
488
- // Answered here for the same reason as the two above: a plan is cached for
489
- // the life of the process, and what this host will serve a file at is not.
480
+ // Answered here, not when the plan is built: a plan is cached for the
481
+ // life of the process, and what this host will serve a file at is not.
490
482
  // It starts as a prediction from the startup benchmarks and is replaced by
491
483
  // what an encoder running on this very source turns out to cost — frozen
492
484
  // into the cache, every later open of the file would hand the browser the
@@ -590,7 +582,7 @@ export function createPlaybackPlanner({
590
582
  };
591
583
  const cached = cache.get(cacheKey);
592
584
  if (cached) {
593
- return withHostTimings(cached);
585
+ return withLiveFigures(cached);
594
586
  }
595
587
  // Where the time before playback goes. `cold-start` already breaks down
596
588
  // everything from the transcode-session request onwards, but the plan
@@ -634,7 +626,7 @@ export function createPlaybackPlanner({
634
626
  subtitleTracks: []
635
627
  };
636
628
  cache.set(cacheKey, plan);
637
- return withHostTimings(plan);
629
+ return withLiveFigures(plan);
638
630
  }
639
631
 
640
632
  // Structural reads state their exact missing bytes through
@@ -710,11 +702,9 @@ export function createPlaybackPlanner({
710
702
  // all.
711
703
  sidecarSubtitles: sidecars.subtitles,
712
704
  sidecarImages: sidecars.images,
713
- // Both host timings are filled in by `withHostTimings` on the way out,
714
- // never here: read at build time they would be frozen into the cached
715
- // plan, which is the bug fixed in 2.9.106.
716
- expectedFirstSegmentMs: null,
717
- expectedSessionCreateMs: null,
705
+ // Filled in by `withLiveFigures` on the way out, never here: read at
706
+ // build time it would be frozen into the cached plan, which is the bug
707
+ // fixed in 2.9.106.
718
708
  offeredHeights: null,
719
709
  // What the offer is computed FROM, kept on the cached plan so the offer
720
710
  // itself can be recomputed on every response. The figures are the
@@ -771,9 +761,9 @@ export function createPlaybackPlanner({
771
761
  // Retain every declared stream count for failed-run diagnostics.
772
762
  streamCounts: probe.streamCounts
773
763
  });
774
- return withHostTimings(plan);
764
+ return withLiveFigures(plan);
775
765
  }
776
- return withHostTimings({ ...plan, pending: true });
766
+ return withLiveFigures({ ...plan, pending: true });
777
767
  }
778
768
  };
779
769
  }
@@ -52,7 +52,7 @@ export class SegmentServing {
52
52
  #host;
53
53
 
54
54
  /**
55
- * @param {object} host - `buildMasterPlaylist`, `declaredTracks`, `publishedGridFor`, `runStartTimeFor`, `cushion`, `encodeOrchestrator`, `encodeRuns`, `hostTimings`, `lookaheadSeconds`, `outputTimes`, `outputs`, `segmentStore`, `startupWaitMs`, `viewers`
55
+ * @param {object} host - `buildMasterPlaylist`, `declaredTracks`, `publishedGridFor`, `runStartTimeFor`, `cushion`, `encodeOrchestrator`, `encodeRuns`, `coldStarts`, `lookaheadSeconds`, `outputTimes`, `outputs`, `segmentStore`, `startupWaitMs`, `viewers`
56
56
  */
57
57
  constructor(host) {
58
58
  this.#host = host;
@@ -288,8 +288,8 @@ export class SegmentServing {
288
288
  }
289
289
 
290
290
  // Cold-start: the time from the create request to a playable first
291
- // segment, measured once per output by the owner of host timings.
292
- const coldStartMs = isPlaylist ? null : this.#host.hostTimings.noteSegmentServed(session);
291
+ // segment, measured once per output.
292
+ const coldStartMs = isPlaylist ? null : this.#host.coldStarts.noteSegmentServed(session);
293
293
  if (coldStartMs !== null) {
294
294
  // Data is flowing again, so the next loss starts its backoff afresh
295
295
  // rather than inheriting the delay of the last one.
@@ -47,7 +47,7 @@ export class ViewerRequests {
47
47
  #rateStateBySession = new WeakMap();
48
48
 
49
49
  /**
50
- * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `expectedFirstSegmentMs`, `expectedSessionCreateMs`, `planEncodersSoon`, `waitUntilReady`, `encodeRuns`, `encodeSpeedReadingOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
50
+ * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `planEncodersSoon`, `waitUntilReady`, `encodeRuns`, `encodeSpeedReadingOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
51
51
  */
52
52
  constructor(host) {
53
53
  this.#host = host;
@@ -531,15 +531,6 @@ export class ViewerRequests {
531
531
  // confirmed), or `no measurement`. The page says which, so an estimate
532
532
  // is never presented as a guarantee.
533
533
  servingVerdict: this.#host.servingVerdictOf(consumerId),
534
- // What this host takes to create a session and to make a first segment.
535
- // Also on the playback plan, but the browser reads that once per file:
536
- // measured 2026-08-06 across four seeks, a proxy that had just restarted
537
- // answered null for both, and every later seek then computed its estimate
538
- // with one term of four — the figure hit zero after 3.5 s of an 11.8 s
539
- // wait and read "starting now" for the remaining 8.4 s. This response is
540
- // polled about every 1.5 s, so carrying them here keeps them current.
541
- expectedSessionCreateMs: this.#host.expectedSessionCreateMs(),
542
- expectedFirstSegmentMs: this.#host.expectedFirstSegmentMs(),
543
534
  updatedAt: progress.updatedAt,
544
535
  error: this.#host.encodeRuns.failureOf(failed ?? session)
545
536
  };
@@ -78,7 +78,7 @@ import { EncodeRuns } from "../encode/EncodeRuns.js";
78
78
  import { EncodeInputs } from "../encode/EncodeInputs.js";
79
79
  import { OutputTimes } from "../encode/OutputTimes.js";
80
80
  import { HostLoad } from "../encode/quality/HostLoad.js";
81
- import { HostTimings } from "../encode/quality/HostTimings.js";
81
+ import { ColdStarts, secondsToFirstPiece } from "../encode/quality/ColdStarts.js";
82
82
  import { BUDGET_CHECK_INTERVAL_MS, QualityController } from "../encode/quality/QualityController.js";
83
83
  import { EncodeOrchestrator } from "../encode/EncodeOrchestrator.js";
84
84
  import { EncodeAdmission } from "../encode/EncodeAdmission.js";
@@ -93,9 +93,8 @@ import { wireMachineBudget } from "../storage/wire.js";
93
93
  import { Returns } from "../storage/returns.js";
94
94
  import { freeBytesFor } from "../storage/free.js";
95
95
  import path from "node:path";
96
- import { contentOf, LocalObservations } from "../encode/LocalObservations.js";
96
+ import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
97
97
  import { configurationKeyOf } from "../encode/fingerprint.js";
98
- import { PROXY_ROOT } from "../encode/quality/HostTimings.js";
99
98
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
100
99
 
101
100
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
@@ -488,7 +487,7 @@ export function wireOutputs({
488
487
  get cushion() { return parts.cushion; },
489
488
  get encodeOrchestrator() { return parts.encodeOrchestrator; },
490
489
  get encodeRuns() { return parts.encodeRuns; },
491
- get hostTimings() { return parts.hostTimings; },
490
+ get coldStarts() { return parts.coldStarts; },
492
491
  get lookaheadSeconds() { return parts.lookaheadSeconds; },
493
492
  get outputTimes() { return parts.outputTimes; },
494
493
  get outputs() { return parts.outputs; },
@@ -539,7 +538,7 @@ export function wireOutputs({
539
538
  get admission() { return parts.admission; },
540
539
  get getCachedMediaInfo() { return parts.getCachedMediaInfo; },
541
540
  get hostLoad() { return parts.hostLoad; },
542
- get hostTimings() { return parts.hostTimings; },
541
+ get coldStarts() { return parts.coldStarts; },
543
542
  get keyframeTables() { return parts.keyframeTables; },
544
543
  get localBaseUrl() { return parts.localBaseUrl; },
545
544
  get outputs() { return parts.outputs; },
@@ -564,8 +563,6 @@ export function wireOutputs({
564
563
  minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
565
564
  getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
566
565
  disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
567
- expectedFirstSegmentMs: (...args) => parts.hostTimings.expectedFirstSegmentMs(...args),
568
- expectedSessionCreateMs: (...args) => parts.hostTimings.expectedSessionCreateMs(...args),
569
566
  planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
570
567
  waitUntilReady: (...args) => parts.serving.waitUntilReady(...args),
571
568
  get encodeRuns() { return parts.encodeRuns; },
@@ -618,12 +615,8 @@ export function wireOutputs({
618
615
  noteServingVerdict: (consumerId, verdict) => noteServingVerdict(parts.viewers, consumerId, verdict),
619
616
  servingVerdictOf: (consumerId) => servingVerdictOf(parts.viewers, consumerId),
620
617
  });
621
- // What this host takes to create an output and to produce its first segment, kept across restarts until the synthetic figure can replace it (CLAUDE.md, host timings).
622
- parts.hostTimings = new HostTimings({
623
- get segmentDurationSec() { return parts.segmentDurationSec; },
624
- get softwarePresetBenchmark() { return parts.softwarePresetBenchmark; },
625
- get stateDir() { return parts.stateDir; },
626
- });
618
+ // How long each output took to its first served segment, said once in the log.
619
+ parts.coldStarts = new ColdStarts();
627
620
  parts.enabled = Boolean(enabled);
628
621
  parts.ffmpegBin = ffmpegBin;
629
622
  parts.sourceInputsFor = typeof sourceInputsFor === "function" ? sourceInputsFor : null;
@@ -776,9 +769,6 @@ export function wireOutputs({
776
769
  fileLengthOf: (session) => parts.hostLoad.fileLengthByKey.get(session.file.key) ?? 0,
777
770
  largestPieceOf: (address) => parts.segmentStore.largestPiece(address)
778
771
  });
779
- // What this host learned last time it ran. Without it every restart shows
780
- // the first viewer a figure with no measurement behind it.
781
- parts.hostTimings.loadHostTimings();
782
772
  // Where produced segments live, addressed by WHAT they are rather than by
783
773
  // which session's encoder wrote them. Two sessions of one output — two
784
774
  // viewers who opened the same film at different places — write into one
@@ -1033,9 +1023,14 @@ export function wireOutputs({
1033
1023
  // (roadmap item 98), as values.
1034
1024
  bufferOf: (output, consumerId, spanSec) => bufferOf(parts.viewers, output, consumerId, spanSec),
1035
1025
  visiblePictureOf: (output, consumerId) => visiblePictureOf(parts.viewers, output, consumerId),
1036
- // How long this host takes to close a first segment: how soon another
1037
- // output could have the piece a viewer needs.
1038
- expectedFirstSegmentMs: () => parts.hostTimings.expectedFirstSegmentMs(),
1026
+ // How soon another output of this mode could have the piece a viewer needs,
1027
+ // computed from this host's measured wait for a first output and the
1028
+ // output's own measured speed.
1029
+ computedPreparationSec: (output) => secondsToFirstPiece({
1030
+ firstByteWaitSec: parts.encodeOrchestrator?.runCostSeconds().firstByteWaitSec ?? 0,
1031
+ segmentDurationSec: parts.segmentDurationSec,
1032
+ speed: parts.encodeCost.speedForOutput(output?.outputKey ?? "")
1033
+ }),
1039
1034
  dropAskOf: (output, consumerId) => dropAskOf(parts.viewers, output, consumerId),
1040
1035
  // EACH viewer's own link and who is present: a thin link decides for the
1041
1036
  // person on it and for nobody else (roadmap item 97, step 11), so the