@torrent-tv/proxy 2.88.4 → 2.88.6

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 (489) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2269 -2258
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -0
  19. package/assets/calibration/audio/aac.m4a +0 -0
  20. package/assets/calibration/audio/ac3.ac3 +0 -0
  21. package/assets/calibration/audio/alac.m4a +0 -0
  22. package/assets/calibration/audio/dts.dts +0 -0
  23. package/assets/calibration/audio/eac3.eac3 +0 -0
  24. package/assets/calibration/audio/flac.flac +0 -0
  25. package/assets/calibration/audio/manifest.json +98 -0
  26. package/assets/calibration/audio/mp2.mp2 +0 -0
  27. package/assets/calibration/audio/mp3.mp3 +0 -0
  28. package/assets/calibration/audio/opus.opus +0 -0
  29. package/assets/calibration/audio/pcm.wav +0 -0
  30. package/assets/calibration/audio/truehd.thd +0 -0
  31. package/assets/calibration/audio/vorbis.ogg +0 -0
  32. package/assets/diagnostics/sctpstate.gdb +31 -31
  33. package/bin/cli.js +684 -684
  34. package/bin/thread-pool.js +30 -30
  35. package/biome.json +412 -411
  36. package/docs/browser-proxy-contract.md +155 -152
  37. package/docs/container-architecture.md +218 -218
  38. package/docs/download-architecture.md +261 -261
  39. package/docs/encode-architecture.md +729 -729
  40. package/docs/encode-run-state.md +96 -96
  41. package/docs/logs.md +97 -97
  42. package/docs/playback-start-forecast.md +56 -0
  43. package/docs/socks5-egress-research.md +114 -114
  44. package/docs/storage-architecture.md +267 -267
  45. package/knip.json +14 -14
  46. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  47. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  48. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  49. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  50. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  51. package/openspec/changes/binary-distribution/proposal.md +53 -53
  52. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  53. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  54. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  55. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  56. package/openspec/changes/cold-start/design.md +98 -98
  57. package/openspec/changes/cold-start/proposal.md +62 -62
  58. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  59. package/openspec/changes/cold-start/tasks.md +48 -48
  60. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  61. package/openspec/changes/disk-cap/proposal.md +37 -37
  62. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  63. package/openspec/changes/disk-cap/tasks.md +19 -19
  64. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  65. package/openspec/changes/proxy-observability/design.md +38 -38
  66. package/openspec/changes/proxy-observability/proposal.md +49 -49
  67. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  68. package/openspec/changes/proxy-observability/tasks.md +21 -21
  69. package/openspec/changes/seek-debounce/design.md +118 -118
  70. package/openspec/changes/seek-debounce/proposal.md +59 -59
  71. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  72. package/openspec/changes/seek-debounce/tasks.md +33 -33
  73. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  74. package/openspec/changes/subtitle-language/proposal.md +50 -50
  75. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  76. package/openspec/changes/subtitle-language/tasks.md +26 -26
  77. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  78. package/openspec/changes/track-selection/design.md +47 -47
  79. package/openspec/changes/track-selection/proposal.md +45 -45
  80. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  81. package/openspec/changes/track-selection/tasks.md +20 -20
  82. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  83. package/openspec/changes/transcode-quality/proposal.md +51 -51
  84. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  85. package/openspec/changes/transcode-quality/tasks.md +63 -63
  86. package/openspec/config.yaml +38 -38
  87. package/package.json +1 -1
  88. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  89. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  90. package/research/double-spawn-2026-09-10.md +171 -171
  91. package/research/handover-2026-09-13.md +209 -209
  92. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  93. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  94. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  95. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  96. package/routes/api/client-logs/post.js +86 -86
  97. package/routes/api/delivery-sink/get.js +72 -72
  98. package/routes/api/link-probe/get.js +51 -51
  99. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  100. package/routes/api/playback-plan/post.js +53 -53
  101. package/routes/api/sources/post.js +36 -36
  102. package/routes/api/sources/stats/get.js +132 -132
  103. package/routes/api/sources/warm/post.js +217 -217
  104. package/routes/api/subtitles/get.js +230 -230
  105. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  106. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  107. package/routes/api/transcode-sessions/post.js +178 -178
  108. package/routes/api/transcode-sessions/progress/get.js +27 -27
  109. package/routes/api/transcode-sessions/release/post.js +41 -41
  110. package/routes/api/transcode-sessions/seek/post.js +63 -63
  111. package/routes/health/get.js +13 -13
  112. package/routes/healthz/get.js +13 -13
  113. package/routes/stream/get.js +380 -380
  114. package/routes/transcode/audio-file/get.js +64 -64
  115. package/routes/transcode/audio-warm/get.js +95 -95
  116. package/routes/transcode/session-file/get.js +52 -52
  117. package/routes/transcode/variant-file/get.js +95 -95
  118. package/routes/transcode/variant-warm/get.js +84 -84
  119. package/scripts/build-audio-samples.mjs +31 -0
  120. package/scripts/render-run-graph.js +173 -173
  121. package/server.js +730 -725
  122. package/services/encode/CoverageMap.js +428 -428
  123. package/services/encode/CushionReport.js +345 -345
  124. package/services/encode/EncodeAdmission.js +376 -376
  125. package/services/encode/EncodeOrchestrator.js +1217 -1217
  126. package/services/encode/EncodePlan.js +1366 -1366
  127. package/services/encode/EncodeRun.js +813 -813
  128. package/services/encode/EncodeRuns.js +1239 -1235
  129. package/services/encode/Encoder.js +142 -142
  130. package/services/encode/EncoderSelection.js +38 -38
  131. package/services/encode/LocalObservations.js +359 -359
  132. package/services/encode/NvencEncoder.js +50 -50
  133. package/services/encode/OutputOpening.js +783 -783
  134. package/services/encode/OutputTimes.js +579 -579
  135. package/services/encode/QsvEncoder.js +58 -58
  136. package/services/encode/Renditions.js +2282 -2282
  137. package/services/encode/RunProgress.js +84 -84
  138. package/services/encode/SegmentDemand.js +95 -95
  139. package/services/encode/SoftwareEncoder.js +121 -121
  140. package/services/encode/V4l2m2mEncoder.js +53 -53
  141. package/services/encode/VaapiEncoder.js +66 -66
  142. package/services/encode/args.js +418 -418
  143. package/services/encode/audio-calibration.js +67 -0
  144. package/services/encode/audio-work-rate.js +15 -0
  145. package/services/encode/available-share.js +89 -89
  146. package/services/encode/calibration.js +361 -361
  147. package/services/encode/contention.js +128 -128
  148. package/services/encode/decode-cost-fit.js +363 -363
  149. package/services/encode/encode-exit.js +114 -114
  150. package/services/encode/encode-run-state.js +551 -551
  151. package/services/encode/encoder-readings.js +45 -45
  152. package/services/encode/fingerprint.js +322 -322
  153. package/services/encode/host-load.js +174 -174
  154. package/services/encode/hwaccel.js +1894 -1894
  155. package/services/encode/index.js +9 -9
  156. package/services/encode/learned-median.js +90 -90
  157. package/services/encode/output/EncodedOutput.js +57 -57
  158. package/services/encode/output/Output.js +131 -131
  159. package/services/encode/output/OutputCatalog.js +414 -414
  160. package/services/encode/output/OutputSpec.js +390 -390
  161. package/services/encode/output/Timeline.js +308 -308
  162. package/services/encode/output/cut-grid.js +107 -107
  163. package/services/encode/output/index.js +24 -20
  164. package/services/encode/output/ladder.js +32 -32
  165. package/services/encode/output/playlists.js +284 -284
  166. package/services/encode/output/rates.js +125 -125
  167. package/services/encode/output-key-format.js +21 -21
  168. package/services/encode/quality/EncodeCost.js +1167 -1150
  169. package/services/encode/quality/HostLoad.js +525 -525
  170. package/services/encode/quality/HostTimings.js +249 -249
  171. package/services/encode/quality/OutputQualityState.js +28 -28
  172. package/services/encode/quality/QualityController.js +935 -935
  173. package/services/encode/quality/QualityOffer.js +416 -416
  174. package/services/encode/quality/drain-threat.js +47 -47
  175. package/services/encode/quality/link-budget.js +349 -349
  176. package/services/encode/quality/output-format.js +319 -319
  177. package/services/encode/quality/serving-output.js +216 -216
  178. package/services/encode/quality/visible-rung.js +57 -57
  179. package/services/encode/run-budget.js +97 -97
  180. package/services/encode/run-command.js +755 -754
  181. package/services/encode/run-costs.js +155 -155
  182. package/services/encode/run-inputs.js +42 -42
  183. package/services/encode/segment-formats/fmp4.js +458 -456
  184. package/services/encode/segment-formats/index.js +85 -85
  185. package/services/encode/segment-formats/init-compat.js +324 -324
  186. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  187. package/services/encode/segment-formats/mpegts.js +134 -134
  188. package/services/encode/start-stop-cost.js +178 -178
  189. package/services/encode/throughput.js +97 -97
  190. package/services/encode/torrent-cost.js +88 -88
  191. package/services/identity/installation-id.js +38 -38
  192. package/services/media/ContainerOrchestrator.js +149 -149
  193. package/services/media/KeyframeTables.js +316 -316
  194. package/services/media/SourceFile.js +298 -298
  195. package/services/media/SubtitleCues.js +520 -520
  196. package/services/media/SubtitleOrchestrator.js +167 -167
  197. package/services/media/audio-inventory.js +299 -298
  198. package/services/media/container/AviContainer.js +266 -266
  199. package/services/media/container/Container.js +609 -609
  200. package/services/media/container/ContainerFactory.js +56 -56
  201. package/services/media/container/KeyframeTable.js +272 -272
  202. package/services/media/container/MatroskaContainer.js +1494 -1494
  203. package/services/media/container/Mp4Container.js +1344 -1344
  204. package/services/media/container/ebml-reader.js +159 -159
  205. package/services/media/ffmpeg-banner.js +260 -260
  206. package/services/media/keyframe-probe.js +157 -157
  207. package/services/media/media-info-probe.js +93 -93
  208. package/services/media/playback-planner.js +931 -931
  209. package/services/media/tracks/AudioTrack.js +143 -143
  210. package/services/media/tracks/ContainerTrack.js +75 -75
  211. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  212. package/services/media/tracks/SubtitleTrack.js +38 -38
  213. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  214. package/services/media/tracks/VideoTrack.js +33 -33
  215. package/services/media/tracks/audio-bound.js +233 -233
  216. package/services/media/tracks/index.js +15 -15
  217. package/services/media/tracks/language-detect.js +174 -174
  218. package/services/server/OutputLifecycle.js +418 -418
  219. package/services/server/SegmentServing.js +1150 -1150
  220. package/services/server/ViewerRequests.js +786 -760
  221. package/services/server/WaitLedger.js +142 -142
  222. package/services/server/controllers/PlaybackController.js +40 -40
  223. package/services/server/controllers/SubtitleController.js +109 -109
  224. package/services/server/http-responses.js +60 -60
  225. package/services/server/playback-readiness.js +516 -708
  226. package/services/server/transcode-session-files.js +325 -325
  227. package/services/server/wire-outputs.js +1020 -995
  228. package/services/storage/Diagnostics.js +202 -202
  229. package/services/storage/MachineBudget.js +235 -235
  230. package/services/storage/allowance.js +107 -107
  231. package/services/storage/core-dumps.js +106 -106
  232. package/services/storage/files/CompletedFiles.js +391 -391
  233. package/services/storage/files/piece-from-whole-file.js +118 -118
  234. package/services/storage/free.js +71 -71
  235. package/services/storage/keep.js +48 -48
  236. package/services/storage/machine-memory.js +56 -56
  237. package/services/storage/memory-report.js +575 -575
  238. package/services/storage/piece-store/piece-disk-store.js +539 -539
  239. package/services/storage/piece-store/piece-lru.js +495 -495
  240. package/services/storage/piece-store/shared-piece-store.js +1937 -1931
  241. package/services/storage/returns.js +103 -103
  242. package/services/storage/segment-store/SegmentStore.js +1255 -1240
  243. package/services/storage/wire.js +175 -175
  244. package/services/torrent/demand/DemandRegister.js +202 -202
  245. package/services/torrent/demand/Urgency.js +137 -137
  246. package/services/torrent/demand/Window.js +118 -118
  247. package/services/torrent/demand/index.js +8 -8
  248. package/services/torrent/demand/pieces.js +140 -140
  249. package/services/torrent/download/SwarmSelection.js +386 -386
  250. package/services/torrent/download/registry.js +102 -102
  251. package/services/torrent/download/withdraw-claim.js +80 -80
  252. package/services/torrent/files.js +455 -455
  253. package/services/torrent/naming.js +619 -619
  254. package/services/torrent/piece-store-of.js +29 -29
  255. package/services/torrent/supply-margin.js +262 -262
  256. package/services/torrent/torrent-pool.js +2688 -2675
  257. package/services/torrent/worker/background-fill.js +213 -213
  258. package/services/torrent/worker/channel.js +271 -271
  259. package/services/torrent/worker/client.js +765 -765
  260. package/services/torrent/worker/fastest-wires.js +342 -342
  261. package/services/torrent/worker/handle-state.js +23 -23
  262. package/services/torrent/worker/held-bytes.js +155 -155
  263. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  264. package/services/torrent/worker/piece-reader.js +1078 -1078
  265. package/services/torrent/worker/pool-adapter.js +371 -371
  266. package/services/torrent/worker/protocol.js +184 -184
  267. package/services/torrent/worker/resume-warm.js +107 -107
  268. package/services/torrent/worker/webrtc-shim.js +219 -219
  269. package/services/torrent/worker/worker.js +1129 -1129
  270. package/services/transport/data-channel-handler.js +1369 -1369
  271. package/services/transport/delivery-probe.js +842 -842
  272. package/services/transport/health-collector.js +53 -53
  273. package/services/transport/nat-classifier.js +231 -231
  274. package/services/transport/packet-witness.js +808 -808
  275. package/services/transport/port-mapper.js +235 -235
  276. package/services/transport/registry-api.js +81 -81
  277. package/services/transport/tunnel-client.js +447 -447
  278. package/services/transport/usrsctp-state.js +175 -175
  279. package/services/transport/webrtc-manager.js +484 -484
  280. package/services/viewer/Assignments.js +340 -340
  281. package/services/viewer/PriorityMap.js +390 -390
  282. package/services/viewer/PriorityOrchestrator.js +325 -325
  283. package/services/viewer/Viewer.js +691 -691
  284. package/services/viewer/Viewers.js +368 -368
  285. package/services/viewer/active-output.js +37 -37
  286. package/services/viewer/audio-start.js +46 -46
  287. package/services/viewer/buffer-trend.js +81 -81
  288. package/services/viewer/choices.js +647 -647
  289. package/services/viewer/positions.js +126 -126
  290. package/services/viewer/report-intake.js +46 -46
  291. package/store/source-registry.js +71 -71
  292. package/test/asset-paths.test.js +28 -28
  293. package/test/audio-calibration.test.js +23 -0
  294. package/test/audio-inventory.test.js +212 -212
  295. package/test/audio-track-bitrate.test.js +40 -40
  296. package/test/auto-quality-step.test.js +707 -707
  297. package/test/available-share.test.js +76 -76
  298. package/test/background-fill.test.js +190 -190
  299. package/test/client-log-file.test.js +110 -110
  300. package/test/completed-files.test.js +115 -115
  301. package/test/concurrent-cost.test.js +148 -148
  302. package/test/container-media-info.test.js +228 -228
  303. package/test/contention.test.js +76 -76
  304. package/test/core-dumps.test.js +57 -57
  305. package/test/coverage-follows-the-disk.test.js +228 -228
  306. package/test/coverage-map.test.js +195 -195
  307. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  308. package/test/cut-times-timeline.test.js +65 -65
  309. package/test/cuts-follow-published-grid.test.js +109 -109
  310. package/test/data-channel-frame.test.js +63 -63
  311. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  312. package/test/dead-torrent-handle.test.js +52 -52
  313. package/test/declared-rates.test.js +116 -116
  314. package/test/declared-tracks.test.js +43 -43
  315. package/test/decode-cost-fit.test.js +202 -202
  316. package/test/decode-cost.test.js +462 -462
  317. package/test/delivery-probe.test.js +393 -393
  318. package/test/demand-register.test.js +206 -206
  319. package/test/dht-bootstrap.test.js +72 -72
  320. package/test/drain-threat.test.js +65 -65
  321. package/test/duplicate-add.test.js +48 -48
  322. package/test/encode-admission.test.js +146 -146
  323. package/test/encode-cost.test.js +283 -283
  324. package/test/encode-exit.test.js +111 -111
  325. package/test/encode-orchestrator.test.js +419 -419
  326. package/test/encode-plan-viewers.test.js +718 -718
  327. package/test/encode-plan.test.js +545 -545
  328. package/test/encode-run-state.test.js +314 -314
  329. package/test/encode-run.test.js +162 -162
  330. package/test/encode-slope.test.js +46 -46
  331. package/test/encoded-output.test.js +39 -39
  332. package/test/encoder-kinds.test.js +122 -122
  333. package/test/encoder-readings.test.js +46 -46
  334. package/test/every-claimant-is-told-its-share.test.js +152 -152
  335. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  336. package/test/failed-start-breaker.test.js +202 -202
  337. package/test/fastest-wires.test.js +143 -143
  338. package/test/ffmpeg-stream-counts.test.js +78 -78
  339. package/test/file-edges.test.js +191 -191
  340. package/test/fmp4-presentation-clock.test.js +162 -147
  341. package/test/fmp4-self-contained.test.js +80 -80
  342. package/test/fmp4-timestamps.test.js +140 -140
  343. package/test/fmp4-track-completeness.test.js +41 -41
  344. package/test/format-keyed-outputs.test.js +78 -78
  345. package/test/health-metrics.test.js +38 -38
  346. package/test/held-request-width.test.js +157 -157
  347. package/test/helpers/encode-run.js +136 -136
  348. package/test/helpers/held-file.js +29 -29
  349. package/test/helpers/manager.js +57 -57
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +102 -102
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +112 -112
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +292 -292
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/memory-budget.test.js +390 -390
  372. package/test/move-cost.test.js +216 -216
  373. package/test/mp4-subtitles.test.js +173 -173
  374. package/test/one-authority.test.js +285 -285
  375. package/test/one-budget.test.js +149 -149
  376. package/test/one-output-one-session.test.js +273 -270
  377. package/test/one-resource-divided.test.js +185 -185
  378. package/test/one-serving-output-per-height.test.js +458 -458
  379. package/test/orchestrator-wired.test.js +206 -206
  380. package/test/output-catalog.test.js +39 -39
  381. package/test/output-shape.test.js +21 -21
  382. package/test/output-spec.test.js +251 -251
  383. package/test/output-speed.test.js +88 -88
  384. package/test/outputs-being-prepared.test.js +40 -40
  385. package/test/packet-witness-ring.test.js +236 -236
  386. package/test/packet-witness.test.js +148 -148
  387. package/test/piece-disk-store.test.js +401 -401
  388. package/test/piece-from-whole-file.test.js +129 -129
  389. package/test/piece-lru.test.js +319 -319
  390. package/test/piece-reader.test.js +162 -162
  391. package/test/piece-selection-offset.test.js +85 -85
  392. package/test/piece-store-eviction.test.js +595 -595
  393. package/test/piece-store-never-refuses.test.js +153 -153
  394. package/test/piece-store-reservations.test.js +403 -403
  395. package/test/piece-store-slow-disk.test.js +159 -159
  396. package/test/piece-tail.test.js +97 -97
  397. package/test/plan-host-timings.test.js +68 -68
  398. package/test/plan-is-steady.test.js +225 -225
  399. package/test/playback-readiness.test.js +483 -453
  400. package/test/playback-start-regressions.test.js +99 -99
  401. package/test/priority-map-download.test.js +174 -174
  402. package/test/priority-map-emptied.test.js +204 -204
  403. package/test/priority-map-per-output.test.js +215 -215
  404. package/test/priority-map.test.js +163 -163
  405. package/test/probe-wedge-certainty.test.js +60 -60
  406. package/test/produced-copy-choice.test.js +213 -213
  407. package/test/published-timeline.test.js +83 -83
  408. package/test/quality-offer.test.js +162 -162
  409. package/test/quality-variants.test.js +1257 -1257
  410. package/test/rate-control-in-the-output.test.js +180 -180
  411. package/test/read-survives-withdrawal.test.js +164 -164
  412. package/test/read-window.test.js +365 -365
  413. package/test/resume-warm.test.js +39 -39
  414. package/test/run-graph-drift.test.js +37 -37
  415. package/test/run-inputs.test.js +80 -80
  416. package/test/run-intervals.test.js +100 -100
  417. package/test/run-position-follows-published-grid.test.js +89 -89
  418. package/test/run-progress.test.js +65 -65
  419. package/test/same-height-switch.test.js +664 -664
  420. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  421. package/test/seek-landing.test.js +109 -109
  422. package/test/seek-target-not-superseded.test.js +195 -195
  423. package/test/segment-closed-on-both-branches.test.js +96 -96
  424. package/test/segment-cut-times.test.js +63 -63
  425. package/test/segment-demand.test.js +80 -80
  426. package/test/segment-hold-supersede.test.js +91 -91
  427. package/test/segment-judged-by-its-own-header.test.js +221 -221
  428. package/test/segment-serve-wiring.test.js +475 -475
  429. package/test/segment-store-eviction.test.js +247 -247
  430. package/test/segment-store.test.js +352 -323
  431. package/test/segment-true-start.test.js +86 -86
  432. package/test/segments-are-shared.test.js +176 -176
  433. package/test/send-chunk-bytes.test.js +86 -86
  434. package/test/serving-existing-output.test.js +105 -105
  435. package/test/serving-output.test.js +102 -102
  436. package/test/shared-piece-store.test.js +310 -310
  437. package/test/sidecar-naming.test.js +142 -142
  438. package/test/soundtrack-rate-bound.test.js +289 -289
  439. package/test/source-file.test.js +133 -133
  440. package/test/source-registry.test.js +18 -18
  441. package/test/split-a-stretch.test.js +139 -139
  442. package/test/stale-request-after-seek.test.js +158 -158
  443. package/test/startup-readings.test.js +124 -124
  444. package/test/stream-route.test.js +209 -209
  445. package/test/subtitle-cue-framing.test.js +200 -200
  446. package/test/subtitle-cue-source.test.js +102 -102
  447. package/test/subtitle-cue-walk.test.js +370 -370
  448. package/test/subtitle-cursor.test.js +63 -63
  449. package/test/subtitle-defaults.test.js +118 -118
  450. package/test/subtitle-language.test.js +252 -252
  451. package/test/subtitle-track-numbering.test.js +386 -386
  452. package/test/supply-margin.test.js +177 -177
  453. package/test/swarm-follows-readers.test.js +174 -174
  454. package/test/swarm-reach.test.js +130 -130
  455. package/test/swarm-selection.test.js +220 -220
  456. package/test/tail-duplication.test.js +167 -167
  457. package/test/the-channel-has-both-ends.test.js +91 -91
  458. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  459. package/test/thread-pool.test.js +53 -53
  460. package/test/timeline.test.js +84 -84
  461. package/test/torrent-contents.test.js +235 -235
  462. package/test/torrent-cost.test.js +71 -71
  463. package/test/torrent-files.test.js +221 -221
  464. package/test/torrent-naming.test.js +255 -255
  465. package/test/tracks-begin-together.test.js +209 -209
  466. package/test/tunnel-renewal.test.js +167 -167
  467. package/test/two-viewers-one-picture.test.js +504 -504
  468. package/test/upload-hurry.test.js +186 -186
  469. package/test/usrsctp-state.test.js +131 -131
  470. package/test/video-facts.test.js +102 -102
  471. package/test/viewer-assignments.test.js +143 -143
  472. package/test/viewer-outputs.test.js +285 -285
  473. package/test/viewer-presence.test.js +116 -116
  474. package/test/viewer-requests-admission.test.js +89 -89
  475. package/test/viewer-states.test.js +189 -189
  476. package/test/viewer-subtitle-subscription.test.js +96 -96
  477. package/test/viewer.test.js +139 -139
  478. package/test/viewing-generations.test.js +390 -390
  479. package/test/visible-rung.test.js +37 -37
  480. package/test/wait-ledger.test.js +100 -100
  481. package/test/webrtc-shim.test.js +149 -149
  482. package/test/wedge-certainty.test.js +131 -131
  483. package/test/withdraw-piece-claim.test.js +212 -212
  484. package/test/worker-channel.test.js +158 -158
  485. package/utils/client-log-file.js +151 -151
  486. package/utils/logger.js +312 -312
  487. package/utils/parse-range.js +25 -25
  488. package/utils/perf.js +59 -59
  489. package/utils/torrent-source-key.js +38 -38
