@torrent-tv/proxy 2.87.10 → 2.87.12

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