@torrent-tv/proxy 2.89.5 → 2.89.7

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