@@ -1,1150 +1,1167 @@
1
- /**
2
- * @file What encoding this file costs THIS machine, and which heights follow.
3
- *
4
- * One subject: seconds of work per second of video. Everything here is that
5
- * question asked about something — a picture being re-encoded, a soundtrack, a
6
- * copy, everything running beside the rung being judged — and the last method
7
- * turns the answers into the list of heights the machine can hold.
8
- *
9
- * It decides what a viewer is offered, so being wrong in either direction costs
10
- * them: too generous and they are given a rung that runs below realtime, which
11
- * is a slideshow; too mean and they are refused quality the host could hold.
12
- *
13
- * Nothing here is chosen. Every figure is a measurement — the startup
14
- * benchmarks, what an encoder has since been seen doing on this very file, what
15
- * a second job costs on this host, what share of the machine is free — and where
16
- * a term has not been measured it contributes nothing rather than a guess.
17
- *
18
- * What it is given, and why each is passed rather than reached for: which
19
- * sessions belong to one file (`outputs`), the host's own readings, how many
20
- * encoders are running, what the file costs merely by being fetched, and whether
21
- * a slow run is short of the machine or short of the swarm. The learned costs it
22
- * holds itself: they are what an encoder taught it, and it is their only writer
23
- * and their only reader.
24
- *
25
- * **Learning them lives here too, and it did not until 2026-09-15.** The three
26
- * methods that turn a reading off a running encoder into a price were methods of
27
- * the session manager, writing into these maps from outside — so a fact had one
28
- * keeper and a different author, and the maps had to be public for it. They are
29
- * private now, and what leaves this class is an answer rather than a store.
30
- */
31
-
32
- /**
33
- * Which cost a speed reading from this session is a measurement OF.
34
- *
35
- * Three encodes share one reading path and price three different things: a
36
- * soundtrack published on its own, a picture being re-encoded (whose reading
37
- * prices this source's DECODING, the encode half being known from the startup
38
- * benchmark), and a picture being copied (which prices copying).
39
- *
40
- * Exported because the routing is where the fault was: a rendition was refused
41
- * a reading by one guard while the call that would have priced it sat behind
42
- * another, so the soundtrack was charged at nothing no matter how long it ran.
43
- * A pure function makes that a test rather than a field session.
44
- *
45
- * @param {{ audioOnly?: boolean, transcodeVideo?: boolean }} session
46
- * @returns {"audio" | "decode" | "copy"}
47
- */
48
- export function costKindForSession(session) {
49
- if (session?.spec?.carries === "audio-only") {
50
- return "audio";
51
- }
52
- return session?.spec?.transcodesVideo === true ? "decode" : "copy";
53
- }
54
-
55
- /** The narrowest stretch of uninterrupted encoding a speed may be read from. */
56
- const LEARN_WINDOW_MIN_SEC = 3;
57
-
58
- import { correctForAvailability } from "../available-share.js";
59
- import { medianOf, movedBeyondScatter, READINGS_KEPT } from "../learned-median.js";
60
- import { speedFromReadings } from "../encoder-readings.js";
61
- import { contentionPenalty } from "../contention.js";
62
- import { ENCODE_RUN_STATE, liveRunsOf, processCanBeSignalled } from "../encode-run-state.js";
63
- import { canSustainOutput, chooseSoftwareEncodeSettings, speedBar } from "../hwaccel.js";
64
- import { throughputAt } from "../throughput.js";
65
- import { computeOutputDimensions, TRANSCODE_FPS } from "../args.js";
66
- import { logger } from "../../../utils/logger.js";
67
- import { qualityStateOf } from "./OutputQualityState.js";
68
-
69
- export class EncodeCost {
70
- /**
71
- * What a soundtrack encoder has been seen to cost, by the key naming that
72
- * track. Written and read here and nowhere else.
73
- *
74
- * @type {Map<string, { costSec: number, readings: number[], version: number }>}
75
- */
76
- #audioCost = new Map();
77
-
78
- /** What copying a file's picture has been seen to cost. @type {Map<string, { costSec: number, readings: number[], version: number }>} */
79
- #copyCost = new Map();
80
-
81
- /** What decoding a file has been seen to cost. @type {Map<string, { costSec: number, readings: number[], version: number }>} */
82
- #decodeCost = new Map();
83
-
84
- /**
85
- * What each height was last predicted to do, kept so a session started at
86
- * that height can be compared against the prediction once it runs.
87
- *
88
- * @type {Map<number, number | null> | null}
89
- */
90
- lastPredictedByHeight = null;
91
-
92
- notePredictionFor(session, height) {
93
- qualityStateOf(session).predictedSpeedWhenOffered = this.lastPredictedByHeight?.get(height) ?? null;
94
- }
95
-
96
- // The last refusal printed. The offer is recomputed on the path that serves
97
- // every playlist, init and segment, and the figures behind it move every few
98
- // seconds — so the line is written when the ANSWER changes, not when it is
99
- // asked again.
100
- #lastOfferLine = "";
101
-
102
- #outputs;
103
- #host;
104
- #runningEncoders;
105
- #encodersRunningNow;
106
- #torrentCostSecFor;
107
- #boundBy;
108
- #runsFor;
109
- #stateFor;
110
- #progressFor;
111
-
112
- /**
113
- * @param {{
114
- * outputs: import("../output/OutputCatalog.js").OutputCatalog,
115
- * host: () => { benchmark: object[] | null, decodeModel: object | null, contentionPenalties: object | null, copySpeedX: number | null, availability: { known: boolean, share: number } | null, encoderKind: string | null },
116
- * runningEncoders: () => number,
117
- * encodersRunningNow: () => number,
118
- * torrentCostSecFor: (session: object) => number,
119
- * boundBy: (session: object) => Promise<"cpu" | "download" | "unknown">,
120
- * runsFor: (output: object) => object[],
121
- * stateFor: (output: object) => string,
122
- * progressFor: (output: object, run: object | null) => object | null - The progress of that one run.
123
- * }} deps
124
- */
125
- constructor({ outputs, host, runningEncoders, encodersRunningNow, torrentCostSecFor, boundBy, runsFor, stateFor, progressFor }) {
126
- this.#outputs = outputs;
127
- // Asked at the moment of the question, not copied: the share of the machine
128
- // that is free is re-read every few seconds, and a copy taken when this was
129
- // built would price every later rung against a machine that has gone.
130
- this.#host = host;
131
- this.#runningEncoders = runningEncoders;
132
- this.#encodersRunningNow = encodersRunningNow;
133
- this.#torrentCostSecFor = torrentCostSecFor;
134
- // WHETHER A SLOW RUN IS SHORT OF THE MACHINE OR SHORT OF THE SWARM, which
135
- // this cannot answer and must not guess at: a run starved of torrent data
136
- // reports a speed that measures the swarm, and filed as a price it refuses
137
- // every quality step on the download's account. Asked of whoever holds the
138
- // torrent's readings; passed in, so this can still be exercised with plain
139
- // values and no swarm.
140
- this.#boundBy = boundBy;
141
- if (typeof runsFor !== "function") {
142
- throw new TypeError("EncodeCost requires runsFor");
143
- }
144
- if (typeof stateFor !== "function") {
145
- throw new TypeError("EncodeCost requires stateFor");
146
- }
147
- if (typeof progressFor !== "function") {
148
- throw new TypeError("EncodeCost requires progressFor");
149
- }
150
- this.#runsFor = runsFor;
151
- this.#stateFor = stateFor;
152
- this.#progressFor = progressFor;
153
- }
154
-
155
- /**
156
- * The key naming one soundtrack of one file.
157
- *
158
- * Static because it is the NAME of a thing this class stores, not a reading
159
- * about the machine — and because the offer's cache key needs it too, so a
160
- * caller that has a session can ask without holding an instance.
161
- *
162
- * Named by everything that changes what producing it costs: the file the
163
- * sound is read from, the track inside that file, and whether it is copied
164
- * or re-encoded. One track may be copied for one viewer and re-encoded for
165
- * another, and a copy's speed priced an encode it says nothing about; and a
166
- * soundtrack shipped as its own file is track 0 of THAT file, which without
167
- * the file in the name shared a price with the picture's own track 0.
168
- *
169
- * @param {{ file: { key: string }, spec?: { audioFileIndex?: number, audioSourceTrackIndex?: number, transcodesAudio?: boolean } }} session
170
- * @returns {string}
171
- */
172
- static audioKeyOf(session) {
173
- const spec = session.spec ?? {};
174
- return `${session.file.key}:a${spec.audioFileIndex ?? ""}:${spec.audioSourceTrackIndex ?? 0}:` +
175
- `${spec.transcodesAudio === true ? "aac" : "copy"}`;
176
- }
177
-
178
- /**
179
- * What decoding this file has been measured to cost, and how many times that
180
- * answer has changed.
181
- *
182
- * @param {string} fileKey
183
- * @returns {{ costSec: number, version: number } | null}
184
- */
185
- decodeCostFor(fileKey) {
186
- const entry = this.#decodeCost.get(fileKey);
187
- return entry ? { costSec: entry.costSec, version: entry.version } : null;
188
- }
189
-
190
- /**
191
- * How many times the price of COPYING this file has changed.
192
- *
193
- * Asked by the offer's cache key: everything the answer is derived from has
194
- * to be in what identifies it, or the menu keeps an answer computed before
195
- * anything was measured.
196
- *
197
- * @param {string} fileKey
198
- * @returns {number}
199
- */
200
- copyVersionFor(fileKey) {
201
- return this.#copyCost.get(fileKey)?.version ?? 0;
202
- }
203
-
204
- /**
205
- * How many times the price of this session's soundtrack has changed.
206
- *
207
- * @param {object} session
208
- * @returns {number}
209
- */
210
- audioVersionFor(session) {
211
- return this.#audioCost.get(EncodeCost.audioKeyOf(session))?.version ?? 0;
212
- }
213
-
214
- /**
215
- * What a running re-encode of the picture costs, in seconds of work per
216
- * second of video.
217
- *
218
- * Measured first: `lastAloneSpeed` is what this very rung did with the
219
- * machine to itself. Failing that, the encode model that decides every rung —
220
- * the same benchmark, the same decode term — applied to this rung's own pixel
221
- * rate. There is no third answer: a rung whose cost cannot be derived at all
222
- * contributes nothing rather than a number somebody invented.
223
- *
224
- * @param {HlsSession} session
225
- * @returns {number}
226
- */
227
- #pictureCostOf(session) {
228
- const state = qualityStateOf(session);
229
- if (Number.isFinite(state.lastAloneSpeed) && state.lastAloneSpeed > 0) {
230
- return 1 / state.lastAloneSpeed;
231
- }
232
- // WHAT AN ENCODE OF THIS MODE WAS SEEN DOING HERE BEFORE, alone, on
233
- // material no easier than this (roadmap item 97, step 14): the slowest
234
- // such reading, on this configuration only. It refines the startup
235
- // prediction; with nothing seen, the prediction stands.
236
- const seen = Number(this.#host().observedAloneSpeed?.(session));
237
- if (Number.isFinite(seen) && seen > 0) {
238
- return 1 / seen;
239
- }
240
- const benchmark = this.#host().benchmark;
241
- const width = Number(session.output.encodeWidth) || 0;
242
- const height = Number(session.output.encodeHeight) || 0;
243
- const fps = Number(session.output.outputFps) || TRANSCODE_FPS;
244
- if (!Array.isArray(benchmark) || benchmark.length === 0 || width <= 0 || height <= 0) {
245
- return 0;
246
- }
247
- const { speed } = canSustainOutput({
248
- benchmark,
249
- decodeModel: this.#host().decodeModel,
250
- source: session.file.decode ?? null,
251
- outputPixelsPerSec: width * height * fps,
252
- observedDecodeCostSec: null,
253
- concurrentCostSec: 0,
254
- frame: { width, height }
255
- });
256
- return Number.isFinite(speed) && speed > 0 ? 1 / speed : 0;
257
- }
258
-
259
- /**
260
- * How fast this machine produces ONE output, in seconds of film per second.
261
- *
262
- * THERE IS ALWAYS AN ANSWER, and that is the point of this method. Every
263
- * decision in the encoding layer is made from arrivals — when would this
264
- * encoder reach that piece — and an arrival cannot be computed without a
265
- * speed. A speed that is missing is therefore not a smaller answer, it is no
266
- * answer at all: the plan then cannot tell a viewer who will be served from
267
- * one who will be left waiting, and the moment it happens is the cold start,
268
- * which is when the question matters most.
269
- *
270
- * Three sources, most specific first, and every one of them measured:
271
- *
272
- * 1. what a run on THIS output has been seen doing. It is this machine, this
273
- * material and these settings, so nothing beats it;
274
- * 2. the startup benchmark, for an output whose picture is re-encoded: the
275
- * preset readings and the decode model, applied to this output's own pixel
276
- * rate. It exists before any viewer;
277
- * 3. the startup copy measurement, for an output whose picture is copied.
278
- * Copying neither decodes nor encodes, so neither of the above describes
279
- * it, and until it was measured this branch had no figure at all.
280
- *
281
- * @param {string} address - The output, as the encoding layer names it.
282
- * @returns {number} Seconds of film per second. Zero only where the host
283
- * measured nothing at all, which is a broken startup rather than a state to
284
- * plan around.
285
- */
286
- speedForOutput(address) {
287
- const outputs = this.#outputs.outputsOn(address);
288
- let measured = 0;
289
- for (const session of outputs) {
290
- const speed = Number(qualityStateOf(session).lastAloneSpeed);
291
- if (Number.isFinite(speed) && speed > measured) {
292
- measured = speed;
293
- }
294
- }
295
- if (measured > 0) {
296
- return measured;
297
- }
298
- for (const session of outputs) {
299
- if (session.spec.transcodesVideo) {
300
- const cost = this.#pictureCostOf(session);
301
- if (cost > 0) {
302
- return 1 / cost;
303
- }
304
- continue;
305
- }
306
- const copying = Number(this.#host().copySpeedX);
307
- if (Number.isFinite(copying) && copying > 0) {
308
- return copying;
309
- }
310
- }
311
- return 0;
312
- }
313
-
314
- /**
315
- * What one encoder on this output costs the machine, in seconds of work per
316
- * second of film, and what its file costs simply by being fetched.
317
- *
318
- * The unit the quality offer already judges every step in, so that "may one
319
- * more encoder run" and "may this height be offered" are the same arithmetic
320
- * and cannot disagree. Three kinds, priced from what each was seen doing:
321
- *
322
- * 1. a soundtrack, from its own measured speed;
323
- * 2. a copied picture, from its own measured speed, and otherwise from the
324
- * startup copy measurement;
325
- * 3. a re-encoded picture, from what it did alone, and otherwise from the
326
- * encode model applied to its own pixel rate.
327
- *
328
- * NULL WHEN NOTHING HAS PRICED IT, and never a guess: a soundtrack nobody has
329
- * measured yet contributes nothing, as it does to the offer. The caller says
330
- * so rather than inventing a figure.
331
- *
332
- * @param {string} address - The output, as the encoding layer names it.
333
- * @returns {{ costSec: number | null, fileKey: string, fileCostSec: number } | null}
334
- * Null when no output of that address is here.
335
- */
336
- loadOfOutput(address) {
337
- const session = this.#outputs.outputsOn(address)[0] ?? null;
338
- if (!session) {
339
- return null;
340
- }
341
- return this.#loadOfSession(session);
342
- }
343
-
344
- /**
345
- * What a not-yet-opened output would cost, priced by the same readings and
346
- * rules as an output already in the catalogue. This lets the machine-wide
347
- * admission answer before the format is committed, so AUTO can select a
348
- * lower rung that the whole host can sustain.
349
- *
350
- * @param {object} spec
351
- * @param {object} file
352
- * @returns {{ costSec: number | null, fileKey: string, fileCostSec: number } | null}
353
- */
354
- loadForCandidate(spec, file) {
355
- if (!spec || !file) {
356
- return null;
357
- }
358
- const encode = spec.video?.encode ?? null;
359
- const candidate = {
360
- spec,
361
- file,
362
- output: {
363
- encodeWidth: Number(encode?.width) || 0,
364
- encodeHeight: Number(encode?.height) || 0,
365
- outputFps: Number(encode?.fps) || TRANSCODE_FPS
366
- }
367
- };
368
- return this.#loadOfSession(candidate);
369
- }
370
-
371
- #loadOfSession(session) {
372
- const fileKey = session.file?.key ?? "";
373
- const fileCost = Number(this.#torrentCostSecFor(session));
374
- const fileCostSec = Number.isFinite(fileCost) && fileCost > 0 ? fileCost : 0;
375
- const measured = Number(qualityStateOf(session).lastAloneSpeed);
376
- if (Number.isFinite(measured) && measured > 0) {
377
- return { costSec: 1 / measured, fileKey, fileCostSec };
378
- }
379
- if (session.spec.carries === "audio-only") {
380
- const audio = this.#audioCost.get(EncodeCost.audioKeyOf(session));
381
- return { costSec: audio && audio.costSec > 0 ? audio.costSec : null, fileKey, fileCostSec };
382
- }
383
- if (!session.spec.transcodesVideo) {
384
- const copy = this.#copyCost.get(session.file.key);
385
- if (copy && copy.costSec > 0) {
386
- return { costSec: copy.costSec, fileKey, fileCostSec };
387
- }
388
- const copying = Number(this.#host().copySpeedX);
389
- return { costSec: Number.isFinite(copying) && copying > 0 ? 1 / copying : null, fileKey, fileCostSec };
390
- }
391
- const picture = this.#pictureCostOf(session);
392
- return { costSec: picture > 0 ? picture : null, fileKey, fileCostSec };
393
- }
394
-
395
- /**
396
- * What everything OTHER than this session is costing right now, or null when
397
- * any of it is unpriced.
398
- *
399
- * Used to recover a soundtrack's own share from a reading taken beside the
400
- * picture — the only kind of reading a rendition ever gives, since it runs
401
- * exactly as long as the picture does. Refusing to answer when something
402
- * running has no price is the point: unpriced work would otherwise be
403
- * attributed to the soundtrack, and an overpriced soundtrack refuses quality
404
- * steps the host could actually hold.
405
- *
406
- * @param {HlsSession} session
407
- * @returns {number | null}
408
- */
409
- pricedConcurrentCost(session) {
410
- let cost = 0;
411
- for (const member of this.#outputs.familyOf(session)) {
412
- if (member === session || !processCanBeSignalled(this.#stateFor(member))) {
413
- continue;
414
- }
415
- if (member.spec.carries === "audio-only") {
416
- const audio = this.#audioCost.get(EncodeCost.audioKeyOf(member));
417
- if (!audio || !(audio.costSec > 0)) {
418
- return null;
419
- }
420
- cost += audio.costSec;
421
- continue;
422
- }
423
- if (!member.spec.transcodesVideo) {
424
- const copy = this.#copyCost.get(member.file.key);
425
- if (!copy || !(copy.costSec > 0)) {
426
- return null;
427
- }
428
- cost += copy.costSec;
429
- continue;
430
- }
431
- const picture = this.#pictureCostOf(member);
432
- if (!(picture > 0)) {
433
- return null;
434
- }
435
- cost += picture;
436
- }
437
- // Encoders outside this family are counted by number only — there is no
438
- // price to look up for another film's session — so a reading taken while
439
- // one is running cannot be attributed either.
440
- return this.#runningEncoders() > this.#outputs.familyOf(session).filter(
441
- (member) => processCanBeSignalled(this.#stateFor(member))
442
- ).length
443
- ? null
444
- : cost;
445
- }
446
-
447
- /**
448
- * What each height of this family is costing RIGHT NOW, for the heights an
449
- * encoder is actually running at.
450
- *
451
- * Exists so a height can be judged against what the machine spends on
452
- * everything else — a step being warmed is running while it is judged, and
453
- * charged its own cost it refuses itself.
454
- *
455
- * @param {HlsSession} session
456
- * @returns {Map<number, number>}
457
- */
458
- runningCostByHeight(session) {
459
- /** @type {Map<number, number>} */
460
- const byHeight = new Map();
461
- for (const member of this.#outputs.familyOf(session)) {
462
- if (member.spec.carries === "audio-only" || !member.spec.transcodesVideo) {
463
- continue;
464
- }
465
- if (!processCanBeSignalled(this.#stateFor(member))) {
466
- continue;
467
- }
468
- const height = this.#outputs.variantHeightOf(member);
469
- if (height > 0) {
470
- byHeight.set(height, (byHeight.get(height) ?? 0) + this.#pictureCostOf(member));
471
- }
472
- }
473
- return byHeight;
474
- }
475
-
476
- /**
477
- * Seconds of work per second of video this family is ALREADY committed to,
478
- * beside any rung being considered.
479
- *
480
- * Every encoder of the family that is actually running: the picture, whether
481
- * it is copied or re-encoded, and each audio rendition. The rung the viewer
482
- * is watching and the source's own copied height are never withdrawn by the
483
- * caller, so charging for the encoder that serves them cannot strand anyone —
484
- * what it does is stop the NEXT rung being offered as though the machine were
485
- * idle, which is what the field disproved on 2026-08-15.
486
- *
487
- * Anything whose cost is neither measured nor derivable contributes nothing.
488
- * A guess here would refuse rungs on arithmetic nobody performed.
489
- *
490
- * @param {HlsSession} session
491
- * @returns {number}
492
- */
493
- committedCostOf(session) {
494
- let cost = 0;
495
- for (const member of this.#outputs.familyOf(session)) {
496
- // Only what still HAS an encoder. A quality step the viewer left keeps
497
- // its session and its segments but not a process, and it produces nothing
498
- // for anybody — charging the machine for it would refuse steps on work
499
- // nobody is doing.
500
- //
501
- // A SUSPENDED encoder is charged, deliberately, and this is not the same
502
- // question. The unit here is seconds of work per second of VIDEO, not per
503
- // second of wall clock: a copy running at 8x costs 0.125 s/s whether it
504
- // is producing right now or parked by the look-ahead cap, because over an
505
- // hour of watching it still produces an hour of video. Suspension is how
506
- // that cost is spread, not a discount on it — and pricing a parked
507
- // encoder at zero would offer a step on the strength of a pause that ends
508
- // the moment the viewer catches up.
509
- if (!processCanBeSignalled(this.#stateFor(member))) {
510
- continue;
511
- }
512
- if (member.spec.carries === "audio-only") {
513
- // A soundtrack encoder, priced from its own measured speed. Nothing is
514
- // charged for a track nobody has measured: a guess here refuses rungs
515
- // on arithmetic no one performed.
516
- const audio = this.#audioCost.get(EncodeCost.audioKeyOf(member));
517
- cost += audio && audio.costSec > 0 ? audio.costSec : 0;
518
- continue;
519
- }
520
- if (!member.spec.transcodesVideo) {
521
- const observed = this.#copyCost.get(member.file.key);
522
- cost += observed && observed.costSec > 0 ? observed.costSec : 0;
523
- continue;
524
- }
525
- // A picture being RE-ENCODED beside the rung being judged — the warm-up
526
- // that makes a quality switch seamless is two encoders by design, and
527
- // that overlap is exactly where the field measured 0.504x on a rung
528
- // predicted at 1.58x (2026-08-15). Priced by what it has been SEEN doing
529
- // when it had the machine to itself, and otherwise by the same model that
530
- // judges every rung — which is a prediction, not a guess.
531
- cost += this.#pictureCostOf(member);
532
- }
533
- // And what the FILE costs simply by being fetched and delivered while it is
534
- // watched: a viewer consumes it at its own byte rate, and every one of
535
- // those bytes is downloaded, verified and pushed by this process. Priced
536
- // per megabyte from readings taken while nothing was encoding, so the two
537
- // measurements do not contain each other.
538
- cost += this.#torrentCostSecFor(session);
539
- return cost;
540
- }
541
-
542
- /**
543
- * The speed each rung of this family was last seen running at, when it was
544
- * running alone.
545
- *
546
- * A rung that has been watched failing is refused on that evidence; a rung
547
- * nobody has run says nothing about itself and is judged by the startup
548
- * measurement like any other.
549
- *
550
- * @param {HlsSession} base
551
- * @returns {Map<number, number>}
552
- */
553
- measuredRungSpeeds(base) {
554
- /** @type {Map<number, number>} */
555
- const speeds = new Map();
556
- for (const session of this.#outputs.familyOf(base)) {
557
- const speed = qualityStateOf(session).lastAloneSpeed;
558
- if (!session.spec.transcodesVideo || !Number.isFinite(speed)) {
559
- continue;
560
- }
561
- const height = this.#outputs.variantHeightOf(session);
562
- if (height > 0) {
563
- speeds.set(height, speed);
564
- }
565
- }
566
- return speeds;
567
- }
568
-
569
- /**
570
- * Drop the rungs this host cannot hold at realtime.
571
- *
572
- * Every rung below the source height is a full re-encode — decode the whole
573
- * source, encode a smaller picture — and on a weak host that is dearer than
574
- * the copy it replaces. Measured 2026-08-14: 1080p was copied at 7.8-8.9x
575
- * while the offered 240p rung ran at 0.388-0.947x, its first segment took
576
- * 30 s and later ones were held 22 s, so choosing a LOWER quality is what
577
- * broke playback. A rung that cannot be produced faster than it is watched
578
- * must not be offered at all.
579
- *
580
- * The session's OWN height always stays: an encoder is already producing it,
581
- * and removing it would point the player at a rung nobody is encoding.
582
- *
583
- * @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
584
- * @returns {number[]}
585
- */
586
- sustainableHeights({
587
- heights,
588
- ownHeight,
589
- // Every height a viewer has on screen, not one: two viewers of one picture
590
- // can be on two rungs, and a rung is never withdrawn while somebody is
591
- // watching it — their next segment would 404 on a stream that is playing.
592
- playingHeights = new Set(),
593
- sourceWidth,
594
- sourceHeight,
595
- fps,
596
- source,
597
- transcodeVideo,
598
- observedDecodeCostSec = null,
599
- concurrentCostSec = 0,
600
- runningCostByHeight = null,
601
- measuredHeights = null,
602
- requiredSpeed = null
603
- }) {
604
- // What this file's own supply demands, measured by its reader — and
605
- // realtime while it has not been measured. Read once here so the line that
606
- // reports a refusal names the figure it refused against.
607
- const bar = speedBar(requiredSpeed);
608
- const benchmark = this.#host().benchmark;
609
- // CALIBRATED WITH NOTHING QUALIFIED is not the same as not calibrated at
610
- // all (roadmap item 97, step 14): with no mode of this encoder shown to
611
- // work, nothing re-encoded is offered.
612
- const nothingQualified = Array.isArray(benchmark) && benchmark.length === 0;
613
- if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
614
- // Nothing to predict WITH, so nothing is predicted. What has been SEEN
615
- // still counts: a rung measured running below realtime is withdrawn here
616
- // too, because the evidence for it does not come from the benchmark. This
617
- // return used to hand back every height including one measured at 0.4x —
618
- // found by a check written when this moved out of the session manager,
619
- // 2026-09-05.
620
- // The rung on screen is not exempt, for the same reason it is not exempt
621
- // below: keeping one measured at 0.007x stalls the viewer with no path to
622
- // a faster rung, which is what the field showed on 2026-08-31.
623
- //
624
- // What nothing has priced is not offered either (roadmap item 97, step
625
- // 14): with no mode of this encoder qualified at startup, only a height
626
- // that needs no encoder — the copied source — or one an encoder is
627
- // already seen holding stays.
628
- return heights.filter((height) => {
629
- const measured = measuredHeights?.get(height) ?? null;
630
- if (measured !== null) {
631
- return measured >= 1;
632
- }
633
- return !nothingQualified || (!transcodeVideo && (height === sourceHeight || height === ownHeight));
634
- });
635
- }
636
- /** @type {number[]} */
637
- const kept = [];
638
- /** @type {string[]} */
639
- const dropped = [];
640
- // What each height was predicted to do on THIS machine, kept so a session
641
- // started at that height can be compared against it once it runs. The
642
- // manager holds the last answer, because the offer is computed on the path
643
- // that serves every request while a session is created elsewhere.
644
- /** @type {Map<number, number | null>} */
645
- const predictedByHeight = new Map();
646
- for (const height of heights) {
647
- // A rung this session has actually been seen running below realtime is
648
- // withdrawn on that evidence, whatever the prediction says. This is the
649
- // one thing a live reading is authority on: itself. It is asked before
650
- // any exemption so a rung measured failing while on screen does not stay
651
- // offered because it was on screen when measured — otherwise a step
652
- // would ask for the one rung this machine has been measured failing at,
653
- // then fail again, then step down, for ever. A copied source height
654
- // cannot reach this: `#measuredRungSpeeds` records only sessions that
655
- // re-encode, so a copy has no reading to be withdrawn on, which is right
656
- // — it costs no encoder.
657
- const measured = measuredHeights?.get(height) ?? null;
658
- if (measured !== null && measured < 1) {
659
- // Even the rung on screen is withdrawn on measured failure: keeping it
660
- // would 404 the next segment, but keeping a rung measured at 0.007x
661
- // (field 2026-08-31, 4K HEVC on CM4) stalls the viewer for minutes with
662
- // 0.04s buffered and no way to downgrade because every other rung is
663
- // also dropped. Withdrawing it lets the offer become empty, which the
664
- // caller turns into an error the viewer can act on (try another proxy
665
- // or a lower source) instead of an endless spinner.
666
- dropped.push(`${height}p=${measured.toFixed(2)}x measured`);
667
- continue;
668
- }
669
- // The rung ON SCREEN is kept only when it has not been measured failing
670
- // above. Keeping a rung measured at 0.007x would stall the viewer with
671
- // no path to a faster rung, which is what the field showed.
672
- if (playingHeights.has(height)) {
673
- kept.push(height);
674
- continue;
675
- }
676
- // The height an encoder is ALREADY producing, and the source's own height
677
- // when the FAMILY serves it by copy — neither has to be predicted,
678
- // because it is happening. A copied rung costs no encoder at all, so no
679
- // measurement of this host can ever be a reason to withdraw it, and the
680
- // whole point of it is that it is where a viewer on a rung the machine
681
- // cannot hold goes back to. `transcodeVideo` here is the base's, not the
682
- // asking session's: a 240p rung re-encodes, and reading its own flag is
683
- // what withdrew a copied 1080p in the field on 2026-08-15.
684
- //
685
- // A source height that would have to be RE-ENCODED is a prediction like
686
- // any other: on a session whose budget stepped down to 480p, the source's
687
- // 1080p is neither copied nor being produced, and keeping it unpriced
688
- // would offer exactly the kind of rung this refuses. Likewise, a rung
689
- // this session is already producing at 0.007x (field 2026-08-31, 4K HEVC
690
- // on CM4, 0.1x at 23:45 and 0.007x at 06:57) is not sustainable just
691
- // because it is running — keeping it offered no path to a faster rung
692
- // and left the viewer at 0.04s buffered with no downgrade.
693
- if (
694
- (height === ownHeight && !transcodeVideo) ||
695
- (height === sourceHeight && !transcodeVideo)
696
- ) {
697
- kept.push(height);
698
- continue;
699
- }
700
- const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
701
- // What the machine is spending on everything EXCEPT this height. A step
702
- // being warmed for a switch is already running while it is judged, so its
703
- // own cost is inside the committed total — and charged against itself it
704
- // is counted twice. Measured against the field figures of 2026-08-15
705
- // that is 1.83x against 1.03x: below the margin, so the step the viewer
706
- // had just asked for was dropped from the offer by the act of warming it,
707
- // and its next segment answered 404 on a stream that was playing.
708
- const concurrentBesideThis = Math.max(
709
- 0,
710
- concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
711
- );
712
- const { speed } = canSustainOutput({
713
- benchmark,
714
- decodeModel: this.#host().decodeModel,
715
- source,
716
- outputPixelsPerSec: width * height * fps,
717
- observedDecodeCostSec,
718
- concurrentCostSec: concurrentBesideThis,
719
- frame: { width, height }
720
- });
721
- // The benchmark behind that figure was taken on a QUIET host — one
722
- // ffmpeg and nothing else. The machine a step will actually run on is
723
- // also running the kernel, the container and whatever else its owner
724
- // does, and on the addon host that was measured at 99 % busy with a
725
- // quarter of it unattributed. Only the unattributed part is charged
726
- // here: our own encoders are already in `concurrentBesideThis` and the
727
- // proxy's own work is already priced per megabyte moved.
728
- // Two corrections, and they are different facts about the machine. The
729
- // availability share removes work nobody has been charged for; the
730
- // contention penalty says what OUR OWN second job costs, because the
731
- // budget adds independent prices and this host does not behave that way
732
- // — the same work measured 2.6× dearer beside one encoder and 3.7×
733
- // beside two (2026-08-18). `concurrentBesideThis` already counts what is
734
- // committed; this multiplies by how badly running at all together goes.
735
- const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
736
- const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
737
- const onThisMachine = correctForAvailability(
738
- speed === null ? null : speed / penalty,
739
- this.#host().availability
740
- );
741
- // Kept against the step's own session, so that when it runs the field
742
- // says what the prediction was worth. Without this the only comparison
743
- // available is between two figures written minutes apart in different
744
- // lines of the log.
745
- predictedByHeight.set(height, onThisMachine);
746
- if (onThisMachine !== null && onThisMachine >= bar) {
747
- kept.push(height);
748
- continue;
749
- }
750
- dropped.push(`${height}p=${onThisMachine === null ? "n/a" : `${onThisMachine.toFixed(2)}x`}`);
751
- }
752
- // Written when the ANSWER changes, not when the answer is recomputed. This
753
- // is asked on the path that serves every playlist, init and segment, and
754
- // the figures behind it move every five seconds — so an unconditional line
755
- // here is roughly seven hundred identical lines an hour into a forwarder
756
- // that holds five hundred, which buries whatever is worth reading.
757
- if (dropped.length > 0) {
758
- const line =
759
- `transcode: not offering ${dropped.join(" ")} — below ${bar.toFixed(2)}x ` +
760
- (Number.isFinite(requiredSpeed) && requiredSpeed > 1
761
- ? "(the speed this file's own interruptions demand) "
762
- : "(realtime, this file's supply not measured yet) ") +
763
- // Said with the figures, because a step refused on a busy machine and
764
- // one refused on an idle machine are different facts about the host.
765
- (this.#host().availability?.known
766
- ? `on a machine with ${Math.round(this.#host().availability.share * 100)}% to spare `
767
- : "") +
768
- `(offering ${kept.map((height) => `${height}p`).join(" ")})`;
769
- if (line !== this.#lastOfferLine) {
770
- this.#lastOfferLine = line;
771
- logger.info(line);
772
- }
773
- this.lastPredictedByHeight = predictedByHeight;
774
- } else {
775
- this.lastPredictedByHeight = predictedByHeight;
776
- this.#lastOfferLine = "";
777
- }
778
- return kept;
779
- }
780
-
781
- /**
782
- * Take one reading off an encoder that is running, and file it as the price of
783
- * whatever that encoder is doing.
784
- *
785
- * Separate from the realtime budget, which asks a different question — should
786
- * the quality step down — and answers it only where it CAN step down. Most of
787
- * what is worth measuring is excluded by that: a rung at the foot of its
788
- * ladder, a step whose ladder is one rung long, a picture that is copied.
789
- * Measuring has no such preconditions.
790
- *
791
- * What it does refuse: a suspended encoder (ffmpeg reports a CUMULATIVE
792
- * speed, so a look-ahead pause is divided into it and the figure decays while
793
- * nothing is being encoded), a reading that has not moved since the last one,
794
- * and a run short of input, where what is short is the swarm rather than the
795
- * machine.
796
- *
797
- * @param {HlsSession} session
798
- * @returns {Promise<void>}
799
- */
800
- async learnFrom(session) {
801
- if (!session) {
802
- return;
803
- }
804
- if (
805
- this.#stateFor(session) === ENCODE_RUN_STATE.ENDED_FAILED ||
806
- liveRunsOf(this.#runsFor(session)).length === 0 ||
807
- this.#stateFor(session) === ENCODE_RUN_STATE.SUSPENDED
808
- ) {
809
- qualityStateOf(session).learnSample = null;
810
- return;
811
- }
812
- // Measured as a DELTA between two readings of a run that was going for the
813
- // whole interval, not from ffmpeg's cumulative `speed=`. The cumulative
814
- // figure counts every second the encoder spent SIGSTOPped by the look-ahead
815
- // cap in its denominator, and a copy spends most of its life there — it
816
- // reaches the cap in about fifteen seconds and then waits a minute. Read
817
- // that way a copy running at 8x reports 1.6x and falling, which would be
818
- // filed as the price of copying and refuse rungs on arithmetic that
819
- // measured a pause.
820
- const run = liveRunsOf(this.#runsFor(session))[0] ?? null;
821
- // The position is THAT run's own, read from the same process the sample is
822
- // stamped with. It used to be the output's progress, which is whichever
823
- // live run covers the viewer and updated last — so with two encoders on
824
- // one output a pair of readings could be two processes' positions filed
825
- // under one run, and the difference between them read as a speed.
826
- const processedSeconds = Number(this.#progressFor(session, run)?.processedSeconds);
827
- const takenAt = Date.now();
828
- const state = qualityStateOf(session);
829
- const previous = state.learnSample ?? null;
830
- // Stamped with the run it was taken from. A restart clears this sample, but
831
- // it then spends up to a second and a half making its directory and burying
832
- // its predecessor, and through that window the session still carries the
833
- // OLD process and the OLD position — so a sample taken there, paired with
834
- // the new run's first position, reads a twenty-minute seek as twenty
835
- // minutes of video produced in five seconds. Filed as this file's price it
836
- // admits every quality step there is. Comparing the serials is what the
837
- // twenty-second wait used to stand in for, and unlike the wait it costs no
838
- // readings on a short run.
839
- state.learnSample = { takenAt, processedSeconds, run };
840
- if (previous === null || !Number.isFinite(processedSeconds) || !Number.isFinite(previous.processedSeconds)) {
841
- return;
842
- }
843
- if (previous.run !== run) {
844
- return; // the pair straddles a restart and measures the seek, not the host
845
- }
846
- const speed = speedFromReadings(previous, { takenAt, processedSeconds }, LEARN_WINDOW_MIN_SEC);
847
- if (speed === null) {
848
- return;
849
- }
850
- // Recorded HERE, before any of the conditions below can discard the
851
- // reading, because the budget and the learning ask different questions of
852
- // it. Learning refuses a reading taken beside another encoder, since it
853
- // would file that encoder's work as this file's price; the budget wants
854
- // exactly what this run is doing right now, whatever else the machine is
855
- // doing beside it. Sharing the figure and not the conditions is what lets
856
- // the budget stop reading ffmpeg's cumulative average.
857
- state.recentSpeed = { speed, at: takenAt, run };
858
- const kind = costKindForSession(session);
859
- // A reading taken beside another encoder contains that other encoder's
860
- // work, and the budget ADDS the same work again when it predicts — so filed
861
- // as it stands the price is counted twice and grows with every reading.
862
- // Measured 2026-08-15 in the field: copying, whose truth is 7.9x, was
863
- // learned as 2.03x, and decoding, whose clips say 2.6x, as 0.87x. Every
864
- // step was then refused, the offer collapsed to the one copied height, and
865
- // the viewer lost the quality menu altogether.
866
- //
867
- // For a picture the answer is to wait for a moment alone, which comes often
868
- // enough. For a SOUNDTRACK it never comes: a rendition runs for exactly as
869
- // long as the picture it accompanies, so "alone" is a state it is never in,
870
- // and the price stayed unmeasured for ever — the hole this was meant to
871
- // close. Its share is instead recovered by subtracting what the machine is
872
- // already known to be spending, which is the same arithmetic that recovers
873
- // this source's decoding from a running encoder, and it is only done when
874
- // every other running encode HAS a price. Otherwise the unpriced work would
875
- // land in the soundtrack's account and refuse steps on it.
876
- let othersCostSec = 0;
877
- if (this.#runningEncoders() > 1) {
878
- if (kind !== "audio") {
879
- return;
880
- }
881
- const others = this.pricedConcurrentCost(session);
882
- if (others === null) {
883
- return; // something running has no price; nothing can be attributed
884
- }
885
- othersCostSec = others;
886
- }
887
- if (speed < 1 && await this.#boundBy(session) === "download") {
888
- return; // the torrent is what is short; this says nothing about the host
889
- }
890
- // What this encode did with the machine to itself — the one figure a live
891
- // reading is authority on, and what withdraws a quality step that has been
892
- // seen failing without letting it speak for steps nobody has run.
893
- //
894
- // Recorded only AFTER the download-bound check, and that order is the whole
895
- // point: a run starved of torrent data reports a speed that measures the
896
- // swarm. Stored first, as it was, that figure became this encode's price —
897
- // 0.3x reads as 3.33 s of work per second of video, more than the machine
898
- // has — and every other quality step was refused on the download's account.
899
- state.lastAloneSpeed = speed;
900
- // What the offer predicted for this very step, against what it then did
901
- // with the machine to itself. The prediction is corrected for the share of
902
- // the machine that was free at the time, so this ratio is the error that
903
- // remains AFTER that correction — which is the only way to tell whether a
904
- // stage of roadmap item 3 moved anything. Written when it changes by more
905
- // than a tenth, so a steady step says it once rather than every five
906
- // seconds.
907
- if (Number.isFinite(state.predictedSpeedWhenOffered) && state.predictedSpeedWhenOffered > 0) {
908
- const ratio = speed / state.predictedSpeedWhenOffered;
909
- const lastSaid = state.lastPredictionRatio;
910
- if (!Number.isFinite(lastSaid) || Math.abs(ratio - lastSaid) > 0.1) {
911
- state.lastPredictionRatio = ratio;
912
- logger.info(
913
- `prediction ${session.id.slice(0, 8)} ${session.output.encodeHeight || "source"}p: ` +
914
- `predicted ${state.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
915
- `(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)`
916
- );
917
- }
918
- }
919
- if (kind === "audio") {
920
- // What is left after the work that was already accounted for. `null` when
921
- // the subtraction leaves nothing positive, which means the reading says
922
- // less than the noise in it.
923
- const ownCostSec = 1 / speed - othersCostSec;
924
- if (!(ownCostSec > 0) || !Number.isFinite(ownCostSec)) {
925
- return;
926
- }
927
- await this.#learnAudioCost(session, 1 / ownCostSec);
928
- return;
929
- }
930
- if (kind === "decode") {
931
- this.#learnDecodeCost(session, speed);
932
- return;
933
- }
934
- await this.#learnCopyCost(session, speed);
935
- }
936
-
937
- async #learnCopyCost(session, speed) {
938
- if (this.#stateFor(session) === ENCODE_RUN_STATE.SUSPENDED) {
939
- return; // a suspended run reports a cumulative figure that is decaying
940
- }
941
- // Always asked, not only below realtime. A re-encode near 1x may be the
942
- // host; a COPY near 1x is a copy waiting for the torrent, because copying
943
- // is what a machine does at eight times realtime — and a starved reading
944
- // filed as the price of copying would refuse rungs on the download's
945
- // account.
946
- if (await this.#boundBy(session) === "download") {
947
- return;
948
- }
949
- const costSec = 1 / speed;
950
- if (!(costSec > 0) || !Number.isFinite(costSec)) {
951
- return;
952
- }
953
- const key = session.file.key;
954
- const known = this.#copyCost.get(key);
955
- const readings = [...(known?.readings ?? []), costSec].slice(-READINGS_KEPT);
956
- const median = medianOf(readings);
957
- if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
958
- this.#copyCost.set(key, { ...known, readings });
959
- return;
960
- }
961
- this.#copyCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
962
- logger.info(
963
- `transcode: ${session.file.name} copies at ${(1 / median).toFixed(2)}x on this host ` +
964
- `(median of ${readings.length}, latest ${speed.toFixed(2)}x)`
965
- );
966
- }
967
-
968
- async #learnAudioCost(session, speed) {
969
- if (this.#stateFor(session) === ENCODE_RUN_STATE.SUSPENDED) {
970
- return;
971
- }
972
- if (await this.#boundBy(session) === "download") {
973
- return;
974
- }
975
- const costSec = 1 / speed;
976
- if (!(costSec > 0) || !Number.isFinite(costSec)) {
977
- return;
978
- }
979
- const key = EncodeCost.audioKeyOf(session);
980
- const known = this.#audioCost.get(key);
981
- const readings = [...(known?.readings ?? []), costSec].slice(-READINGS_KEPT);
982
- const median = medianOf(readings);
983
- if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
984
- this.#audioCost.set(key, { ...known, readings });
985
- return;
986
- }
987
- this.#audioCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
988
- logger.info(
989
- `transcode: ${session.file.name} encodes audio track ${session.spec.audioSourceTrackIndex} at ` +
990
- `${(1 / median).toFixed(2)}x on this host (median of ${readings.length}, latest ${speed.toFixed(2)}x)`
991
- );
992
- }
993
-
994
- #learnDecodeCost(session, speed) {
995
- if (!(speed > 0)) {
996
- return;
997
- }
998
- if (!session.spec.transcodesVideo) {
999
- // Nothing to learn about decoding here, and nothing else either: the
1000
- // caller routes a copy to #learnCopyCost and a rendition to
1001
- // #learnAudioCost before this is ever reached. Routing them from here as
1002
- // well put both calls behind a guard the caller had already made
1003
- // (`transcodeVideo === true`), so neither could run.
1004
- return;
1005
- }
1006
- if (this.#host().encoderKind !== "software") {
1007
- return; // the benchmark that prices the encode half is libx264 only
1008
- }
1009
- const benchmark = this.#host().benchmark;
1010
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1011
- return;
1012
- }
1013
- const entry = benchmark.find((item) => item.preset === session.output.softwarePreset);
1014
- const height = Number(session.output.encodeHeight) || 0;
1015
- const width = Number(session.output.encodeWidth) || 0;
1016
- const fps = Number(session.output.outputFps) || TRANSCODE_FPS;
1017
- if (!entry || height <= 0 || width <= 0) {
1018
- return;
1019
- }
1020
- // The preset's throughput AT THIS SIZE, as the startup calibration read it.
1021
- const pixelsPerSec = throughputAt(entry, { width, height });
1022
- if (!(pixelsPerSec > 0)) {
1023
- return;
1024
- }
1025
- const encodeCostSec = (width * height * fps) / pixelsPerSec;
1026
- const decodeCostSec = 1 / speed - encodeCostSec;
1027
- if (!(decodeCostSec > 0)) {
1028
- // The encode half already accounts for everything measured. Nothing is
1029
- // left to attribute to decoding, and a zero or negative cost would say
1030
- // decoding is free, which is a claim this reading cannot support.
1031
- return;
1032
- }
1033
- const key = session.file.key;
1034
- const known = this.#decodeCost.get(key);
1035
- const readings = [...(known?.readings ?? []), decodeCostSec].slice(-READINGS_KEPT);
1036
- const costSec = medianOf(readings);
1037
- if (!movedBeyondScatter(known?.costSec ?? null, costSec, readings)) {
1038
- // The same answer as before, by the readings' own scatter. Storing it
1039
- // would bump the version and make every session recompute its offer,
1040
- // which is asked for on the path that serves every playlist, init and
1041
- // segment.
1042
- this.#decodeCost.set(key, { ...known, readings });
1043
- return;
1044
- }
1045
- this.#decodeCost.set(key, { costSec, readings, version: (known?.version ?? 0) + 1 });
1046
- logger.info(
1047
- `transcode: ${session.file.name} decodes at ${(1 / costSec).toFixed(2)}x on this host ` +
1048
- `(median of ${readings.length}, latest ${(1 / decodeCostSec).toFixed(2)}x from ${height}p ` +
1049
- `at ${speed.toFixed(2)}x, preset ${session.output.softwarePreset})`
1050
- );
1051
- }
1052
-
1053
- /**
1054
- * The speed this run is making RIGHT NOW, or null when nothing recent enough
1055
- * says.
1056
- *
1057
- * Read as the slope between two progress reports, never as ffmpeg's own
1058
- * `speed=`. That figure is cumulative — output time over wall time since the
1059
- * run began — so a run starved of torrent data early carries the average of
1060
- * that starvation for the rest of its life. Measured 2026-08-21: a run whose
1061
- * progress lines showed 1.30x at that moment (13 s of video in 10.02 s of
1062
- * clock) still reported a cumulative 0.39x from four minutes on a ~100 KB/s
1063
- * swarm, and the budget stepped the picture down on it. The same mistake was
1064
- * found and solved once already — the startup decode benchmark reads the
1065
- * slope between two progress reports for exactly this reason.
1066
- *
1067
- * @param {HlsSession} session
1068
- * @param {number} now
1069
- * @returns {number | null}
1070
- */
1071
- recentSpeedOf(session, now, withinMs) {
1072
- return this.recentSpeedReadingOf(session, now, withinMs)?.speed ?? null;
1073
- }
1074
-
1075
- /**
1076
- * The newest measured speed from the output's current or most recent run.
1077
- * Its measurement time lets the playback model project the observed trend
1078
- * without imposing a separate freshness window.
1079
- *
1080
- * @param {HlsSession} session
1081
- * @returns {{ speed: number, at: number } | null}
1082
- */
1083
- latestSpeedReadingOf(session) {
1084
- const reading = qualityStateOf(session).recentSpeed;
1085
- if (!reading || !this.#runsFor(session).includes(reading.run)) {
1086
- return null;
1087
- }
1088
- return { speed: reading.speed, at: reading.at };
1089
- }
1090
-
1091
- /**
1092
- * The current output's measured production speed, with the time it was
1093
- * measured. The caller can use the same observation in a trend without
1094
- * treating repeated progress polls as new samples.
1095
- *
1096
- * @param {HlsSession} session
1097
- * @param {number} now
1098
- * @param {number} withinMs
1099
- * @returns {{ speed: number, at: number } | null}
1100
- */
1101
- recentSpeedReadingOf(session, now, withinMs) {
1102
- const reading = this.latestSpeedReadingOf(session);
1103
- // Stale by whatever the asker calls stale — two of its own ticks, for the
1104
- // budget loop, which takes a fresh reading every pass anyway. A reading
1105
- // older than that is not about the machine as it stands.
1106
- if (!reading || now - reading.at > withinMs) {
1107
- return null;
1108
- }
1109
- return reading;
1110
- }
1111
-
1112
- /**
1113
- * Realtime budget (software encoder only): choose the output resolution AND
1114
- * libx264 preset this host can encode faster than realtime, from the startup
1115
- * benchmark. The ceiling is the client-requested box capped to the source
1116
- * (never upscaled); the budget picks the highest resolution rung at or below
1117
- * that ceiling that clears realtime × margin, then the best preset at that
1118
- * resolution. On a weak host this downscales below the client target instead
1119
- * of dropping into sub-realtime playback. Returns null when not applicable
1120
- * (no video transcode, hardware encoder, or missing benchmark/source size) —
1121
- * the encode then keeps the ceiling resolution and the default preset.
1122
- *
1123
- * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1124
- * @returns {{ width: number, height: number, preset: string } | null}
1125
- */
1126
- chooseEncodeBudget({
1127
- transcodeVideo,
1128
- targetWidth,
1129
- targetHeight,
1130
- sourceWidth,
1131
- sourceHeight,
1132
- outputFps,
1133
- source = null,
1134
- requiredSpeed = null
1135
- }) {
1136
- if (!transcodeVideo || this.#host().encoderKind !== "software" || !this.#host().benchmark) {
1137
- return null;
1138
- }
1139
- const ceiling = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
1140
- if (!ceiling) {
1141
- return null;
1142
- }
1143
- return chooseSoftwareEncodeSettings(
1144
- this.#host().benchmark,
1145
- { width: ceiling.w, height: ceiling.h },
1146
- outputFps,
1147
- { decodeModel: this.#host().decodeModel, source, requiredSpeed }
1148
- );
1149
- }
1150
- }
1
+ /**
2
+ * @file What encoding this file costs THIS machine, and which heights follow.
3
+ *
4
+ * One subject: seconds of work per second of video. Everything here is that
5
+ * question asked about something — a picture being re-encoded, a soundtrack, a
6
+ * copy, everything running beside the rung being judged — and the last method
7
+ * turns the answers into the list of heights the machine can hold.
8
+ *
9
+ * It decides what a viewer is offered, so being wrong in either direction costs
10
+ * them: too generous and they are given a rung that runs below realtime, which
11
+ * is a slideshow; too mean and they are refused quality the host could hold.
12
+ *
13
+ * Nothing here is chosen. Every figure is a measurement — the startup
14
+ * benchmarks, what an encoder has since been seen doing on this very file, what
15
+ * a second job costs on this host, what share of the machine is free — and where
16
+ * a term has not been measured it contributes nothing rather than a guess.
17
+ *
18
+ * What it is given, and why each is passed rather than reached for: which
19
+ * sessions belong to one file (`outputs`), the host's own readings, how many
20
+ * encoders are running, what the file costs merely by being fetched, and whether
21
+ * a slow run is short of the machine or short of the swarm. The learned costs it
22
+ * holds itself: they are what an encoder taught it, and it is their only writer
23
+ * and their only reader.
24
+ *
25
+ * **Learning them lives here too, and it did not until 2026-09-15.** The three
26
+ * methods that turn a reading off a running encoder into a price were methods of
27
+ * the session manager, writing into these maps from outside — so a fact had one
28
+ * keeper and a different author, and the maps had to be public for it. They are
29
+ * private now, and what leaves this class is an answer rather than a store.
30
+ */
31
+
32
+ /**
33
+ * Which cost a speed reading from this session is a measurement OF.
34
+ *
35
+ * Three encodes share one reading path and price three different things: a
36
+ * soundtrack published on its own, a picture being re-encoded (whose reading
37
+ * prices this source's DECODING, the encode half being known from the startup
38
+ * benchmark), and a picture being copied (which prices copying).
39
+ *
40
+ * Exported because the routing is where the fault was: a rendition was refused
41
+ * a reading by one guard while the call that would have priced it sat behind
42
+ * another, so the soundtrack was charged at nothing no matter how long it ran.
43
+ * A pure function makes that a test rather than a field session.
44
+ *
45
+ * @param {{ audioOnly?: boolean, transcodeVideo?: boolean }} session
46
+ * @returns {"audio" | "decode" | "copy"}
47
+ */
48
+ export function costKindForSession(session) {
49
+ if (session?.spec?.carries === "audio-only") {
50
+ return "audio";
51
+ }
52
+ return session?.spec?.transcodesVideo === true ? "decode" : "copy";
53
+ }
54
+
55
+ /** The narrowest stretch of uninterrupted encoding a speed may be read from. */
56
+ const LEARN_WINDOW_MIN_SEC = 3;
57
+
58
+ import { correctForAvailability } from "../available-share.js";
59
+ import { medianOf, movedBeyondScatter, READINGS_KEPT } from "../learned-median.js";
60
+ import { speedFromReadings } from "../encoder-readings.js";
61
+ import { contentionPenalty } from "../contention.js";
62
+ import { ENCODE_RUN_STATE, liveRunsOf, processCanBeSignalled } from "../encode-run-state.js";
63
+ import { canSustainOutput, chooseSoftwareEncodeSettings, speedBar } from "../hwaccel.js";
64
+ import { throughputAt } from "../throughput.js";
65
+ import { computeOutputDimensions, TRANSCODE_FPS } from "../args.js";
66
+ import { logger } from "../../../utils/logger.js";
67
+ import { qualityStateOf } from "./OutputQualityState.js";
68
+ import { audioReadingFor } from "../audio-work-rate.js";
69
+
70
+ export class EncodeCost {
71
+ /**
72
+ * What a soundtrack encoder has been seen to cost, by the key naming that
73
+ * track. Written and read here and nowhere else.
74
+ *
75
+ * @type {Map<string, { costSec: number, readings: number[], version: number }>}
76
+ */
77
+ #audioCost = new Map();
78
+
79
+ /** What copying a file's picture has been seen to cost. @type {Map<string, { costSec: number, readings: number[], version: number }>} */
80
+ #copyCost = new Map();
81
+
82
+ /** What decoding a file has been seen to cost. @type {Map<string, { costSec: number, readings: number[], version: number }>} */
83
+ #decodeCost = new Map();
84
+
85
+ /**
86
+ * What each height was last predicted to do, kept so a session started at
87
+ * that height can be compared against the prediction once it runs.
88
+ *
89
+ * @type {Map<number, number | null> | null}
90
+ */
91
+ lastPredictedByHeight = null;
92
+
93
+ notePredictionFor(session, height) {
94
+ qualityStateOf(session).predictedSpeedWhenOffered = this.lastPredictedByHeight?.get(height) ?? null;
95
+ }
96
+
97
+ // The last refusal printed. The offer is recomputed on the path that serves
98
+ // every playlist, init and segment, and the figures behind it move every few
99
+ // seconds — so the line is written when the ANSWER changes, not when it is
100
+ // asked again.
101
+ #lastOfferLine = "";
102
+
103
+ #outputs;
104
+ #host;
105
+ #runningEncoders;
106
+ #encodersRunningNow;
107
+ #torrentCostSecFor;
108
+ #boundBy;
109
+ #runsFor;
110
+ #stateFor;
111
+ #progressFor;
112
+
113
+ /**
114
+ * @param {{
115
+ * outputs: import("../output/OutputCatalog.js").OutputCatalog,
116
+ * host: () => { benchmark: object[] | null, decodeModel: object | null, contentionPenalties: object | null, copySpeedX: number | null, availability: { known: boolean, share: number } | null, encoderKind: string | null },
117
+ * runningEncoders: () => number,
118
+ * encodersRunningNow: () => number,
119
+ * torrentCostSecFor: (session: object) => number,
120
+ * boundBy: (session: object) => Promise<"cpu" | "download" | "unknown">,
121
+ * runsFor: (output: object) => object[],
122
+ * stateFor: (output: object) => string,
123
+ * progressFor: (output: object, run: object | null) => object | null - The progress of that one run.
124
+ * }} deps
125
+ */
126
+ constructor({ outputs, host, runningEncoders, encodersRunningNow, torrentCostSecFor, boundBy, runsFor, stateFor, progressFor }) {
127
+ this.#outputs = outputs;
128
+ // Asked at the moment of the question, not copied: the share of the machine
129
+ // that is free is re-read every few seconds, and a copy taken when this was
130
+ // built would price every later rung against a machine that has gone.
131
+ this.#host = host;
132
+ this.#runningEncoders = runningEncoders;
133
+ this.#encodersRunningNow = encodersRunningNow;
134
+ this.#torrentCostSecFor = torrentCostSecFor;
135
+ // WHETHER A SLOW RUN IS SHORT OF THE MACHINE OR SHORT OF THE SWARM, which
136
+ // this cannot answer and must not guess at: a run starved of torrent data
137
+ // reports a speed that measures the swarm, and filed as a price it refuses
138
+ // every quality step on the download's account. Asked of whoever holds the
139
+ // torrent's readings; passed in, so this can still be exercised with plain
140
+ // values and no swarm.
141
+ this.#boundBy = boundBy;
142
+ if (typeof runsFor !== "function") {
143
+ throw new TypeError("EncodeCost requires runsFor");
144
+ }
145
+ if (typeof stateFor !== "function") {
146
+ throw new TypeError("EncodeCost requires stateFor");
147
+ }
148
+ if (typeof progressFor !== "function") {
149
+ throw new TypeError("EncodeCost requires progressFor");
150
+ }
151
+ this.#runsFor = runsFor;
152
+ this.#stateFor = stateFor;
153
+ this.#progressFor = progressFor;
154
+ }
155
+
156
+ /**
157
+ * The key naming one soundtrack of one file.
158
+ *
159
+ * Static because it is the NAME of a thing this class stores, not a reading
160
+ * about the machine — and because the offer's cache key needs it too, so a
161
+ * caller that has a session can ask without holding an instance.
162
+ *
163
+ * Named by everything that changes what producing it costs: the file the
164
+ * sound is read from, the track inside that file, and whether it is copied
165
+ * or re-encoded. One track may be copied for one viewer and re-encoded for
166
+ * another, and a copy's speed priced an encode it says nothing about; and a
167
+ * soundtrack shipped as its own file is track 0 of THAT file, which without
168
+ * the file in the name shared a price with the picture's own track 0.
169
+ *
170
+ * @param {{ file: { key: string }, spec?: { audioFileIndex?: number, audioSourceTrackIndex?: number, transcodesAudio?: boolean } }} session
171
+ * @returns {string}
172
+ */
173
+ static audioKeyOf(session) {
174
+ const spec = session.spec ?? {};
175
+ return `${session.file.key}:a${spec.audioFileIndex ?? ""}:${spec.audioSourceTrackIndex ?? 0}:` +
176
+ `${spec.transcodesAudio === true ? "aac" : "copy"}`;
177
+ }
178
+
179
+ /**
180
+ * What decoding this file has been measured to cost, and how many times that
181
+ * answer has changed.
182
+ *
183
+ * @param {string} fileKey
184
+ * @returns {{ costSec: number, version: number } | null}
185
+ */
186
+ decodeCostFor(fileKey) {
187
+ const entry = this.#decodeCost.get(fileKey);
188
+ return entry ? { costSec: entry.costSec, version: entry.version } : null;
189
+ }
190
+
191
+ /**
192
+ * How many times the price of COPYING this file has changed.
193
+ *
194
+ * Asked by the offer's cache key: everything the answer is derived from has
195
+ * to be in what identifies it, or the menu keeps an answer computed before
196
+ * anything was measured.
197
+ *
198
+ * @param {string} fileKey
199
+ * @returns {number}
200
+ */
201
+ copyVersionFor(fileKey) {
202
+ return this.#copyCost.get(fileKey)?.version ?? 0;
203
+ }
204
+
205
+ /**
206
+ * How many times the price of this session's soundtrack has changed.
207
+ *
208
+ * @param {object} session
209
+ * @returns {number}
210
+ */
211
+ audioVersionFor(session) {
212
+ return this.#audioCost.get(EncodeCost.audioKeyOf(session))?.version ?? 0;
213
+ }
214
+
215
+ /**
216
+ * What a running re-encode of the picture costs, in seconds of work per
217
+ * second of video.
218
+ *
219
+ * Measured first: `lastAloneSpeed` is what this very rung did with the
220
+ * machine to itself. Failing that, the encode model that decides every rung —
221
+ * the same benchmark, the same decode term — applied to this rung's own pixel
222
+ * rate. There is no third answer: a rung whose cost cannot be derived at all
223
+ * contributes nothing rather than a number somebody invented.
224
+ *
225
+ * @param {HlsSession} session
226
+ * @returns {number}
227
+ */
228
+ #pictureCostOf(session) {
229
+ const state = qualityStateOf(session);
230
+ if (Number.isFinite(state.lastAloneSpeed) && state.lastAloneSpeed > 0) {
231
+ return 1 / state.lastAloneSpeed;
232
+ }
233
+ // WHAT AN ENCODE OF THIS MODE WAS SEEN DOING HERE BEFORE, alone, on
234
+ // material no easier than this (roadmap item 97, step 14): the slowest
235
+ // such reading, on this configuration only. It refines the startup
236
+ // prediction; with nothing seen, the prediction stands.
237
+ const seen = Number(this.#host().observedAloneSpeed?.(session));
238
+ if (Number.isFinite(seen) && seen > 0) {
239
+ return 1 / seen;
240
+ }
241
+ const benchmark = this.#host().benchmark;
242
+ const width = Number(session.output.encodeWidth) || 0;
243
+ const height = Number(session.output.encodeHeight) || 0;
244
+ const fps = Number(session.output.outputFps) || TRANSCODE_FPS;
245
+ if (!Array.isArray(benchmark) || benchmark.length === 0 || width <= 0 || height <= 0) {
246
+ return 0;
247
+ }
248
+ const { speed } = canSustainOutput({
249
+ benchmark,
250
+ decodeModel: this.#host().decodeModel,
251
+ source: session.file.decode ?? null,
252
+ outputPixelsPerSec: width * height * fps,
253
+ observedDecodeCostSec: null,
254
+ concurrentCostSec: 0,
255
+ frame: { width, height }
256
+ });
257
+ return Number.isFinite(speed) && speed > 0 ? 1 / speed : 0;
258
+ }
259
+
260
+ /**
261
+ * How fast this machine produces ONE output, in seconds of film per second.
262
+ *
263
+ * THERE IS ALWAYS AN ANSWER, and that is the point of this method. Every
264
+ * decision in the encoding layer is made from arrivals — when would this
265
+ * encoder reach that piece — and an arrival cannot be computed without a
266
+ * speed. A speed that is missing is therefore not a smaller answer, it is no
267
+ * answer at all: the plan then cannot tell a viewer who will be served from
268
+ * one who will be left waiting, and the moment it happens is the cold start,
269
+ * which is when the question matters most.
270
+ *
271
+ * Three sources, most specific first, and every one of them measured:
272
+ *
273
+ * 1. what a run on THIS output has been seen doing. It is this machine, this
274
+ * material and these settings, so nothing beats it;
275
+ * 2. the startup benchmark, for an output whose picture is re-encoded: the
276
+ * preset readings and the decode model, applied to this output's own pixel
277
+ * rate. It exists before any viewer;
278
+ * 3. the startup copy measurement, for an output whose picture is copied.
279
+ * Copying neither decodes nor encodes, so neither of the above describes
280
+ * it, and until it was measured this branch had no figure at all.
281
+ *
282
+ * @param {string} address - The output, as the encoding layer names it.
283
+ * @returns {number} Seconds of film per second. Zero only where the host
284
+ * measured nothing at all, which is a broken startup rather than a state to
285
+ * plan around.
286
+ */
287
+ speedForOutput(address) {
288
+ const outputs = this.#outputs.outputsOn(address);
289
+ let measured = 0;
290
+ for (const session of outputs) {
291
+ const speed = Number(qualityStateOf(session).lastAloneSpeed);
292
+ if (Number.isFinite(speed) && speed > measured) {
293
+ measured = speed;
294
+ }
295
+ }
296
+ if (measured > 0) {
297
+ return measured;
298
+ }
299
+ for (const session of outputs) {
300
+ if (session.spec.transcodesVideo) {
301
+ const cost = this.#pictureCostOf(session);
302
+ if (cost > 0) {
303
+ return 1 / cost;
304
+ }
305
+ continue;
306
+ }
307
+ const copying = Number(this.#host().copySpeedX);
308
+ if (Number.isFinite(copying) && copying > 0) {
309
+ return copying;
310
+ }
311
+ }
312
+ return 0;
313
+ }
314
+
315
+ /**
316
+ * What one encoder on this output costs the machine, in seconds of work per
317
+ * second of film, and what its file costs simply by being fetched.
318
+ *
319
+ * The unit the quality offer already judges every step in, so that "may one
320
+ * more encoder run" and "may this height be offered" are the same arithmetic
321
+ * and cannot disagree. Three kinds, priced from what each was seen doing:
322
+ *
323
+ * 1. a soundtrack, from its own measured speed;
324
+ * 2. a copied picture, from its own measured speed, and otherwise from the
325
+ * startup copy measurement;
326
+ * 3. a re-encoded picture, from what it did alone, and otherwise from the
327
+ * encode model applied to its own pixel rate.
328
+ *
329
+ * NULL WHEN NOTHING HAS PRICED IT, and never a guess: a soundtrack nobody has
330
+ * measured yet contributes nothing, as it does to the offer. The caller says
331
+ * so rather than inventing a figure.
332
+ *
333
+ * @param {string} address - The output, as the encoding layer names it.
334
+ * @returns {{ costSec: number | null, fileKey: string, fileCostSec: number } | null}
335
+ * Null when no output of that address is here.
336
+ */
337
+ loadOfOutput(address) {
338
+ const session = this.#outputs.outputsOn(address)[0] ?? null;
339
+ if (!session) {
340
+ return null;
341
+ }
342
+ return this.#loadOfSession(session);
343
+ }
344
+
345
+ /**
346
+ * What a not-yet-opened output would cost, priced by the same readings and
347
+ * rules as an output already in the catalogue. This lets the machine-wide
348
+ * admission answer before the format is committed, so AUTO can select a
349
+ * lower rung that the whole host can sustain.
350
+ *
351
+ * @param {object} spec
352
+ * @param {object} file
353
+ * @returns {{ costSec: number | null, fileKey: string, fileCostSec: number } | null}
354
+ */
355
+ loadForCandidate(spec, file) {
356
+ if (!spec || !file) {
357
+ return null;
358
+ }
359
+ const encode = spec.video?.encode ?? null;
360
+ const candidate = {
361
+ spec,
362
+ file,
363
+ output: {
364
+ encodeWidth: Number(encode?.width) || 0,
365
+ encodeHeight: Number(encode?.height) || 0,
366
+ outputFps: Number(encode?.fps) || TRANSCODE_FPS
367
+ }
368
+ };
369
+ return this.#loadOfSession(candidate);
370
+ }
371
+
372
+ #loadOfSession(session) {
373
+ const fileKey = session.file?.key ?? "";
374
+ const fileCost = Number(this.#torrentCostSecFor(session));
375
+ const fileCostSec = Number.isFinite(fileCost) && fileCost > 0 ? fileCost : 0;
376
+ const measured = Number(qualityStateOf(session).lastAloneSpeed);
377
+ if (Number.isFinite(measured) && measured > 0) {
378
+ return { costSec: 1 / measured, fileKey, fileCostSec };
379
+ }
380
+ if (session.spec.carries === "audio-only") {
381
+ const audio = this.#audioCost.get(EncodeCost.audioKeyOf(session));
382
+ const initial = audioReadingFor(this.#host().audioCalibration, {
383
+ ...this.#host().audioDescriptionOf?.(session),
384
+ codec: this.#host().audioDescriptionOf?.(session)?.codec ?? session.file.media?.audioCodec,
385
+ transcode: session.spec.audio?.transcode === true
386
+ });
387
+ const initialSpeed = initial?.speed;
388
+ return { costSec: audio && audio.costSec > 0 ? audio.costSec :
389
+ initialSpeed > 0 ? 1 / initialSpeed : null, fileKey, fileCostSec };
390
+ }
391
+ if (!session.spec.transcodesVideo) {
392
+ const copy = this.#copyCost.get(session.file.key);
393
+ if (copy && copy.costSec > 0) {
394
+ return { costSec: copy.costSec, fileKey, fileCostSec };
395
+ }
396
+ const copying = Number(this.#host().copySpeedX);
397
+ return { costSec: Number.isFinite(copying) && copying > 0 ? 1 / copying : null, fileKey, fileCostSec };
398
+ }
399
+ const picture = this.#pictureCostOf(session);
400
+ return { costSec: picture > 0 ? picture : null, fileKey, fileCostSec };
401
+ }
402
+
403
+ /** A prediction for this operation, using its measured cost and actual contention. */
404
+ projectedSpeedOf(session) {
405
+ const load = this.#loadOfSession(session);
406
+ const others = Math.max(0, this.#runningEncoders() - this.#runsFor(session).length);
407
+ const { penalty } = contentionPenalty(others, this.#host().contentionPenalties);
408
+ return load.costSec > 0 ? correctForAvailability(1 / (load.costSec * penalty),
409
+ this.#host().availability) : null;
410
+ }
411
+
412
+ /**
413
+ * What everything OTHER than this session is costing right now, or null when
414
+ * any of it is unpriced.
415
+ *
416
+ * Used to recover a soundtrack's own share from a reading taken beside the
417
+ * picture — the only kind of reading a rendition ever gives, since it runs
418
+ * exactly as long as the picture does. Refusing to answer when something
419
+ * running has no price is the point: unpriced work would otherwise be
420
+ * attributed to the soundtrack, and an overpriced soundtrack refuses quality
421
+ * steps the host could actually hold.
422
+ *
423
+ * @param {HlsSession} session
424
+ * @returns {number | null}
425
+ */
426
+ pricedConcurrentCost(session) {
427
+ let cost = 0;
428
+ for (const member of this.#outputs.familyOf(session)) {
429
+ if (member === session || !processCanBeSignalled(this.#stateFor(member))) {
430
+ continue;
431
+ }
432
+ if (member.spec.carries === "audio-only") {
433
+ const audio = this.#audioCost.get(EncodeCost.audioKeyOf(member));
434
+ if (!audio || !(audio.costSec > 0)) {
435
+ return null;
436
+ }
437
+ cost += audio.costSec;
438
+ continue;
439
+ }
440
+ if (!member.spec.transcodesVideo) {
441
+ const copy = this.#copyCost.get(member.file.key);
442
+ if (!copy || !(copy.costSec > 0)) {
443
+ return null;
444
+ }
445
+ cost += copy.costSec;
446
+ continue;
447
+ }
448
+ const picture = this.#pictureCostOf(member);
449
+ if (!(picture > 0)) {
450
+ return null;
451
+ }
452
+ cost += picture;
453
+ }
454
+ // Encoders outside this family are counted by number only — there is no
455
+ // price to look up for another film's session — so a reading taken while
456
+ // one is running cannot be attributed either.
457
+ return this.#runningEncoders() > this.#outputs.familyOf(session).filter(
458
+ (member) => processCanBeSignalled(this.#stateFor(member))
459
+ ).length
460
+ ? null
461
+ : cost;
462
+ }
463
+
464
+ /**
465
+ * What each height of this family is costing RIGHT NOW, for the heights an
466
+ * encoder is actually running at.
467
+ *
468
+ * Exists so a height can be judged against what the machine spends on
469
+ * everything else — a step being warmed is running while it is judged, and
470
+ * charged its own cost it refuses itself.
471
+ *
472
+ * @param {HlsSession} session
473
+ * @returns {Map<number, number>}
474
+ */
475
+ runningCostByHeight(session) {
476
+ /** @type {Map<number, number>} */
477
+ const byHeight = new Map();
478
+ for (const member of this.#outputs.familyOf(session)) {
479
+ if (member.spec.carries === "audio-only" || !member.spec.transcodesVideo) {
480
+ continue;
481
+ }
482
+ if (!processCanBeSignalled(this.#stateFor(member))) {
483
+ continue;
484
+ }
485
+ const height = this.#outputs.variantHeightOf(member);
486
+ if (height > 0) {
487
+ byHeight.set(height, (byHeight.get(height) ?? 0) + this.#pictureCostOf(member));
488
+ }
489
+ }
490
+ return byHeight;
491
+ }
492
+
493
+ /**
494
+ * Seconds of work per second of video this family is ALREADY committed to,
495
+ * beside any rung being considered.
496
+ *
497
+ * Every encoder of the family that is actually running: the picture, whether
498
+ * it is copied or re-encoded, and each audio rendition. The rung the viewer
499
+ * is watching and the source's own copied height are never withdrawn by the
500
+ * caller, so charging for the encoder that serves them cannot strand anyone —
501
+ * what it does is stop the NEXT rung being offered as though the machine were
502
+ * idle, which is what the field disproved on 2026-08-15.
503
+ *
504
+ * Anything whose cost is neither measured nor derivable contributes nothing.
505
+ * A guess here would refuse rungs on arithmetic nobody performed.
506
+ *
507
+ * @param {HlsSession} session
508
+ * @returns {number}
509
+ */
510
+ committedCostOf(session) {
511
+ let cost = 0;
512
+ for (const member of this.#outputs.familyOf(session)) {
513
+ // Only what still HAS an encoder. A quality step the viewer left keeps
514
+ // its session and its segments but not a process, and it produces nothing
515
+ // for anybody — charging the machine for it would refuse steps on work
516
+ // nobody is doing.
517
+ //
518
+ // A SUSPENDED encoder is charged, deliberately, and this is not the same
519
+ // question. The unit here is seconds of work per second of VIDEO, not per
520
+ // second of wall clock: a copy running at 8x costs 0.125 s/s whether it
521
+ // is producing right now or parked by the look-ahead cap, because over an
522
+ // hour of watching it still produces an hour of video. Suspension is how
523
+ // that cost is spread, not a discount on it — and pricing a parked
524
+ // encoder at zero would offer a step on the strength of a pause that ends
525
+ // the moment the viewer catches up.
526
+ if (!processCanBeSignalled(this.#stateFor(member))) {
527
+ continue;
528
+ }
529
+ if (member.spec.carries === "audio-only") {
530
+ // A soundtrack encoder, priced from its own measured speed. Nothing is
531
+ // charged for a track nobody has measured: a guess here refuses rungs
532
+ // on arithmetic no one performed.
533
+ const audio = this.#audioCost.get(EncodeCost.audioKeyOf(member));
534
+ cost += audio && audio.costSec > 0 ? audio.costSec : 0;
535
+ continue;
536
+ }
537
+ if (!member.spec.transcodesVideo) {
538
+ const observed = this.#copyCost.get(member.file.key);
539
+ cost += observed && observed.costSec > 0 ? observed.costSec : 0;
540
+ continue;
541
+ }
542
+ // A picture being RE-ENCODED beside the rung being judged — the warm-up
543
+ // that makes a quality switch seamless is two encoders by design, and
544
+ // that overlap is exactly where the field measured 0.504x on a rung
545
+ // predicted at 1.58x (2026-08-15). Priced by what it has been SEEN doing
546
+ // when it had the machine to itself, and otherwise by the same model that
547
+ // judges every rung — which is a prediction, not a guess.
548
+ cost += this.#pictureCostOf(member);
549
+ }
550
+ // And what the FILE costs simply by being fetched and delivered while it is
551
+ // watched: a viewer consumes it at its own byte rate, and every one of
552
+ // those bytes is downloaded, verified and pushed by this process. Priced
553
+ // per megabyte from readings taken while nothing was encoding, so the two
554
+ // measurements do not contain each other.
555
+ cost += this.#torrentCostSecFor(session);
556
+ return cost;
557
+ }
558
+
559
+ /**
560
+ * The speed each rung of this family was last seen running at, when it was
561
+ * running alone.
562
+ *
563
+ * A rung that has been watched failing is refused on that evidence; a rung
564
+ * nobody has run says nothing about itself and is judged by the startup
565
+ * measurement like any other.
566
+ *
567
+ * @param {HlsSession} base
568
+ * @returns {Map<number, number>}
569
+ */
570
+ measuredRungSpeeds(base) {
571
+ /** @type {Map<number, number>} */
572
+ const speeds = new Map();
573
+ for (const session of this.#outputs.familyOf(base)) {
574
+ const speed = qualityStateOf(session).lastAloneSpeed;
575
+ if (!session.spec.transcodesVideo || !Number.isFinite(speed)) {
576
+ continue;
577
+ }
578
+ const height = this.#outputs.variantHeightOf(session);
579
+ if (height > 0) {
580
+ speeds.set(height, speed);
581
+ }
582
+ }
583
+ return speeds;
584
+ }
585
+
586
+ /**
587
+ * Drop the rungs this host cannot hold at realtime.
588
+ *
589
+ * Every rung below the source height is a full re-encode — decode the whole
590
+ * source, encode a smaller picture — and on a weak host that is dearer than
591
+ * the copy it replaces. Measured 2026-08-14: 1080p was copied at 7.8-8.9x
592
+ * while the offered 240p rung ran at 0.388-0.947x, its first segment took
593
+ * 30 s and later ones were held 22 s, so choosing a LOWER quality is what
594
+ * broke playback. A rung that cannot be produced faster than it is watched
595
+ * must not be offered at all.
596
+ *
597
+ * The session's OWN height always stays: an encoder is already producing it,
598
+ * and removing it would point the player at a rung nobody is encoding.
599
+ *
600
+ * @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
601
+ * @returns {number[]}
602
+ */
603
+ sustainableHeights({
604
+ heights,
605
+ ownHeight,
606
+ // Every height a viewer has on screen, not one: two viewers of one picture
607
+ // can be on two rungs, and a rung is never withdrawn while somebody is
608
+ // watching it — their next segment would 404 on a stream that is playing.
609
+ playingHeights = new Set(),
610
+ sourceWidth,
611
+ sourceHeight,
612
+ fps,
613
+ source,
614
+ transcodeVideo,
615
+ observedDecodeCostSec = null,
616
+ concurrentCostSec = 0,
617
+ runningCostByHeight = null,
618
+ measuredHeights = null,
619
+ requiredSpeed = null
620
+ }) {
621
+ // What this file's own supply demands, measured by its reader — and
622
+ // realtime while it has not been measured. Read once here so the line that
623
+ // reports a refusal names the figure it refused against.
624
+ const bar = speedBar(requiredSpeed);
625
+ const benchmark = this.#host().benchmark;
626
+ // CALIBRATED WITH NOTHING QUALIFIED is not the same as not calibrated at
627
+ // all (roadmap item 97, step 14): with no mode of this encoder shown to
628
+ // work, nothing re-encoded is offered.
629
+ const nothingQualified = Array.isArray(benchmark) && benchmark.length === 0;
630
+ if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
631
+ // Nothing to predict WITH, so nothing is predicted. What has been SEEN
632
+ // still counts: a rung measured running below realtime is withdrawn here
633
+ // too, because the evidence for it does not come from the benchmark. This
634
+ // return used to hand back every height including one measured at 0.4x —
635
+ // found by a check written when this moved out of the session manager,
636
+ // 2026-09-05.
637
+ // The rung on screen is not exempt, for the same reason it is not exempt
638
+ // below: keeping one measured at 0.007x stalls the viewer with no path to
639
+ // a faster rung, which is what the field showed on 2026-08-31.
640
+ //
641
+ // What nothing has priced is not offered either (roadmap item 97, step
642
+ // 14): with no mode of this encoder qualified at startup, only a height
643
+ // that needs no encoder — the copied source — or one an encoder is
644
+ // already seen holding stays.
645
+ return heights.filter((height) => {
646
+ const measured = measuredHeights?.get(height) ?? null;
647
+ if (measured !== null) {
648
+ return measured >= 1;
649
+ }
650
+ return !nothingQualified || (!transcodeVideo && (height === sourceHeight || height === ownHeight));
651
+ });
652
+ }
653
+ /** @type {number[]} */
654
+ const kept = [];
655
+ /** @type {string[]} */
656
+ const dropped = [];
657
+ // What each height was predicted to do on THIS machine, kept so a session
658
+ // started at that height can be compared against it once it runs. The
659
+ // manager holds the last answer, because the offer is computed on the path
660
+ // that serves every request while a session is created elsewhere.
661
+ /** @type {Map<number, number | null>} */
662
+ const predictedByHeight = new Map();
663
+ for (const height of heights) {
664
+ // A rung this session has actually been seen running below realtime is
665
+ // withdrawn on that evidence, whatever the prediction says. This is the
666
+ // one thing a live reading is authority on: itself. It is asked before
667
+ // any exemption so a rung measured failing while on screen does not stay
668
+ // offered because it was on screen when measured — otherwise a step
669
+ // would ask for the one rung this machine has been measured failing at,
670
+ // then fail again, then step down, for ever. A copied source height
671
+ // cannot reach this: `#measuredRungSpeeds` records only sessions that
672
+ // re-encode, so a copy has no reading to be withdrawn on, which is right
673
+ // — it costs no encoder.
674
+ const measured = measuredHeights?.get(height) ?? null;
675
+ if (measured !== null && measured < 1) {
676
+ // Even the rung on screen is withdrawn on measured failure: keeping it
677
+ // would 404 the next segment, but keeping a rung measured at 0.007x
678
+ // (field 2026-08-31, 4K HEVC on CM4) stalls the viewer for minutes with
679
+ // 0.04s buffered and no way to downgrade because every other rung is
680
+ // also dropped. Withdrawing it lets the offer become empty, which the
681
+ // caller turns into an error the viewer can act on (try another proxy
682
+ // or a lower source) instead of an endless spinner.
683
+ dropped.push(`${height}p=${measured.toFixed(2)}x measured`);
684
+ continue;
685
+ }
686
+ // The rung ON SCREEN is kept only when it has not been measured failing
687
+ // above. Keeping a rung measured at 0.007x would stall the viewer with
688
+ // no path to a faster rung, which is what the field showed.
689
+ if (playingHeights.has(height)) {
690
+ kept.push(height);
691
+ continue;
692
+ }
693
+ // The height an encoder is ALREADY producing, and the source's own height
694
+ // when the FAMILY serves it by copy — neither has to be predicted,
695
+ // because it is happening. A copied rung costs no encoder at all, so no
696
+ // measurement of this host can ever be a reason to withdraw it, and the
697
+ // whole point of it is that it is where a viewer on a rung the machine
698
+ // cannot hold goes back to. `transcodeVideo` here is the base's, not the
699
+ // asking session's: a 240p rung re-encodes, and reading its own flag is
700
+ // what withdrew a copied 1080p in the field on 2026-08-15.
701
+ //
702
+ // A source height that would have to be RE-ENCODED is a prediction like
703
+ // any other: on a session whose budget stepped down to 480p, the source's
704
+ // 1080p is neither copied nor being produced, and keeping it unpriced
705
+ // would offer exactly the kind of rung this refuses. Likewise, a rung
706
+ // this session is already producing at 0.007x (field 2026-08-31, 4K HEVC
707
+ // on CM4, 0.1x at 23:45 and 0.007x at 06:57) is not sustainable just
708
+ // because it is running — keeping it offered no path to a faster rung
709
+ // and left the viewer at 0.04s buffered with no downgrade.
710
+ if (
711
+ (height === ownHeight && !transcodeVideo) ||
712
+ (height === sourceHeight && !transcodeVideo)
713
+ ) {
714
+ kept.push(height);
715
+ continue;
716
+ }
717
+ const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
718
+ // What the machine is spending on everything EXCEPT this height. A step
719
+ // being warmed for a switch is already running while it is judged, so its
720
+ // own cost is inside the committed total — and charged against itself it
721
+ // is counted twice. Measured against the field figures of 2026-08-15
722
+ // that is 1.83x against 1.03x: below the margin, so the step the viewer
723
+ // had just asked for was dropped from the offer by the act of warming it,
724
+ // and its next segment answered 404 on a stream that was playing.
725
+ const concurrentBesideThis = Math.max(
726
+ 0,
727
+ concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
728
+ );
729
+ const { speed } = canSustainOutput({
730
+ benchmark,
731
+ decodeModel: this.#host().decodeModel,
732
+ source,
733
+ outputPixelsPerSec: width * height * fps,
734
+ observedDecodeCostSec,
735
+ concurrentCostSec: concurrentBesideThis,
736
+ frame: { width, height }
737
+ });
738
+ // The benchmark behind that figure was taken on a QUIET host — one
739
+ // ffmpeg and nothing else. The machine a step will actually run on is
740
+ // also running the kernel, the container and whatever else its owner
741
+ // does, and on the addon host that was measured at 99 % busy with a
742
+ // quarter of it unattributed. Only the unattributed part is charged
743
+ // here: our own encoders are already in `concurrentBesideThis` and the
744
+ // proxy's own work is already priced per megabyte moved.
745
+ // Two corrections, and they are different facts about the machine. The
746
+ // availability share removes work nobody has been charged for; the
747
+ // contention penalty says what OUR OWN second job costs, because the
748
+ // budget adds independent prices and this host does not behave that way
749
+ // — the same work measured 2.6× dearer beside one encoder and 3.7×
750
+ // beside two (2026-08-18). `concurrentBesideThis` already counts what is
751
+ // committed; this multiplies by how badly running at all together goes.
752
+ const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
753
+ const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
754
+ const onThisMachine = correctForAvailability(
755
+ speed === null ? null : speed / penalty,
756
+ this.#host().availability
757
+ );
758
+ // Kept against the step's own session, so that when it runs the field
759
+ // says what the prediction was worth. Without this the only comparison
760
+ // available is between two figures written minutes apart in different
761
+ // lines of the log.
762
+ predictedByHeight.set(height, onThisMachine);
763
+ if (onThisMachine !== null && onThisMachine >= bar) {
764
+ kept.push(height);
765
+ continue;
766
+ }
767
+ dropped.push(`${height}p=${onThisMachine === null ? "n/a" : `${onThisMachine.toFixed(2)}x`}`);
768
+ }
769
+ // Written when the ANSWER changes, not when the answer is recomputed. This
770
+ // is asked on the path that serves every playlist, init and segment, and
771
+ // the figures behind it move every five seconds — so an unconditional line
772
+ // here is roughly seven hundred identical lines an hour into a forwarder
773
+ // that holds five hundred, which buries whatever is worth reading.
774
+ if (dropped.length > 0) {
775
+ const line =
776
+ `transcode: not offering ${dropped.join(" ")} — below ${bar.toFixed(2)}x ` +
777
+ (Number.isFinite(requiredSpeed) && requiredSpeed > 1
778
+ ? "(the speed this file's own interruptions demand) "
779
+ : "(realtime, this file's supply not measured yet) ") +
780
+ // Said with the figures, because a step refused on a busy machine and
781
+ // one refused on an idle machine are different facts about the host.
782
+ (this.#host().availability?.known
783
+ ? `on a machine with ${Math.round(this.#host().availability.share * 100)}% to spare `
784
+ : "") +
785
+ `(offering ${kept.map((height) => `${height}p`).join(" ")})`;
786
+ if (line !== this.#lastOfferLine) {
787
+ this.#lastOfferLine = line;
788
+ logger.info(line);
789
+ }
790
+ this.lastPredictedByHeight = predictedByHeight;
791
+ } else {
792
+ this.lastPredictedByHeight = predictedByHeight;
793
+ this.#lastOfferLine = "";
794
+ }
795
+ return kept;
796
+ }
797
+
798
+ /**
799
+ * Take one reading off an encoder that is running, and file it as the price of
800
+ * whatever that encoder is doing.
801
+ *
802
+ * Separate from the realtime budget, which asks a different question — should
803
+ * the quality step down — and answers it only where it CAN step down. Most of
804
+ * what is worth measuring is excluded by that: a rung at the foot of its
805
+ * ladder, a step whose ladder is one rung long, a picture that is copied.
806
+ * Measuring has no such preconditions.
807
+ *
808
+ * What it does refuse: a suspended encoder (ffmpeg reports a CUMULATIVE
809
+ * speed, so a look-ahead pause is divided into it and the figure decays while
810
+ * nothing is being encoded), a reading that has not moved since the last one,
811
+ * and a run short of input, where what is short is the swarm rather than the
812
+ * machine.
813
+ *
814
+ * @param {HlsSession} session
815
+ * @returns {Promise<void>}
816
+ */
817
+ async learnFrom(session) {
818
+ if (!session) {
819
+ return;
820
+ }
821
+ if (
822
+ this.#stateFor(session) === ENCODE_RUN_STATE.ENDED_FAILED ||
823
+ liveRunsOf(this.#runsFor(session)).length === 0 ||
824
+ this.#stateFor(session) === ENCODE_RUN_STATE.SUSPENDED
825
+ ) {
826
+ qualityStateOf(session).learnSample = null;
827
+ return;
828
+ }
829
+ // Measured as a DELTA between two readings of a run that was going for the
830
+ // whole interval, not from ffmpeg's cumulative `speed=`. The cumulative
831
+ // figure counts every second the encoder spent SIGSTOPped by the look-ahead
832
+ // cap in its denominator, and a copy spends most of its life there — it
833
+ // reaches the cap in about fifteen seconds and then waits a minute. Read
834
+ // that way a copy running at 8x reports 1.6x and falling, which would be
835
+ // filed as the price of copying and refuse rungs on arithmetic that
836
+ // measured a pause.
837
+ const run = liveRunsOf(this.#runsFor(session))[0] ?? null;
838
+ // The position is THAT run's own, read from the same process the sample is
839
+ // stamped with. It used to be the output's progress, which is whichever
840
+ // live run covers the viewer and updated last — so with two encoders on
841
+ // one output a pair of readings could be two processes' positions filed
842
+ // under one run, and the difference between them read as a speed.
843
+ const processedSeconds = Number(this.#progressFor(session, run)?.processedSeconds);
844
+ const takenAt = Date.now();
845
+ const state = qualityStateOf(session);
846
+ const previous = state.learnSample ?? null;
847
+ // Stamped with the run it was taken from. A restart clears this sample, but
848
+ // it then spends up to a second and a half making its directory and burying
849
+ // its predecessor, and through that window the session still carries the
850
+ // OLD process and the OLD position — so a sample taken there, paired with
851
+ // the new run's first position, reads a twenty-minute seek as twenty
852
+ // minutes of video produced in five seconds. Filed as this file's price it
853
+ // admits every quality step there is. Comparing the serials is what the
854
+ // twenty-second wait used to stand in for, and unlike the wait it costs no
855
+ // readings on a short run.
856
+ state.learnSample = { takenAt, processedSeconds, run };
857
+ if (previous === null || !Number.isFinite(processedSeconds) || !Number.isFinite(previous.processedSeconds)) {
858
+ return;
859
+ }
860
+ if (previous.run !== run) {
861
+ return; // the pair straddles a restart and measures the seek, not the host
862
+ }
863
+ const speed = speedFromReadings(previous, { takenAt, processedSeconds }, LEARN_WINDOW_MIN_SEC);
864
+ if (speed === null) {
865
+ return;
866
+ }
867
+ // Recorded HERE, before any of the conditions below can discard the
868
+ // reading, because the budget and the learning ask different questions of
869
+ // it. Learning refuses a reading taken beside another encoder, since it
870
+ // would file that encoder's work as this file's price; the budget wants
871
+ // exactly what this run is doing right now, whatever else the machine is
872
+ // doing beside it. Sharing the figure and not the conditions is what lets
873
+ // the budget stop reading ffmpeg's cumulative average.
874
+ state.recentSpeed = { speed, at: takenAt, run };
875
+ const kind = costKindForSession(session);
876
+ // A reading taken beside another encoder contains that other encoder's
877
+ // work, and the budget ADDS the same work again when it predicts — so filed
878
+ // as it stands the price is counted twice and grows with every reading.
879
+ // Measured 2026-08-15 in the field: copying, whose truth is 7.9x, was
880
+ // learned as 2.03x, and decoding, whose clips say 2.6x, as 0.87x. Every
881
+ // step was then refused, the offer collapsed to the one copied height, and
882
+ // the viewer lost the quality menu altogether.
883
+ //
884
+ // For a picture the answer is to wait for a moment alone, which comes often
885
+ // enough. For a SOUNDTRACK it never comes: a rendition runs for exactly as
886
+ // long as the picture it accompanies, so "alone" is a state it is never in,
887
+ // and the price stayed unmeasured for ever — the hole this was meant to
888
+ // close. Its share is instead recovered by subtracting what the machine is
889
+ // already known to be spending, which is the same arithmetic that recovers
890
+ // this source's decoding from a running encoder, and it is only done when
891
+ // every other running encode HAS a price. Otherwise the unpriced work would
892
+ // land in the soundtrack's account and refuse steps on it.
893
+ let othersCostSec = 0;
894
+ if (this.#runningEncoders() > 1) {
895
+ if (kind !== "audio") {
896
+ return;
897
+ }
898
+ const others = this.pricedConcurrentCost(session);
899
+ if (others === null) {
900
+ return; // something running has no price; nothing can be attributed
901
+ }
902
+ othersCostSec = others;
903
+ }
904
+ if (speed < 1 && await this.#boundBy(session) === "download") {
905
+ return; // the torrent is what is short; this says nothing about the host
906
+ }
907
+ // What this encode did with the machine to itself — the one figure a live
908
+ // reading is authority on, and what withdraws a quality step that has been
909
+ // seen failing without letting it speak for steps nobody has run.
910
+ //
911
+ // Recorded only AFTER the download-bound check, and that order is the whole
912
+ // point: a run starved of torrent data reports a speed that measures the
913
+ // swarm. Stored first, as it was, that figure became this encode's price —
914
+ // 0.3x reads as 3.33 s of work per second of video, more than the machine
915
+ // has — and every other quality step was refused on the download's account.
916
+ state.lastAloneSpeed = speed;
917
+ // What the offer predicted for this very step, against what it then did
918
+ // with the machine to itself. The prediction is corrected for the share of
919
+ // the machine that was free at the time, so this ratio is the error that
920
+ // remains AFTER that correction — which is the only way to tell whether a
921
+ // stage of roadmap item 3 moved anything. Written when it changes by more
922
+ // than a tenth, so a steady step says it once rather than every five
923
+ // seconds.
924
+ if (Number.isFinite(state.predictedSpeedWhenOffered) && state.predictedSpeedWhenOffered > 0) {
925
+ const ratio = speed / state.predictedSpeedWhenOffered;
926
+ const lastSaid = state.lastPredictionRatio;
927
+ if (!Number.isFinite(lastSaid) || Math.abs(ratio - lastSaid) > 0.1) {
928
+ state.lastPredictionRatio = ratio;
929
+ logger.info(
930
+ `prediction ${session.id.slice(0, 8)} ${session.output.encodeHeight || "source"}p: ` +
931
+ `predicted ${state.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
932
+ `(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)`
933
+ );
934
+ }
935
+ }
936
+ if (kind === "audio") {
937
+ // What is left after the work that was already accounted for. `null` when
938
+ // the subtraction leaves nothing positive, which means the reading says
939
+ // less than the noise in it.
940
+ const ownCostSec = 1 / speed - othersCostSec;
941
+ if (!(ownCostSec > 0) || !Number.isFinite(ownCostSec)) {
942
+ return;
943
+ }
944
+ await this.#learnAudioCost(session, 1 / ownCostSec);
945
+ return;
946
+ }
947
+ if (kind === "decode") {
948
+ this.#learnDecodeCost(session, speed);
949
+ return;
950
+ }
951
+ await this.#learnCopyCost(session, speed);
952
+ }
953
+
954
+ async #learnCopyCost(session, speed) {
955
+ if (this.#stateFor(session) === ENCODE_RUN_STATE.SUSPENDED) {
956
+ return; // a suspended run reports a cumulative figure that is decaying
957
+ }
958
+ // Always asked, not only below realtime. A re-encode near 1x may be the
959
+ // host; a COPY near 1x is a copy waiting for the torrent, because copying
960
+ // is what a machine does at eight times realtime — and a starved reading
961
+ // filed as the price of copying would refuse rungs on the download's
962
+ // account.
963
+ if (await this.#boundBy(session) === "download") {
964
+ return;
965
+ }
966
+ const costSec = 1 / speed;
967
+ if (!(costSec > 0) || !Number.isFinite(costSec)) {
968
+ return;
969
+ }
970
+ const key = session.file.key;
971
+ const known = this.#copyCost.get(key);
972
+ const readings = [...(known?.readings ?? []), costSec].slice(-READINGS_KEPT);
973
+ const median = medianOf(readings);
974
+ if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
975
+ this.#copyCost.set(key, { ...known, readings });
976
+ return;
977
+ }
978
+ this.#copyCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
979
+ logger.info(
980
+ `transcode: ${session.file.name} copies at ${(1 / median).toFixed(2)}x on this host ` +
981
+ `(median of ${readings.length}, latest ${speed.toFixed(2)}x)`
982
+ );
983
+ }
984
+
985
+ async #learnAudioCost(session, speed) {
986
+ if (this.#stateFor(session) === ENCODE_RUN_STATE.SUSPENDED) {
987
+ return;
988
+ }
989
+ if (await this.#boundBy(session) === "download") {
990
+ return;
991
+ }
992
+ const costSec = 1 / speed;
993
+ if (!(costSec > 0) || !Number.isFinite(costSec)) {
994
+ return;
995
+ }
996
+ const key = EncodeCost.audioKeyOf(session);
997
+ const known = this.#audioCost.get(key);
998
+ const readings = [...(known?.readings ?? []), costSec].slice(-READINGS_KEPT);
999
+ const median = medianOf(readings);
1000
+ if (!movedBeyondScatter(known?.costSec ?? null, median, readings)) {
1001
+ this.#audioCost.set(key, { ...known, readings });
1002
+ return;
1003
+ }
1004
+ this.#audioCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
1005
+ logger.info(
1006
+ `transcode: ${session.file.name} encodes audio track ${session.spec.audioSourceTrackIndex} at ` +
1007
+ `${(1 / median).toFixed(2)}x on this host (median of ${readings.length}, latest ${speed.toFixed(2)}x)`
1008
+ );
1009
+ }
1010
+
1011
+ #learnDecodeCost(session, speed) {
1012
+ if (!(speed > 0)) {
1013
+ return;
1014
+ }
1015
+ if (!session.spec.transcodesVideo) {
1016
+ // Nothing to learn about decoding here, and nothing else either: the
1017
+ // caller routes a copy to #learnCopyCost and a rendition to
1018
+ // #learnAudioCost before this is ever reached. Routing them from here as
1019
+ // well put both calls behind a guard the caller had already made
1020
+ // (`transcodeVideo === true`), so neither could run.
1021
+ return;
1022
+ }
1023
+ if (this.#host().encoderKind !== "software") {
1024
+ return; // the benchmark that prices the encode half is libx264 only
1025
+ }
1026
+ const benchmark = this.#host().benchmark;
1027
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1028
+ return;
1029
+ }
1030
+ const entry = benchmark.find((item) => item.preset === session.output.softwarePreset);
1031
+ const height = Number(session.output.encodeHeight) || 0;
1032
+ const width = Number(session.output.encodeWidth) || 0;
1033
+ const fps = Number(session.output.outputFps) || TRANSCODE_FPS;
1034
+ if (!entry || height <= 0 || width <= 0) {
1035
+ return;
1036
+ }
1037
+ // The preset's throughput AT THIS SIZE, as the startup calibration read it.
1038
+ const pixelsPerSec = throughputAt(entry, { width, height });
1039
+ if (!(pixelsPerSec > 0)) {
1040
+ return;
1041
+ }
1042
+ const encodeCostSec = (width * height * fps) / pixelsPerSec;
1043
+ const decodeCostSec = 1 / speed - encodeCostSec;
1044
+ if (!(decodeCostSec > 0)) {
1045
+ // The encode half already accounts for everything measured. Nothing is
1046
+ // left to attribute to decoding, and a zero or negative cost would say
1047
+ // decoding is free, which is a claim this reading cannot support.
1048
+ return;
1049
+ }
1050
+ const key = session.file.key;
1051
+ const known = this.#decodeCost.get(key);
1052
+ const readings = [...(known?.readings ?? []), decodeCostSec].slice(-READINGS_KEPT);
1053
+ const costSec = medianOf(readings);
1054
+ if (!movedBeyondScatter(known?.costSec ?? null, costSec, readings)) {
1055
+ // The same answer as before, by the readings' own scatter. Storing it
1056
+ // would bump the version and make every session recompute its offer,
1057
+ // which is asked for on the path that serves every playlist, init and
1058
+ // segment.
1059
+ this.#decodeCost.set(key, { ...known, readings });
1060
+ return;
1061
+ }
1062
+ this.#decodeCost.set(key, { costSec, readings, version: (known?.version ?? 0) + 1 });
1063
+ logger.info(
1064
+ `transcode: ${session.file.name} decodes at ${(1 / costSec).toFixed(2)}x on this host ` +
1065
+ `(median of ${readings.length}, latest ${(1 / decodeCostSec).toFixed(2)}x from ${height}p ` +
1066
+ `at ${speed.toFixed(2)}x, preset ${session.output.softwarePreset})`
1067
+ );
1068
+ }
1069
+
1070
+ /**
1071
+ * The speed this run is making RIGHT NOW, or null when nothing recent enough
1072
+ * says.
1073
+ *
1074
+ * Read as the slope between two progress reports, never as ffmpeg's own
1075
+ * `speed=`. That figure is cumulative — output time over wall time since the
1076
+ * run began — so a run starved of torrent data early carries the average of
1077
+ * that starvation for the rest of its life. Measured 2026-08-21: a run whose
1078
+ * progress lines showed 1.30x at that moment (13 s of video in 10.02 s of
1079
+ * clock) still reported a cumulative 0.39x from four minutes on a ~100 KB/s
1080
+ * swarm, and the budget stepped the picture down on it. The same mistake was
1081
+ * found and solved once already — the startup decode benchmark reads the
1082
+ * slope between two progress reports for exactly this reason.
1083
+ *
1084
+ * @param {HlsSession} session
1085
+ * @param {number} now
1086
+ * @returns {number | null}
1087
+ */
1088
+ recentSpeedOf(session, now, withinMs) {
1089
+ return this.recentSpeedReadingOf(session, now, withinMs)?.speed ?? null;
1090
+ }
1091
+
1092
+ /**
1093
+ * The newest measured speed from the output's current or most recent run.
1094
+ * Its measurement time lets the playback model project the observed trend
1095
+ * without imposing a separate freshness window.
1096
+ *
1097
+ * @param {HlsSession} session
1098
+ * @returns {{ speed: number, at: number } | null}
1099
+ */
1100
+ latestSpeedReadingOf(session) {
1101
+ const reading = qualityStateOf(session).recentSpeed;
1102
+ if (!reading || !this.#runsFor(session).includes(reading.run)) {
1103
+ return null;
1104
+ }
1105
+ return { speed: reading.speed, at: reading.at };
1106
+ }
1107
+
1108
+ /**
1109
+ * The current output's measured production speed, with the time it was
1110
+ * measured. The caller can use the same observation in a trend without
1111
+ * treating repeated progress polls as new samples.
1112
+ *
1113
+ * @param {HlsSession} session
1114
+ * @param {number} now
1115
+ * @param {number} withinMs
1116
+ * @returns {{ speed: number, at: number } | null}
1117
+ */
1118
+ recentSpeedReadingOf(session, now, withinMs) {
1119
+ const reading = this.latestSpeedReadingOf(session);
1120
+ // Stale by whatever the asker calls stale — two of its own ticks, for the
1121
+ // budget loop, which takes a fresh reading every pass anyway. A reading
1122
+ // older than that is not about the machine as it stands.
1123
+ if (!reading || now - reading.at > withinMs) {
1124
+ return null;
1125
+ }
1126
+ return reading;
1127
+ }
1128
+
1129
+ /**
1130
+ * Realtime budget (software encoder only): choose the output resolution AND
1131
+ * libx264 preset this host can encode faster than realtime, from the startup
1132
+ * benchmark. The ceiling is the client-requested box capped to the source
1133
+ * (never upscaled); the budget picks the highest resolution rung at or below
1134
+ * that ceiling that clears realtime × margin, then the best preset at that
1135
+ * resolution. On a weak host this downscales below the client target instead
1136
+ * of dropping into sub-realtime playback. Returns null when not applicable
1137
+ * (no video transcode, hardware encoder, or missing benchmark/source size) —
1138
+ * the encode then keeps the ceiling resolution and the default preset.
1139
+ *
1140
+ * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1141
+ * @returns {{ width: number, height: number, preset: string } | null}
1142
+ */
1143
+ chooseEncodeBudget({
1144
+ transcodeVideo,
1145
+ targetWidth,
1146
+ targetHeight,
1147
+ sourceWidth,
1148
+ sourceHeight,
1149
+ outputFps,
1150
+ source = null,
1151
+ requiredSpeed = null
1152
+ }) {
1153
+ if (!transcodeVideo || this.#host().encoderKind !== "software" || !this.#host().benchmark) {
1154
+ return null;
1155
+ }
1156
+ const ceiling = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
1157
+ if (!ceiling) {
1158
+ return null;
1159
+ }
1160
+ return chooseSoftwareEncodeSettings(
1161
+ this.#host().benchmark,
1162
+ { width: ceiling.w, height: ceiling.h },
1163
+ outputFps,
1164
+ { decodeModel: this.#host().decodeModel, source, requiredSpeed }
1165
+ );
1166
+ }
1167
+ }