@torrent-tv/proxy 2.88.4 → 2.88.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (489) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2266 -2259
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -0
  19. package/assets/calibration/audio/aac.m4a +0 -0
  20. package/assets/calibration/audio/ac3.ac3 +0 -0
  21. package/assets/calibration/audio/alac.m4a +0 -0
  22. package/assets/calibration/audio/dts.dts +0 -0
  23. package/assets/calibration/audio/eac3.eac3 +0 -0
  24. package/assets/calibration/audio/flac.flac +0 -0
  25. package/assets/calibration/audio/manifest.json +98 -0
  26. package/assets/calibration/audio/mp2.mp2 +0 -0
  27. package/assets/calibration/audio/mp3.mp3 +0 -0
  28. package/assets/calibration/audio/opus.opus +0 -0
  29. package/assets/calibration/audio/pcm.wav +0 -0
  30. package/assets/calibration/audio/truehd.thd +0 -0
  31. package/assets/calibration/audio/vorbis.ogg +0 -0
  32. package/assets/diagnostics/sctpstate.gdb +31 -31
  33. package/bin/cli.js +684 -684
  34. package/bin/thread-pool.js +30 -30
  35. package/biome.json +412 -411
  36. package/docs/browser-proxy-contract.md +155 -152
  37. package/docs/container-architecture.md +218 -218
  38. package/docs/download-architecture.md +261 -261
  39. package/docs/encode-architecture.md +729 -729
  40. package/docs/encode-run-state.md +96 -96
  41. package/docs/logs.md +97 -97
  42. package/docs/playback-start-forecast.md +56 -0
  43. package/docs/socks5-egress-research.md +114 -114
  44. package/docs/storage-architecture.md +267 -267
  45. package/knip.json +14 -14
  46. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  47. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  48. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  49. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  50. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  51. package/openspec/changes/binary-distribution/proposal.md +53 -53
  52. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  53. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  54. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  55. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  56. package/openspec/changes/cold-start/design.md +98 -98
  57. package/openspec/changes/cold-start/proposal.md +62 -62
  58. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  59. package/openspec/changes/cold-start/tasks.md +48 -48
  60. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  61. package/openspec/changes/disk-cap/proposal.md +37 -37
  62. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  63. package/openspec/changes/disk-cap/tasks.md +19 -19
  64. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  65. package/openspec/changes/proxy-observability/design.md +38 -38
  66. package/openspec/changes/proxy-observability/proposal.md +49 -49
  67. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  68. package/openspec/changes/proxy-observability/tasks.md +21 -21
  69. package/openspec/changes/seek-debounce/design.md +118 -118
  70. package/openspec/changes/seek-debounce/proposal.md +59 -59
  71. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  72. package/openspec/changes/seek-debounce/tasks.md +33 -33
  73. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  74. package/openspec/changes/subtitle-language/proposal.md +50 -50
  75. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  76. package/openspec/changes/subtitle-language/tasks.md +26 -26
  77. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  78. package/openspec/changes/track-selection/design.md +47 -47
  79. package/openspec/changes/track-selection/proposal.md +45 -45
  80. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  81. package/openspec/changes/track-selection/tasks.md +20 -20
  82. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  83. package/openspec/changes/transcode-quality/proposal.md +51 -51
  84. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  85. package/openspec/changes/transcode-quality/tasks.md +63 -63
  86. package/openspec/config.yaml +38 -38
  87. package/package.json +1 -1
  88. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  89. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  90. package/research/double-spawn-2026-09-10.md +171 -171
  91. package/research/handover-2026-09-13.md +209 -209
  92. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  93. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  94. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  95. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  96. package/routes/api/client-logs/post.js +86 -86
  97. package/routes/api/delivery-sink/get.js +72 -72
  98. package/routes/api/link-probe/get.js +51 -51
  99. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  100. package/routes/api/playback-plan/post.js +53 -53
  101. package/routes/api/sources/post.js +36 -36
  102. package/routes/api/sources/stats/get.js +132 -132
  103. package/routes/api/sources/warm/post.js +217 -217
  104. package/routes/api/subtitles/get.js +230 -230
  105. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  106. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  107. package/routes/api/transcode-sessions/post.js +178 -178
  108. package/routes/api/transcode-sessions/progress/get.js +27 -27
  109. package/routes/api/transcode-sessions/release/post.js +41 -41
  110. package/routes/api/transcode-sessions/seek/post.js +63 -63
  111. package/routes/health/get.js +13 -13
  112. package/routes/healthz/get.js +13 -13
  113. package/routes/stream/get.js +380 -380
  114. package/routes/transcode/audio-file/get.js +64 -64
  115. package/routes/transcode/audio-warm/get.js +95 -95
  116. package/routes/transcode/session-file/get.js +52 -52
  117. package/routes/transcode/variant-file/get.js +95 -95
  118. package/routes/transcode/variant-warm/get.js +84 -84
  119. package/scripts/build-audio-samples.mjs +31 -0
  120. package/scripts/render-run-graph.js +173 -173
  121. package/server.js +730 -725
  122. package/services/encode/CoverageMap.js +428 -428
  123. package/services/encode/CushionReport.js +345 -345
  124. package/services/encode/EncodeAdmission.js +376 -376
  125. package/services/encode/EncodeOrchestrator.js +1217 -1217
  126. package/services/encode/EncodePlan.js +1366 -1366
  127. package/services/encode/EncodeRun.js +813 -813
  128. package/services/encode/EncodeRuns.js +1237 -1235
  129. package/services/encode/Encoder.js +142 -142
  130. package/services/encode/EncoderSelection.js +38 -38
  131. package/services/encode/LocalObservations.js +359 -359
  132. package/services/encode/NvencEncoder.js +50 -50
  133. package/services/encode/OutputOpening.js +783 -783
  134. package/services/encode/OutputTimes.js +579 -579
  135. package/services/encode/QsvEncoder.js +58 -58
  136. package/services/encode/Renditions.js +2282 -2282
  137. package/services/encode/RunProgress.js +84 -84
  138. package/services/encode/SegmentDemand.js +95 -95
  139. package/services/encode/SoftwareEncoder.js +121 -121
  140. package/services/encode/V4l2m2mEncoder.js +53 -53
  141. package/services/encode/VaapiEncoder.js +66 -66
  142. package/services/encode/args.js +418 -418
  143. package/services/encode/audio-calibration.js +67 -0
  144. package/services/encode/audio-work-rate.js +15 -0
  145. package/services/encode/available-share.js +89 -89
  146. package/services/encode/calibration.js +361 -361
  147. package/services/encode/contention.js +128 -128
  148. package/services/encode/decode-cost-fit.js +363 -363
  149. package/services/encode/encode-exit.js +114 -114
  150. package/services/encode/encode-run-state.js +551 -551
  151. package/services/encode/encoder-readings.js +45 -45
  152. package/services/encode/fingerprint.js +322 -322
  153. package/services/encode/host-load.js +174 -174
  154. package/services/encode/hwaccel.js +1894 -1894
  155. package/services/encode/index.js +9 -9
  156. package/services/encode/learned-median.js +90 -90
  157. package/services/encode/output/EncodedOutput.js +57 -57
  158. package/services/encode/output/Output.js +131 -131
  159. package/services/encode/output/OutputCatalog.js +414 -414
  160. package/services/encode/output/OutputSpec.js +390 -390
  161. package/services/encode/output/Timeline.js +308 -308
  162. package/services/encode/output/cut-grid.js +107 -107
  163. package/services/encode/output/index.js +20 -20
  164. package/services/encode/output/ladder.js +32 -32
  165. package/services/encode/output/playlists.js +284 -284
  166. package/services/encode/output/rates.js +125 -125
  167. package/services/encode/output-key-format.js +21 -21
  168. package/services/encode/quality/EncodeCost.js +1167 -1150
  169. package/services/encode/quality/HostLoad.js +525 -525
  170. package/services/encode/quality/HostTimings.js +249 -249
  171. package/services/encode/quality/OutputQualityState.js +28 -28
  172. package/services/encode/quality/QualityController.js +935 -935
  173. package/services/encode/quality/QualityOffer.js +416 -416
  174. package/services/encode/quality/drain-threat.js +47 -47
  175. package/services/encode/quality/link-budget.js +349 -349
  176. package/services/encode/quality/output-format.js +319 -319
  177. package/services/encode/quality/serving-output.js +216 -216
  178. package/services/encode/quality/visible-rung.js +57 -57
  179. package/services/encode/run-budget.js +97 -97
  180. package/services/encode/run-command.js +754 -754
  181. package/services/encode/run-costs.js +155 -155
  182. package/services/encode/run-inputs.js +42 -42
  183. package/services/encode/segment-formats/fmp4.js +458 -456
  184. package/services/encode/segment-formats/index.js +85 -85
  185. package/services/encode/segment-formats/init-compat.js +324 -324
  186. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  187. package/services/encode/segment-formats/mpegts.js +134 -134
  188. package/services/encode/start-stop-cost.js +178 -178
  189. package/services/encode/throughput.js +97 -97
  190. package/services/encode/torrent-cost.js +88 -88
  191. package/services/identity/installation-id.js +38 -38
  192. package/services/media/ContainerOrchestrator.js +149 -149
  193. package/services/media/KeyframeTables.js +316 -316
  194. package/services/media/SourceFile.js +298 -298
  195. package/services/media/SubtitleCues.js +520 -520
  196. package/services/media/SubtitleOrchestrator.js +167 -167
  197. package/services/media/audio-inventory.js +299 -298
  198. package/services/media/container/AviContainer.js +266 -266
  199. package/services/media/container/Container.js +609 -609
  200. package/services/media/container/ContainerFactory.js +56 -56
  201. package/services/media/container/KeyframeTable.js +272 -272
  202. package/services/media/container/MatroskaContainer.js +1494 -1494
  203. package/services/media/container/Mp4Container.js +1344 -1344
  204. package/services/media/container/ebml-reader.js +159 -159
  205. package/services/media/ffmpeg-banner.js +260 -260
  206. package/services/media/keyframe-probe.js +157 -157
  207. package/services/media/media-info-probe.js +93 -93
  208. package/services/media/playback-planner.js +931 -931
  209. package/services/media/tracks/AudioTrack.js +143 -143
  210. package/services/media/tracks/ContainerTrack.js +75 -75
  211. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  212. package/services/media/tracks/SubtitleTrack.js +38 -38
  213. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  214. package/services/media/tracks/VideoTrack.js +33 -33
  215. package/services/media/tracks/audio-bound.js +233 -233
  216. package/services/media/tracks/index.js +15 -15
  217. package/services/media/tracks/language-detect.js +174 -174
  218. package/services/server/OutputLifecycle.js +418 -418
  219. package/services/server/SegmentServing.js +1150 -1150
  220. package/services/server/ViewerRequests.js +785 -760
  221. package/services/server/WaitLedger.js +142 -142
  222. package/services/server/controllers/PlaybackController.js +40 -40
  223. package/services/server/controllers/SubtitleController.js +109 -109
  224. package/services/server/http-responses.js +60 -60
  225. package/services/server/playback-readiness.js +516 -708
  226. package/services/server/transcode-session-files.js +325 -325
  227. package/services/server/wire-outputs.js +1020 -995
  228. package/services/storage/Diagnostics.js +202 -202
  229. package/services/storage/MachineBudget.js +235 -235
  230. package/services/storage/allowance.js +107 -107
  231. package/services/storage/core-dumps.js +106 -106
  232. package/services/storage/files/CompletedFiles.js +391 -391
  233. package/services/storage/files/piece-from-whole-file.js +118 -118
  234. package/services/storage/free.js +71 -71
  235. package/services/storage/keep.js +48 -48
  236. package/services/storage/machine-memory.js +56 -56
  237. package/services/storage/memory-report.js +575 -575
  238. package/services/storage/piece-store/piece-disk-store.js +539 -539
  239. package/services/storage/piece-store/piece-lru.js +495 -495
  240. package/services/storage/piece-store/shared-piece-store.js +1937 -1931
  241. package/services/storage/returns.js +103 -103
  242. package/services/storage/segment-store/SegmentStore.js +1255 -1240
  243. package/services/storage/wire.js +175 -175
  244. package/services/torrent/demand/DemandRegister.js +202 -202
  245. package/services/torrent/demand/Urgency.js +137 -137
  246. package/services/torrent/demand/Window.js +118 -118
  247. package/services/torrent/demand/index.js +8 -8
  248. package/services/torrent/demand/pieces.js +140 -140
  249. package/services/torrent/download/SwarmSelection.js +386 -386
  250. package/services/torrent/download/registry.js +102 -102
  251. package/services/torrent/download/withdraw-claim.js +80 -80
  252. package/services/torrent/files.js +455 -455
  253. package/services/torrent/naming.js +619 -619
  254. package/services/torrent/piece-store-of.js +29 -29
  255. package/services/torrent/supply-margin.js +262 -262
  256. package/services/torrent/torrent-pool.js +2688 -2675
  257. package/services/torrent/worker/background-fill.js +213 -213
  258. package/services/torrent/worker/channel.js +271 -271
  259. package/services/torrent/worker/client.js +765 -765
  260. package/services/torrent/worker/fastest-wires.js +342 -342
  261. package/services/torrent/worker/handle-state.js +23 -23
  262. package/services/torrent/worker/held-bytes.js +155 -155
  263. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  264. package/services/torrent/worker/piece-reader.js +1078 -1078
  265. package/services/torrent/worker/pool-adapter.js +371 -371
  266. package/services/torrent/worker/protocol.js +184 -184
  267. package/services/torrent/worker/resume-warm.js +107 -107
  268. package/services/torrent/worker/webrtc-shim.js +219 -219
  269. package/services/torrent/worker/worker.js +1129 -1129
  270. package/services/transport/data-channel-handler.js +1369 -1369
  271. package/services/transport/delivery-probe.js +842 -842
  272. package/services/transport/health-collector.js +53 -53
  273. package/services/transport/nat-classifier.js +231 -231
  274. package/services/transport/packet-witness.js +808 -808
  275. package/services/transport/port-mapper.js +235 -235
  276. package/services/transport/registry-api.js +81 -81
  277. package/services/transport/tunnel-client.js +447 -447
  278. package/services/transport/usrsctp-state.js +175 -175
  279. package/services/transport/webrtc-manager.js +484 -484
  280. package/services/viewer/Assignments.js +340 -340
  281. package/services/viewer/PriorityMap.js +390 -390
  282. package/services/viewer/PriorityOrchestrator.js +325 -325
  283. package/services/viewer/Viewer.js +691 -691
  284. package/services/viewer/Viewers.js +368 -368
  285. package/services/viewer/active-output.js +37 -37
  286. package/services/viewer/audio-start.js +46 -46
  287. package/services/viewer/buffer-trend.js +81 -81
  288. package/services/viewer/choices.js +647 -647
  289. package/services/viewer/positions.js +126 -126
  290. package/services/viewer/report-intake.js +46 -46
  291. package/store/source-registry.js +71 -71
  292. package/test/asset-paths.test.js +28 -28
  293. package/test/audio-calibration.test.js +23 -0
  294. package/test/audio-inventory.test.js +212 -212
  295. package/test/audio-track-bitrate.test.js +40 -40
  296. package/test/auto-quality-step.test.js +707 -707
  297. package/test/available-share.test.js +76 -76
  298. package/test/background-fill.test.js +190 -190
  299. package/test/client-log-file.test.js +110 -110
  300. package/test/completed-files.test.js +115 -115
  301. package/test/concurrent-cost.test.js +148 -148
  302. package/test/container-media-info.test.js +228 -228
  303. package/test/contention.test.js +76 -76
  304. package/test/core-dumps.test.js +57 -57
  305. package/test/coverage-follows-the-disk.test.js +228 -228
  306. package/test/coverage-map.test.js +195 -195
  307. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  308. package/test/cut-times-timeline.test.js +65 -65
  309. package/test/cuts-follow-published-grid.test.js +109 -109
  310. package/test/data-channel-frame.test.js +63 -63
  311. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  312. package/test/dead-torrent-handle.test.js +52 -52
  313. package/test/declared-rates.test.js +116 -116
  314. package/test/declared-tracks.test.js +43 -43
  315. package/test/decode-cost-fit.test.js +202 -202
  316. package/test/decode-cost.test.js +462 -462
  317. package/test/delivery-probe.test.js +393 -393
  318. package/test/demand-register.test.js +206 -206
  319. package/test/dht-bootstrap.test.js +72 -72
  320. package/test/drain-threat.test.js +65 -65
  321. package/test/duplicate-add.test.js +48 -48
  322. package/test/encode-admission.test.js +146 -146
  323. package/test/encode-cost.test.js +283 -283
  324. package/test/encode-exit.test.js +111 -111
  325. package/test/encode-orchestrator.test.js +419 -419
  326. package/test/encode-plan-viewers.test.js +718 -718
  327. package/test/encode-plan.test.js +545 -545
  328. package/test/encode-run-state.test.js +314 -314
  329. package/test/encode-run.test.js +162 -162
  330. package/test/encode-slope.test.js +46 -46
  331. package/test/encoded-output.test.js +39 -39
  332. package/test/encoder-kinds.test.js +122 -122
  333. package/test/encoder-readings.test.js +46 -46
  334. package/test/every-claimant-is-told-its-share.test.js +152 -152
  335. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  336. package/test/failed-start-breaker.test.js +202 -202
  337. package/test/fastest-wires.test.js +143 -143
  338. package/test/ffmpeg-stream-counts.test.js +78 -78
  339. package/test/file-edges.test.js +191 -191
  340. package/test/fmp4-presentation-clock.test.js +162 -147
  341. package/test/fmp4-self-contained.test.js +80 -80
  342. package/test/fmp4-timestamps.test.js +140 -140
  343. package/test/fmp4-track-completeness.test.js +41 -41
  344. package/test/format-keyed-outputs.test.js +78 -78
  345. package/test/health-metrics.test.js +38 -38
  346. package/test/held-request-width.test.js +157 -157
  347. package/test/helpers/encode-run.js +136 -136
  348. package/test/helpers/held-file.js +29 -29
  349. package/test/helpers/manager.js +57 -57
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +102 -102
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +112 -112
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +292 -292
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/memory-budget.test.js +390 -390
  372. package/test/move-cost.test.js +216 -216
  373. package/test/mp4-subtitles.test.js +173 -173
  374. package/test/one-authority.test.js +285 -285
  375. package/test/one-budget.test.js +149 -149
  376. package/test/one-output-one-session.test.js +273 -270
  377. package/test/one-resource-divided.test.js +185 -185
  378. package/test/one-serving-output-per-height.test.js +458 -458
  379. package/test/orchestrator-wired.test.js +206 -206
  380. package/test/output-catalog.test.js +39 -39
  381. package/test/output-shape.test.js +21 -21
  382. package/test/output-spec.test.js +251 -251
  383. package/test/output-speed.test.js +88 -88
  384. package/test/outputs-being-prepared.test.js +40 -40
  385. package/test/packet-witness-ring.test.js +236 -236
  386. package/test/packet-witness.test.js +148 -148
  387. package/test/piece-disk-store.test.js +401 -401
  388. package/test/piece-from-whole-file.test.js +129 -129
  389. package/test/piece-lru.test.js +319 -319
  390. package/test/piece-reader.test.js +162 -162
  391. package/test/piece-selection-offset.test.js +85 -85
  392. package/test/piece-store-eviction.test.js +595 -595
  393. package/test/piece-store-never-refuses.test.js +153 -153
  394. package/test/piece-store-reservations.test.js +403 -403
  395. package/test/piece-store-slow-disk.test.js +159 -159
  396. package/test/piece-tail.test.js +97 -97
  397. package/test/plan-host-timings.test.js +68 -68
  398. package/test/plan-is-steady.test.js +225 -225
  399. package/test/playback-readiness.test.js +483 -453
  400. package/test/playback-start-regressions.test.js +99 -99
  401. package/test/priority-map-download.test.js +174 -174
  402. package/test/priority-map-emptied.test.js +204 -204
  403. package/test/priority-map-per-output.test.js +215 -215
  404. package/test/priority-map.test.js +163 -163
  405. package/test/probe-wedge-certainty.test.js +60 -60
  406. package/test/produced-copy-choice.test.js +213 -213
  407. package/test/published-timeline.test.js +83 -83
  408. package/test/quality-offer.test.js +162 -162
  409. package/test/quality-variants.test.js +1257 -1257
  410. package/test/rate-control-in-the-output.test.js +180 -180
  411. package/test/read-survives-withdrawal.test.js +164 -164
  412. package/test/read-window.test.js +365 -365
  413. package/test/resume-warm.test.js +39 -39
  414. package/test/run-graph-drift.test.js +37 -37
  415. package/test/run-inputs.test.js +80 -80
  416. package/test/run-intervals.test.js +100 -100
  417. package/test/run-position-follows-published-grid.test.js +89 -89
  418. package/test/run-progress.test.js +65 -65
  419. package/test/same-height-switch.test.js +664 -664
  420. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  421. package/test/seek-landing.test.js +109 -109
  422. package/test/seek-target-not-superseded.test.js +195 -195
  423. package/test/segment-closed-on-both-branches.test.js +96 -96
  424. package/test/segment-cut-times.test.js +63 -63
  425. package/test/segment-demand.test.js +80 -80
  426. package/test/segment-hold-supersede.test.js +91 -91
  427. package/test/segment-judged-by-its-own-header.test.js +221 -221
  428. package/test/segment-serve-wiring.test.js +475 -475
  429. package/test/segment-store-eviction.test.js +247 -247
  430. package/test/segment-store.test.js +338 -323
  431. package/test/segment-true-start.test.js +86 -86
  432. package/test/segments-are-shared.test.js +176 -176
  433. package/test/send-chunk-bytes.test.js +86 -86
  434. package/test/serving-existing-output.test.js +105 -105
  435. package/test/serving-output.test.js +102 -102
  436. package/test/shared-piece-store.test.js +310 -310
  437. package/test/sidecar-naming.test.js +142 -142
  438. package/test/soundtrack-rate-bound.test.js +289 -289
  439. package/test/source-file.test.js +133 -133
  440. package/test/source-registry.test.js +18 -18
  441. package/test/split-a-stretch.test.js +139 -139
  442. package/test/stale-request-after-seek.test.js +158 -158
  443. package/test/startup-readings.test.js +124 -124
  444. package/test/stream-route.test.js +209 -209
  445. package/test/subtitle-cue-framing.test.js +200 -200
  446. package/test/subtitle-cue-source.test.js +102 -102
  447. package/test/subtitle-cue-walk.test.js +370 -370
  448. package/test/subtitle-cursor.test.js +63 -63
  449. package/test/subtitle-defaults.test.js +118 -118
  450. package/test/subtitle-language.test.js +252 -252
  451. package/test/subtitle-track-numbering.test.js +386 -386
  452. package/test/supply-margin.test.js +177 -177
  453. package/test/swarm-follows-readers.test.js +174 -174
  454. package/test/swarm-reach.test.js +130 -130
  455. package/test/swarm-selection.test.js +220 -220
  456. package/test/tail-duplication.test.js +167 -167
  457. package/test/the-channel-has-both-ends.test.js +91 -91
  458. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  459. package/test/thread-pool.test.js +53 -53
  460. package/test/timeline.test.js +84 -84
  461. package/test/torrent-contents.test.js +235 -235
  462. package/test/torrent-cost.test.js +71 -71
  463. package/test/torrent-files.test.js +221 -221
  464. package/test/torrent-naming.test.js +255 -255
  465. package/test/tracks-begin-together.test.js +209 -209
  466. package/test/tunnel-renewal.test.js +167 -167
  467. package/test/two-viewers-one-picture.test.js +504 -504
  468. package/test/upload-hurry.test.js +186 -186
  469. package/test/usrsctp-state.test.js +131 -131
  470. package/test/video-facts.test.js +102 -102
  471. package/test/viewer-assignments.test.js +143 -143
  472. package/test/viewer-outputs.test.js +285 -285
  473. package/test/viewer-presence.test.js +116 -116
  474. package/test/viewer-requests-admission.test.js +89 -89
  475. package/test/viewer-states.test.js +189 -189
  476. package/test/viewer-subtitle-subscription.test.js +96 -96
  477. package/test/viewer.test.js +139 -139
  478. package/test/viewing-generations.test.js +390 -390
  479. package/test/visible-rung.test.js +37 -37
  480. package/test/wait-ledger.test.js +100 -100
  481. package/test/webrtc-shim.test.js +149 -149
  482. package/test/wedge-certainty.test.js +131 -131
  483. package/test/withdraw-piece-claim.test.js +212 -212
  484. package/test/worker-channel.test.js +158 -158
  485. package/utils/client-log-file.js +151 -151
  486. package/utils/logger.js +312 -312
  487. package/utils/parse-range.js +25 -25
  488. package/utils/perf.js +59 -59
  489. package/utils/torrent-source-key.js +38 -38
@@ -1,935 +1,935 @@
1
- /**
2
- * @file The quality budget of the outputs that exist: when a picture is asked to
3
- * move to another height, when its bitrate is capped by a viewer's measured link,
4
- * and what the player is told to follow.
5
- *
6
- * Moved out of the session manager whole (plan step 3). What it needs of the
7
- * rest of the proxy is the host object it is built with, and nothing else.
8
- */
9
-
10
- import { stat } from "node:fs/promises";
11
- import { logger } from "../../../utils/logger.js";
12
- import { ENCODE_RUN_STATE } from "../encode-run-state.js";
13
- import { LINK_SAFETY, linkCouldCarry, loadOf } from "./link-budget.js";
14
- import { qualityStateOf } from "./OutputQualityState.js";
15
- import { drainThreat } from "./drain-threat.js";
16
- import { visibleHeightCap } from "./visible-rung.js";
17
-
18
- // Realtime budget — runtime downswitch (software encoder only). Periodically
19
- // check each active software-transcode session's ffmpeg `speed`; when it stays
20
- // below realtime for a sustained window AND the input is not download-starved
21
- // (so the limit is the encoder, not the torrent), step down one resolution rung
22
- // and restart at the current segment. Conservative so it never thrashes: a long
23
- // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
24
- export const BUDGET_CHECK_INTERVAL_MS = 5_000;
25
- // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
26
- // realtime resets the slow window (hysteresis).
27
- const BUDGET_SPEED_SLOW = 0.95;
28
- const BUDGET_SPEED_OK = 1.0;
29
- // Slow must persist this long before a downshift (absorbs warm-up + brief
30
- // complex scenes; the cumulative average won't dip this long unless the host
31
- // genuinely can't keep up).
32
- const BUDGET_SUSTAINED_MS = 15_000;
33
- // The step BACK UP has to be slower to fire than the step down, or the two
34
- // take turns: a rung that has just been left is by definition one the arithmetic
35
- // still thinks this machine can hold, so it would be asked for again as soon as
36
- // the cooldown expired. Four times the down window is a statement about how long
37
- // a machine has to look able before it is believed, not a measured quantity, and
38
- // it is written here rather than dressed up as one.
39
- const BUDGET_UP_SUSTAINED_MS = 60_000;
40
- // How long a request to the player to change variant stands before it is
41
- // treated as unanswered. A progress report is polled about every 1.5 s and the
42
- // switch itself needs the rung warmed, which is the cold start this host
43
- // measures; this is long enough for both and short enough that a browser which
44
- // cannot honour the request (no master playlist, a viewer on a manual pick) is
45
- // not chased for the rest of the film.
46
- const QUALITY_ASK_TTL_MS = 45_000;
47
- // The input counts as "keeping up" when the torrent downloads at least this
48
- // multiple of the source's average byte rate. Below it (and not yet fully
49
- // downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
50
- const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
51
- // Viewer-link adaptation. Judged on each report a viewer makes, for that
52
- // viewer alone (roadmap item 98): when their link does not carry the stream
53
- // they are given AND their buffer, on its present trend, would run dry before
54
- // another output could have the piece they need (`drain-threat.js`), a smaller
55
- // output is prepared for them. A chosen window of slowness and a chosen buffer
56
- // threshold stood here, and a chosen wait after every action; all three are
57
- // gone. A manual viewer is never sent an automatic quality request.
58
- // Observed produced bitrate: average over this many recently completed
59
- // segments (the newest file on disk may still be written and is excluded).
60
- const LINK_OBSERVED_SEGMENTS = 5;
61
-
62
- /** @typedef {{ pathOf: (address: string, index: number) => string | null }} SegmentPathLookup */
63
-
64
- export class QualityController {
65
- /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
66
- #host;
67
-
68
- /** A budget pass is under way; a timer tick that lands meanwhile is skipped. */
69
- #tickRunning = false;
70
-
71
- /** Viewers whose report is being judged now (`noteViewerReported`). @type {Set<string>} */
72
- #judging = new Set();
73
-
74
- /**
75
- * @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `getSourceStats`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `expectedFirstSegmentMs`
76
- * @param {SegmentPathLookup} host.segmentPaths - Lookup for completed
77
- * segment paths needed by quality decisions.
78
- */
79
- constructor(host) {
80
- this.#host = host;
81
- }
82
-
83
- /**
84
- * One pass of the quality budget: learn what this host is doing with each
85
- * running encode, and act on it.
86
- *
87
- * Public because it is an operation with a name, not an implementation
88
- * detail of a timer — and because a loop that decides what the viewer sees
89
- * and can only be reached through `setInterval` is a loop nothing can check.
90
- * The timer calls exactly this.
91
- *
92
- * @returns {Promise<void>}
93
- */
94
- async runQualityBudgetOnce() {
95
- void this.#host.reportHostLoad();
96
- // One tick at a time. Both halves await torrent statistics per source, so a
97
- // slow or stuck answer would otherwise let the next tick in behind it — two
98
- // passes over the same sessions, taking the same reading twice and acting
99
- // on the same speed twice, and an earlier tick's rates landing on top of a
100
- // later tick's.
101
- if (this.#tickRunning === true) {
102
- return;
103
- }
104
- this.#tickRunning = true;
105
- try {
106
- // Taken whatever the encoder is: the torrent's price is charged against
107
- // this rate on every host, not only on the ones that re-encode.
108
- await this.#host.sampleDownloadRates();
109
- if (this.#host.videoEncoder?.kind !== "software") {
110
- return;
111
- }
112
- await this.#realtimeBudgetPass();
113
- } finally {
114
- this.#tickRunning = false;
115
- }
116
- }
117
-
118
- async #realtimeBudgetPass() {
119
- const now = Date.now();
120
- for (const session of this.#host.outputs.values()) {
121
- // What this file costs to decode is learned from EVERY encoding session,
122
- // before any of the budget's own conditions are consulted. Those exist to
123
- // decide whether to step the quality, and they exclude most of what is
124
- // worth measuring: a rung already at the foot of its ladder has nowhere
125
- // to step, and a 240p variant IS its whole ladder — which is exactly the
126
- // rung the field measured at 0.95x on 2026-08-15, learning nothing from
127
- // three minutes of it because the loop had already skipped the session as
128
- // un-actionable.
129
- await this.#host.encodeCost.learnFrom(session);
130
- if (
131
- !session ||
132
- !this.#host.isLive(session) ||
133
- this.#host.runStateOf(session) === ENCODE_RUN_STATE.ENDED_FAILED ||
134
- // Nothing is encoding, so there is no speed to judge. A variant the
135
- // viewer has switched away from is left in exactly this state, and its
136
- // last recorded speed would otherwise buy it a step — which restarts
137
- // the encoder it was just stopped for.
138
- this.#host.liveRunsOf(session).length === 0 ||
139
- // A soundtrack published on its own carries no picture, so no quality
140
- // step is its to make; its price is learned above and that is all.
141
- session.spec.carries === "audio-only"
142
- ) {
143
- continue;
144
- }
145
- // A viewer's link is judged on each of their reports
146
- // (`noteViewerReported`), not here: nothing about it changes between two
147
- // reports, so a timer would only read the same statement again.
148
- if (await this.#checkEncoderBudget(session, now)) {
149
- continue;
150
- }
151
- await this.#checkStepUp(session, now);
152
- }
153
- }
154
-
155
- /**
156
- * The encoder-speed check for one session: sustained sub-realtime, and the
157
- * encoder — not a download-starved input — is the limit.
158
- *
159
- * @param {HlsSession} session
160
- * @param {number} now
161
- * @returns {Promise<boolean>} True when a step was asked for this tick.
162
- */
163
- async #checkEncoderBudget(session, now) {
164
- if (!session.spec.transcodesVideo) {
165
- // A copy has no encoder to make cheaper. Whatever the machine is short
166
- // of, moving this viewer to a RE-ENCODED rung costs it more, not less —
167
- // so the copy path's only lever is the viewer's link, above.
168
- return false;
169
- }
170
- const speed = this.#host.encodeCost.recentSpeedOf(session, now, BUDGET_CHECK_INTERVAL_MS * 2);
171
- if (speed === null) {
172
- return false; // no measurement yet
173
- }
174
- if (speed >= BUDGET_SPEED_OK) {
175
- qualityStateOf(session).budgetSlowSince = 0; // recovered — reset the slow window
176
- return false;
177
- }
178
- if (speed >= BUDGET_SPEED_SLOW) {
179
- return false; // in the hysteresis band; neither slow nor ok
180
- }
181
- if (qualityStateOf(session).budgetSlowSince === 0) {
182
- qualityStateOf(session).budgetSlowSince = now;
183
- return false;
184
- }
185
- if (now - qualityStateOf(session).budgetSlowSince < BUDGET_SUSTAINED_MS) {
186
- return false; // not sustained yet
187
- }
188
- const bound = await this.classifyTranscodeBound(session);
189
- if (bound === "download") {
190
- logger.info(
191
- `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
192
- `"${session.file.name}"; not stepping down (torrent is the bottleneck)`
193
- );
194
- qualityStateOf(session).budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
195
- return false;
196
- }
197
- qualityStateOf(session).budgetSlowSince = 0;
198
- qualityStateOf(session).budgetUpSince = 0;
199
- const boundLabel = bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
200
- // The machine is the output's reason, so every viewer with it on screen is
201
- // asked — each to the rung their own picture bounds a re-encode by, where
202
- // that is lower than the next rung down (roadmap item 98).
203
- const reasonText = `${boundLabel} speed=${speed.toFixed(2)}x`;
204
- const base = this.#host.outputs.pictureOf(session);
205
- const watching = this.#host.presentOn(session).filter((consumerId) => this.#onScreenHere(session, consumerId));
206
- if (watching.length === 0) {
207
- return this.#askLowerHeight(session, reasonText);
208
- }
209
- let asked = false;
210
- for (const consumerId of watching) {
211
- if (this.#askLowerHeight(session, reasonText, [consumerId], { cap: this.#visibleCapOf(base, consumerId) })) {
212
- asked = true;
213
- }
214
- }
215
- return asked;
216
- }
217
-
218
- /**
219
- * Ask the player to move down one offered rung.
220
- *
221
- * THE SIZE OF THE PICTURE IS NEVER REWRITTEN UNDERNEATH A RUNNING SESSION.
222
- * The fMP4 init segment is fetched once — a player reads `#EXT-X-MAP` and
223
- * never asks again — and `avc1` keeps SPS and PPS in it rather than in the
224
- * fragments, so every fragment produced after a size change is decoded
225
- * against parameter sets describing a picture that is no longer being made.
226
- * Measured 2026-08-21 on two files: one browser went on reporting
227
- * `size=1280x720` for three and a half minutes over a band of macroblock
228
- * garbage after the encoder had left for 960x540; the other errored on the
229
- * first mismatched fragment, closed the MediaSource and sat at `size=0x0`
230
- * for four and a half minutes. Which of the two happens is the decoder's
231
- * choice, not ours, and no layer reported an error either time.
232
- *
233
- * A change of resolution is a change of VARIANT, as the standard has it.
234
- * Every height is already published in the master with its own init, so the
235
- * step is made by ASKING the browser to move — the same act the manual menu
236
- * performs, which has never had this fault.
237
- *
238
- * @param {HlsSession} session
239
- * @param {string} reasonText
240
- * @param {string[] | null} [onlyFor]
241
- * @param {{ urgent?: boolean, cap?: number | null }} [options] - `urgent`
242
- * when the viewer's buffer would run dry first; `cap` the height the
243
- * picture they see bounds a re-encode by, preferred where it is lower than
244
- * the next rung down.
245
- * @returns {boolean} True when an ask was recorded.
246
- */
247
- #askLowerHeight(session, reasonText, onlyFor = null, { urgent = false, cap = null } = {}) {
248
- const base = this.#host.outputs.pictureOf(session);
249
- const current = this.#host.outputs.variantHeightOf(session);
250
- const offered = this.#host.qualityOffer.offeredHeights(base);
251
- // The highest rung strictly below the one on screen that this host is still
252
- // willing to serve. `offeredHeights` has already refused everything the
253
- // machine cannot hold, so a rung that survives it is one worth moving to.
254
- // Where the picture the viewer sees bounds a re-encode lower still, the
255
- // highest rung within that bound is taken instead: they are shown nothing
256
- // the rung between would have added (roadmap item 98).
257
- const lower = this.#host.outputs.splicableHeights(base)
258
- .filter((height) => height < current && offered.includes(height));
259
- const next = (Number.isInteger(cap) && cap > 0 ? lower.find((height) => height <= cap) : undefined) ?? lower[0];
260
- if (next === undefined) {
261
- // Nothing lower — but "lower" is not the same question as "cheaper", and
262
- // on a source that is COPIED the answer is above, not below. A copied
263
- // rung costs no encoder at all, whatever its size, so when a re-encode
264
- // cannot keep up it is both the fastest thing this host can serve AND the
265
- // best picture it has.
266
- //
267
- // Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
268
- // 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
269
- // own 1038p sat on offer beside it, copied and free. This line printed
270
- // fifty times — "nothing lower is on offer; leaving the picture alone" —
271
- // and the picture stood still 161 times for 940 seconds. The rescue was
272
- // on the screen the whole time and the rule could only look down.
273
- const copied = this.#host.qualityOffer.copiedHeightOf(base);
274
- if (copied > 0 && copied !== current && offered.includes(copied)) {
275
- logger.info(
276
- `[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
277
- `but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
278
- `Asking for it instead of leaving the viewer on an encode that cannot keep up`
279
- );
280
- return this.#askQualityHeight(base, copied, reasonText, onlyFor, urgent);
281
- }
282
- logger.info(
283
- `[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
284
- `for "${session.file.name}"; leaving the picture alone`
285
- );
286
- return false;
287
- }
288
- return this.#askQualityHeight(base, next, reasonText, onlyFor, urgent);
289
- }
290
-
291
- /**
292
- * Record a request to the viewer's player to move to another variant.
293
- *
294
- * The proxy cannot move a player between variants; it can only say which one
295
- * it would rather serve. The request travels in every progress report, and
296
- * the browser honours it ONLY in automatic mode — a height the viewer picked
297
- * by hand is theirs, and nothing here may take it away.
298
- *
299
- * @param {HlsSession} base
300
- * @param {number} height
301
- * @param {string} reasonText
302
- * @param {string[] | null} [onlyFor] - The viewers this ask is FOR. A reason
303
- * that belongs to one person's link is theirs alone: a thin link asks only
304
- * the viewer on it, never everybody watching the picture. Null for a reason
305
- * that belongs to the output — the machine cannot keep up with it — which
306
- * is every AUTO viewer's.
307
- * @param {boolean} [urgent] - Their buffer would run dry before anything else
308
- * could arrive, so their page switches without waiting for a cushion.
309
- * @returns {boolean}
310
- */
311
- #askQualityHeight(base, height, reasonText, onlyFor = null, urgent = false) {
312
- if (!this.#host.outputs.publishesVariants(base)) {
313
- // Said once for the session. Repeating it is not information: the answer
314
- // is a property of the stream and cannot change while it plays.
315
- if (qualityStateOf(base).saidNoVariants !== true) {
316
- qualityStateOf(base).saidNoVariants = true;
317
- logger.info(
318
- `[budget] transcode ${base.id} would ask for ${height}p, but this stream publishes no ` +
319
- `variants to move between; leaving the picture alone for the rest of the session`
320
- );
321
- }
322
- return false;
323
- }
324
- const now = Date.now();
325
- const asked = [];
326
- const playing = [];
327
- for (const consumerId of this.#host.consumersOn(base)) {
328
- if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
329
- continue;
330
- }
331
- if (onlyFor && !onlyFor.includes(consumerId)) {
332
- continue;
333
- }
334
- const onScreen = this.#host.stepOnScreenOf(base, consumerId);
335
- const current = onScreen ? this.#host.outputs.get(onScreen) : base;
336
- const currentHeight = this.#host.outputs.variantHeightOf(current ?? base);
337
- playing.push(currentHeight);
338
- if (currentHeight === height) {
339
- this.#host.dropAskOf(base, consumerId);
340
- continue;
341
- }
342
- const standing = this.#host.standingAskOf(base, consumerId);
343
- if (standing && standing.height === height && now - standing.at < QUALITY_ASK_TTL_MS) {
344
- continue;
345
- }
346
- if (this.#host.askQualityOf(base, consumerId, height, reasonText, now, urgent)) {
347
- asked.push(consumerId);
348
- }
349
- }
350
- if (asked.length === 0) {
351
- return false;
352
- }
353
- logger.info(
354
- `[budget] transcode ${base.id} asks the player to move ${playing.join("p/")}p → ${height}p${urgent ? " (urgent)" : ""}: ${reasonText} ` +
355
- `"${base.file.name}" for ${asked.length} AUTO viewer(s) ` +
356
- `(a change of size is a change of variant — its own init describes it)`
357
- );
358
- return true;
359
- }
360
-
361
- /**
362
- * The step BACK UP, in two stages and one rung at a time.
363
- *
364
- * 1. ANOTHER LIMIT OF THE SAME HEIGHT: a viewer moved down to a lower limit
365
- * goes back up the limits of their height first, without their player
366
- * being told (roadmap item 97, step 12). A limit is part of an output, so
367
- * this is a move between outputs, prepared for one viewer.
368
- * 2. ANOTHER HEIGHT, asked of the player in AUTO — only for a viewer with no
369
- * higher limit to go to on the height they watch, and only if their own
370
- * link admits the next height's whole load.
371
- *
372
- * Both follow one window: the machine and the viewer's link have held the
373
- * output on screen, with room to spare, for four times as long as a step
374
- * DOWN needs.
375
- *
376
- * @param {HlsSession} session
377
- * @param {number} now
378
- * @returns {Promise<void>}
379
- */
380
- async #checkStepUp(session, now) {
381
- const base = this.#host.outputs.pictureOf(session);
382
- const current = this.#host.outputs.variantHeightOf(session);
383
- // What the machine and the link would have to look like for a step up, held
384
- // for a window four times the one a step DOWN needs. Anything that fails
385
- // resets it, so the window measures an unbroken stretch.
386
- const spare = this.#roomToSpare(session, now);
387
- // A step up being prepared for a viewer who no longer has room is let go:
388
- // the conditions it was asked under have gone back (roadmap item 98).
389
- for (const consumerId of this.#host.presentOn(session)) {
390
- if (!spare.includes(consumerId)) {
391
- this.#dropUpAsk(session, consumerId, "the room it was asked for has gone");
392
- }
393
- }
394
- if (spare.length === 0) {
395
- qualityStateOf(session).budgetUpSince = 0;
396
- return;
397
- }
398
- if (qualityStateOf(session).budgetUpSince === 0) {
399
- qualityStateOf(session).budgetUpSince = now;
400
- return;
401
- }
402
- if (now - qualityStateOf(session).budgetUpSince < BUDGET_UP_SUSTAINED_MS) {
403
- return;
404
- }
405
- qualityStateOf(session).budgetUpSince = 0;
406
- const reasonText =
407
- `the machine and the link have carried ${current}p for ` +
408
- `${Math.round(BUDGET_UP_SUSTAINED_MS / 1000)}s with room to spare`;
409
- const atTheirHighestLimit = [];
410
- for (const consumerId of spare) {
411
- const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "up", reasonText);
412
- if (move.started) {
413
- continue;
414
- }
415
- // Refused because the MACHINE holds no more encoders: a higher height
416
- // costs more than the limit that was just refused, so it is not asked for.
417
- if (move.noPlace) {
418
- logger.info(`[budget] transcode ${session.id} no step up for ${consumerId}: ${move.reason}`);
419
- continue;
420
- }
421
- atTheirHighestLimit.push(consumerId);
422
- }
423
- // One rung at a time: the lowest height above the one on screen, never
424
- // above the source (upscaling invents detail and costs more than the
425
- // source itself). A second step follows a second unbroken window.
426
- const higher = this.#host.qualityOffer.nextHeightUp(base, current);
427
- if (higher === undefined) {
428
- return;
429
- }
430
- // Only the viewers whose OWN link admits the next rung, and whose picture on
431
- // screen is not already served by the rung they are on: a re-encode is
432
- // never made taller than the picture they see (roadmap item 98). The copy
433
- // of the source is not a re-encode, and going back to it is not bounded.
434
- // Another viewer's fast link or large screen says nothing about theirs.
435
- const copied = this.#host.qualityOffer.copiedHeightOf(base);
436
- const carriers = atTheirHighestLimit.filter((consumerId) =>
437
- this.#linkAdmitsHeight(base, session, consumerId, higher) &&
438
- (higher === copied || this.#visibleCapOf(base, consumerId) === null || higher <= this.#visibleCapOf(base, consumerId))
439
- );
440
- if (carriers.length > 0) {
441
- this.#askQualityHeight(base, higher, reasonText, carriers);
442
- }
443
- }
444
-
445
- /**
446
- * The viewers who have this session on screen with room to spare: the
447
- * encoder ahead of realtime and the torrent not the limit — which are the
448
- * OUTPUT's and hold for all of them or none — and, for each viewer on their
449
- * own, a buffer that is not draining and no move of theirs already being
450
- * prepared.
451
- *
452
- * @param {HlsSession} session
453
- * @param {number} now
454
- * @returns {string[]} Empty when nobody has room.
455
- */
456
- #roomToSpare(session, now) {
457
- if (session.spec.transcodesVideo) {
458
- const speed = this.#host.encodeCost.recentSpeedOf(session, now, BUDGET_CHECK_INTERVAL_MS * 2);
459
- if (speed === null || speed < BUDGET_SPEED_OK) {
460
- return [];
461
- }
462
- }
463
- if (qualityStateOf(session).budgetSlowSince !== 0) {
464
- return [];
465
- }
466
- // A buffer that drains over a whole segment's period is being spent faster
467
- // than it is filled: there is no room for more.
468
- return this.#host.presentOn(session).filter((consumerId) =>
469
- !((this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec)?.slope ?? 0) < 0) &&
470
- this.#onScreenHere(session, consumerId) &&
471
- !this.#host.sameHeightSwitchPending(consumerId)
472
- );
473
- }
474
-
475
- /**
476
- * Whether this viewer's own link admits the whole load of a height nothing
477
- * may have produced yet.
478
- *
479
- * Nothing measured their link: no ground to refuse, the same silence that
480
- * stops `#checkViewerLink` from acting on them.
481
- *
482
- * @param {HlsSession} base
483
- * @param {HlsSession} session
484
- * @param {string} consumerId
485
- * @param {number} height
486
- * @returns {boolean}
487
- */
488
- #linkAdmitsHeight(base, session, consumerId, height) {
489
- const report = this.#host.linkReportOf(session, consumerId);
490
- const load = loadOf(
491
- this.#host.qualityOffer.videoLoadFor(base, height, this.#host.videoEncoder?.kind ?? ""),
492
- this.#host.viewerAudioLoadOf(base, consumerId)
493
- );
494
- return linkCouldCarry(report?.linkMbps ?? null, load).admitted;
495
- }
496
-
497
- /**
498
- * A viewer reported on themselves: judge their link and the picture they
499
- * see, for them alone, now (roadmap item 98).
500
- *
501
- * The report is the event that carries a change of either, so it is where
502
- * they are judged — not a timer, which between two reports would only read
503
- * the same statement again.
504
- *
505
- * @param {string} sessionId - The output the page addressed.
506
- * @param {string} consumerId
507
- * @returns {Promise<void>}
508
- */
509
- async noteViewerReported(sessionId, consumerId) {
510
- const named = sessionId ? this.#host.outputs.get(sessionId) : null;
511
- if (!named || !consumerId) {
512
- return;
513
- }
514
- const base = this.#host.outputs.pictureOf(named);
515
- const onScreen = this.#host.stepOnScreenOf(base, consumerId);
516
- const session = (onScreen ? this.#host.outputs.get(onScreen) : null) ?? base;
517
- if (!this.#host.isLive(session) || session.spec.carries === "audio-only") {
518
- return;
519
- }
520
- // One judgement of a viewer at a time: reports can arrive while the last
521
- // one is still reading its files.
522
- if (this.#judging.has(consumerId)) {
523
- return;
524
- }
525
- this.#judging.add(consumerId);
526
- try {
527
- const now = Date.now();
528
- if (this.#host.videoEncoder?.kind === "software" &&
529
- (await this.#checkViewerLink(session, consumerId, now))) {
530
- return;
531
- }
532
- this.#checkVisiblePicture(session, consumerId, now);
533
- } finally {
534
- this.#judging.delete(consumerId);
535
- }
536
- }
537
-
538
- /**
539
- * This viewer's link against the stream they are given.
540
- *
541
- * THE CONDITION, both halves measured: their link does not carry the stream
542
- * (the reading times the safety margin is below the observed bitrate), and
543
- * their buffer, on its present trend, would run dry before another output
544
- * could close the piece they need (`drain-threat.js`). A buffer that falls
545
- * without that threat moves nothing.
546
- *
547
- * THE LEVERS, in order: another limit of the height on their screen (roadmap
548
- * item 97, step 12), then a lower height, asked of their player as URGENT —
549
- * their page switches as soon as the rung is ready, without waiting for a
550
- * cushion that is shrinking. Where neither can be prepared they stay where
551
- * they are, with no message: what is on screen goes on being delivered, and
552
- * a buffer that runs dry is filled again before the picture moves.
553
- *
554
- * @param {HlsSession} session - The output on their screen.
555
- * @param {string} consumerId
556
- * @param {number} now
557
- * @returns {Promise<boolean>} True when a move was started or asked for.
558
- */
559
- async #checkViewerLink(session, consumerId, now) {
560
- if (!this.#onScreenHere(session, consumerId)) {
561
- return false;
562
- }
563
- const report = this.#host.linkReportOf(session, consumerId);
564
- if (!report) {
565
- return false; // their link has not been measured: no ground to move them
566
- }
567
- const observed = await this.observedStreamMbps(session);
568
- if (observed === null) {
569
- return false; // not enough produced material to compare against
570
- }
571
- const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
572
- // How long another output of the mode on their screen has been seen taking
573
- // to be ready here (roadmap item 97, step 14), and otherwise this host's
574
- // time to a first segment. The observation refines WHEN a move is started;
575
- // what the move may be — admission, the link, the viewer's mode — is
576
- // decided below exactly as without it.
577
- const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
578
- const expectedMs = Number.isFinite(observedMs) ? observedMs : (this.#host.expectedFirstSegmentMs?.() ?? null);
579
- const { threat, secondsToEmpty } = drainThreat({
580
- bufferedSec: buffer?.bufferedSec ?? 0,
581
- slope: buffer?.slope ?? null,
582
- reportGapSec: buffer?.reportGapSec ?? 0,
583
- secondsToReady: Number.isFinite(expectedMs) && expectedMs >= 0 ? expectedMs / 1000 : null
584
- });
585
- const carries = report.linkMbps * LINK_SAFETY >= observed;
586
- if (carries && !threat) {
587
- return false;
588
- }
589
- // Whatever else happens, a step UP prepared for them is not what they need.
590
- this.#dropUpAsk(session, consumerId, "their buffer is draining or their link does not carry the stream");
591
- if (this.#host.sameHeightSwitchDirection(consumerId) === "up") {
592
- this.#host.cancelSameHeightSwitch(consumerId, "their buffer is draining or their link does not carry the stream");
593
- }
594
- if (carries || !threat) {
595
- return false;
596
- }
597
- if (this.#host.sameHeightSwitchPending(consumerId)) {
598
- return false; // a move down is already being prepared for them
599
- }
600
- const reasonText =
601
- `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` +
602
- `buffer=${(buffer?.bufferedSec ?? 0).toFixed(1)}s empty in ${secondsToEmpty === null ? "?" : secondsToEmpty.toFixed(1)}s ` +
603
- `for ${consumerId}`;
604
- const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "down", `viewer-link-bound ${reasonText}`);
605
- if (move.started) {
606
- return true;
607
- }
608
- logger.info(`[budget] transcode ${session.id} ${reasonText}: no lower limit to move to (${move.reason})`);
609
- const base = this.#host.outputs.pictureOf(session);
610
- if (this.#askLowerHeight(session, `viewer-link-bound ${reasonText}`, [consumerId], {
611
- urgent: true,
612
- cap: this.#visibleCapOf(base, consumerId)
613
- })) {
614
- return true;
615
- }
616
- logger.info(
617
- `[budget] transcode ${session.id} ${reasonText}: nothing smaller can be prepared; ` +
618
- `the viewer stays on what they are given`
619
- );
620
- return false;
621
- }
622
-
623
- /**
624
- * The picture this viewer sees against the height they are given (roadmap
625
- * item 98).
626
- *
627
- * A re-encode taller than the picture they see is moved down to a rung of
628
- * the right height — but only onto one that is READY, the piece they will
629
- * ask for next closed on it; their page then switches once they hold the
630
- * cushion this file needs. With no such rung ready, nothing is started: the
631
- * next judgement of their link or of the machine takes the bound into
632
- * account (`#askLowerHeight`).
633
- *
634
- * A picture shorter than the one they see is moved up one rung, in the
635
- * background, when the machine and their link have room; the request is
636
- * dropped if that room goes.
637
- *
638
- * A copy of the source is left alone whatever its height: it is never
639
- * re-encoded for being taller than the picture on screen.
640
- *
641
- * @param {HlsSession} session - The output on their screen.
642
- * @param {string} consumerId
643
- * @param {number} now
644
- * @returns {void}
645
- */
646
- #checkVisiblePicture(session, consumerId, now) {
647
- if (!session.spec.transcodesVideo || !this.#onScreenHere(session, consumerId)) {
648
- return;
649
- }
650
- const base = this.#host.outputs.pictureOf(session);
651
- const cap = this.#visibleCapOf(base, consumerId);
652
- if (cap === null || !this.#host.outputs.publishesVariants(base)) {
653
- return;
654
- }
655
- const current = this.#host.outputs.variantHeightOf(session);
656
- const offered = this.#host.qualityOffer.offeredHeights(base);
657
- if (current > cap) {
658
- const target = this.#host.outputs.splicableHeights(base)
659
- .find((height) => height <= cap && offered.includes(height));
660
- if (target === undefined || !this.#host.heightReadyFor(base, consumerId, target)) {
661
- return;
662
- }
663
- this.#askQualityHeight(base, target, `the picture they see (${cap}p) is smaller than ${current}p`, [consumerId]);
664
- return;
665
- }
666
- if (current < cap) {
667
- const higher = this.#host.qualityOffer.nextHeightUp(base, current);
668
- const room = this.#roomToSpare(session, now).includes(consumerId);
669
- if (higher === undefined || higher > cap || !room || !this.#linkAdmitsHeight(base, session, consumerId, higher)) {
670
- return;
671
- }
672
- this.#askQualityHeight(base, higher, `the picture they see (${cap}p) is larger than ${current}p`, [consumerId]);
673
- }
674
- }
675
-
676
- /**
677
- * The height a re-encode is bounded by for this viewer, from the picture
678
- * they see, or null when their page has not said.
679
- *
680
- * @param {HlsSession} base
681
- * @param {string} consumerId
682
- * @returns {number | null}
683
- */
684
- #visibleCapOf(base, consumerId) {
685
- return visibleHeightCap(base.file.width, base.file.height, this.#host.visiblePictureOf(base, consumerId));
686
- }
687
-
688
- /**
689
- * Let go of a request to move this viewer UP, if one stands.
690
- *
691
- * @param {HlsSession} session
692
- * @param {string} consumerId
693
- * @param {string} because
694
- * @returns {void}
695
- */
696
- #dropUpAsk(session, consumerId, because) {
697
- const base = this.#host.outputs.pictureOf(session);
698
- const ask = this.#host.standingAskOf(base, consumerId);
699
- const onScreen = this.#host.stepOnScreenOf(base, consumerId);
700
- const current = this.#host.outputs.variantHeightOf((onScreen ? this.#host.outputs.get(onScreen) : null) ?? base);
701
- if (ask && ask.height > current) {
702
- this.#host.dropAskOf(base, consumerId);
703
- logger.info(`[budget] transcode ${base.id} no longer asks ${consumerId} for ${ask.height}p: ${because}`);
704
- }
705
- }
706
-
707
- /**
708
- * Whether this output is the one on this viewer's screen.
709
- *
710
- * A viewer is registered on the picture for as long as they watch any step
711
- * of it, so being present on an output does not mean watching it. What an
712
- * output measures — its stream, its speed — is about the people who have it
713
- * on screen, and a decision about a viewer's link is taken against the
714
- * output they actually receive.
715
- *
716
- * @param {HlsSession} session
717
- * @param {string} consumerId
718
- * @returns {boolean}
719
- */
720
- #onScreenHere(session, consumerId) {
721
- const base = this.#host.outputs.pictureOf(session);
722
- const onScreen = this.#host.stepOnScreenOf(base, consumerId);
723
- return (onScreen ?? base.id) === session.id;
724
- }
725
-
726
- /**
727
- * Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
728
- * segment files (the newest file may still be being written and is
729
- * excluded). Transcode sessions only — their segment grid is uniform, so
730
- * bytes / (count × segDur) is exact. Returns null when there is not enough
731
- * material to measure.
732
- *
733
- * @param {HlsSession} session
734
- * @returns {Promise<number | null>}
735
- */
736
- async observedStreamMbps(session) {
737
- let names;
738
- try {
739
- names = this.#host.producedNumbers(session).map((index) => session.segmentFormat.segmentFileName(index));
740
- } catch {
741
- return null;
742
- }
743
- const indices = [];
744
- for (const name of names) {
745
- const index = session.segmentFormat.segmentIndexFromName(name);
746
- if (index >= 0) {
747
- indices.push(index);
748
- }
749
- }
750
- if (indices.length < 3) {
751
- return null; // need ≥2 completed segments after dropping the newest
752
- }
753
- indices.sort((a, b) => a - b);
754
- const completed = indices.slice(0, -1).slice(-LINK_OBSERVED_SEGMENTS);
755
- let bytes = 0;
756
- try {
757
- for (const index of completed) {
758
- const segmentPath = this.#host.segmentPaths.pathOf(session.outputKey ?? "", index);
759
- if (!segmentPath) {
760
- break;
761
- }
762
- const st = await stat(segmentPath);
763
- bytes += st.size;
764
- }
765
- } catch {
766
- return null; // a segment vanished mid-measure (seek-restart cleanup)
767
- }
768
- return (bytes * 8) / (completed.length * this.#host.segmentDurationSec) / 1e6;
769
- }
770
-
771
- /**
772
- * Decide whether a sustained sub-realtime transcode is limited by the encoder
773
- * (CPU) or by a download-starved input. Compares the torrent's download rate
774
- * with the source's average byte rate; a fully-downloaded file can never be
775
- * download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
776
- * as CPU by the caller — the common case, logged as such).
777
- *
778
- * @param {HlsSession} session
779
- * @returns {Promise<"cpu" | "download" | "unknown">}
780
- */
781
- async classifyTranscodeBound(session) {
782
- if (!this.#host.getSourceStats) {
783
- return "unknown";
784
- }
785
- let stats;
786
- try {
787
- stats = await this.#host.getSourceStats(session.file.sourceKey, session.file.fileIndex);
788
- } catch {
789
- return "unknown";
790
- }
791
- if (!stats) {
792
- return "unknown";
793
- }
794
- // A fully (or almost fully) downloaded file cannot be download-bound.
795
- if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
796
- return "cpu";
797
- }
798
- const duration = Number.isFinite(session.file.durationSeconds) ? session.file.durationSeconds : 0;
799
- const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
800
- const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
801
- if (duration <= 0 || length <= 0) {
802
- return "unknown"; // cannot compute the source byte rate
803
- }
804
- const sourceByteRate = length / duration;
805
- return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
806
- }
807
-
808
- /**
809
- * An encoder is about to start on this output.
810
- *
811
- * Any start resets the cumulative `speed` ffmpeg reports, so both of this
812
- * budget's windows over it start again: the pair of readings speed is learned
813
- * from, and the slow window — otherwise warm-up right after a seek reads as
814
- * sustained sub-realtime and triggers a premature step down.
815
- *
816
- * @param {object} output
817
- * @returns {void}
818
- */
819
- noteRunStarting(output) {
820
- const state = qualityStateOf(output);
821
- state.learnSample = null;
822
- state.budgetSlowSince = 0;
823
- }
824
-
825
- /**
826
- * The height this proxy is asking the player to move to, or 0.
827
- *
828
- * Cleared the moment the viewer is on it — the request has been answered —
829
- * and dropped when it runs out, which is the only sign this side ever gets
830
- * that a player could not or would not follow it. A browser on a manual pick
831
- * ignores every request by design, so an unanswered one is not an error; it
832
- * is said once and let go, rather than repeated for the rest of the film.
833
- *
834
- * @param {HlsSession} named - The session the browser addressed.
835
- * @returns {number}
836
- */
837
- standingAskFor(named, consumerId = "") {
838
- const base = this.#host.outputs.pictureOf(named);
839
- if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
840
- this.#host.dropAskOf(base, consumerId);
841
- return 0;
842
- }
843
- const ask = this.#host.standingAskOf(base, consumerId);
844
- if (!ask) {
845
- return 0;
846
- }
847
- const onScreen = this.#host.stepOnScreenOf(base, consumerId);
848
- const current = onScreen ? this.#host.outputs.get(onScreen) : base;
849
- if (this.#host.outputs.variantHeightOf(current ?? base) === ask.height) {
850
- this.#host.dropAskOf(base, consumerId); // this viewer is there; nothing left to ask for
851
- return 0;
852
- }
853
- if (Date.now() - ask.at > QUALITY_ASK_TTL_MS) {
854
- this.#host.dropAskOf(base, consumerId);
855
- logger.info(
856
- `[budget] transcode ${base.id} asked viewer ${consumerId || "unnamed"} for ${ask.height}p and the player stayed where it was ` +
857
- `(${ask.reason}); letting the request go`
858
- );
859
- return 0;
860
- }
861
- return ask.height;
862
- }
863
-
864
- /**
865
- * Whether the request standing for this viewer is urgent — their buffer
866
- * would run dry before anything else could arrive.
867
- *
868
- * @param {HlsSession} named
869
- * @param {string} [consumerId]
870
- * @returns {boolean}
871
- */
872
- standingAskIsUrgent(named, consumerId = "") {
873
- if (this.standingAskFor(named, consumerId) === 0) {
874
- return false;
875
- }
876
- return this.#host.standingAskOf(this.#host.outputs.pictureOf(named), consumerId)?.urgent === true;
877
- }
878
-
879
- heightsOnScreen(base) {
880
- return [...this.variantsOnScreen(base)]
881
- .map((id) => this.#host.outputs.get(id))
882
- .filter((member) => member)
883
- .map((member) => this.#host.outputs.variantHeightOf(member));
884
- }
885
-
886
- /**
887
- * Every session of this family that a live viewer has on screen.
888
- *
889
- * The question "may this rung's encoder be stopped" has no single answer once
890
- * two viewers watch one picture at two qualities: the rung one of them left is
891
- * the rung the other is watching. Nothing may be stopped for being left unless
892
- * nobody is left on it.
893
- *
894
- * @param {HlsSession} base
895
- * @returns {Set<string>} Session ids.
896
- */
897
- variantsOnScreen(base) {
898
- const live = this.#host.liveConsumers(base);
899
- const onScreen = new Set();
900
- for (const consumerId of this.#host.consumersOn(base)) {
901
- if (consumerId && live.size > 0 && !live.has(consumerId)) {
902
- continue;
903
- }
904
- // No step chosen is the picture itself, which is on screen too.
905
- onScreen.add(this.#host.stepOnScreenOf(base, consumerId) ?? base.id);
906
- }
907
- if (onScreen.size === 0) {
908
- onScreen.add(base.id);
909
- }
910
- return onScreen;
911
- }
912
-
913
- /**
914
- * The heights this session's file will be served at, largest first, and the
915
- * two lists a file would be served at before any session exists.
916
- *
917
- * Both are the quality layer's answers and are computed there. What is left
918
- * here is the door: a route holds the session manager and asks it, and these
919
- * go the day the route can ask the layer directly.
920
- *
921
- * @param {HlsSession} session
922
- * @returns {number[]}
923
- */
924
- offeredHeights(session) {
925
- return this.#host.qualityOffer.offeredHeights(session);
926
- }
927
-
928
- /**
929
- * @param {object} mediaInfo
930
- * @returns {{ copy: number[], transcode: number[] } | null}
931
- */
932
- predictOfferedHeights(mediaInfo) {
933
- return this.#host.qualityOffer.predictOfferedHeights(mediaInfo);
934
- }
935
- }
1
+ /**
2
+ * @file The quality budget of the outputs that exist: when a picture is asked to
3
+ * move to another height, when its bitrate is capped by a viewer's measured link,
4
+ * and what the player is told to follow.
5
+ *
6
+ * Moved out of the session manager whole (plan step 3). What it needs of the
7
+ * rest of the proxy is the host object it is built with, and nothing else.
8
+ */
9
+
10
+ import { stat } from "node:fs/promises";
11
+ import { logger } from "../../../utils/logger.js";
12
+ import { ENCODE_RUN_STATE } from "../encode-run-state.js";
13
+ import { LINK_SAFETY, linkCouldCarry, loadOf } from "./link-budget.js";
14
+ import { qualityStateOf } from "./OutputQualityState.js";
15
+ import { drainThreat } from "./drain-threat.js";
16
+ import { visibleHeightCap } from "./visible-rung.js";
17
+
18
+ // Realtime budget — runtime downswitch (software encoder only). Periodically
19
+ // check each active software-transcode session's ffmpeg `speed`; when it stays
20
+ // below realtime for a sustained window AND the input is not download-starved
21
+ // (so the limit is the encoder, not the torrent), step down one resolution rung
22
+ // and restart at the current segment. Conservative so it never thrashes: a long
23
+ // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
24
+ export const BUDGET_CHECK_INTERVAL_MS = 5_000;
25
+ // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
26
+ // realtime resets the slow window (hysteresis).
27
+ const BUDGET_SPEED_SLOW = 0.95;
28
+ const BUDGET_SPEED_OK = 1.0;
29
+ // Slow must persist this long before a downshift (absorbs warm-up + brief
30
+ // complex scenes; the cumulative average won't dip this long unless the host
31
+ // genuinely can't keep up).
32
+ const BUDGET_SUSTAINED_MS = 15_000;
33
+ // The step BACK UP has to be slower to fire than the step down, or the two
34
+ // take turns: a rung that has just been left is by definition one the arithmetic
35
+ // still thinks this machine can hold, so it would be asked for again as soon as
36
+ // the cooldown expired. Four times the down window is a statement about how long
37
+ // a machine has to look able before it is believed, not a measured quantity, and
38
+ // it is written here rather than dressed up as one.
39
+ const BUDGET_UP_SUSTAINED_MS = 60_000;
40
+ // How long a request to the player to change variant stands before it is
41
+ // treated as unanswered. A progress report is polled about every 1.5 s and the
42
+ // switch itself needs the rung warmed, which is the cold start this host
43
+ // measures; this is long enough for both and short enough that a browser which
44
+ // cannot honour the request (no master playlist, a viewer on a manual pick) is
45
+ // not chased for the rest of the film.
46
+ const QUALITY_ASK_TTL_MS = 45_000;
47
+ // The input counts as "keeping up" when the torrent downloads at least this
48
+ // multiple of the source's average byte rate. Below it (and not yet fully
49
+ // downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
50
+ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
51
+ // Viewer-link adaptation. Judged on each report a viewer makes, for that
52
+ // viewer alone (roadmap item 98): when their link does not carry the stream
53
+ // they are given AND their buffer, on its present trend, would run dry before
54
+ // another output could have the piece they need (`drain-threat.js`), a smaller
55
+ // output is prepared for them. A chosen window of slowness and a chosen buffer
56
+ // threshold stood here, and a chosen wait after every action; all three are
57
+ // gone. A manual viewer is never sent an automatic quality request.
58
+ // Observed produced bitrate: average over this many recently completed
59
+ // segments (the newest file on disk may still be written and is excluded).
60
+ const LINK_OBSERVED_SEGMENTS = 5;
61
+
62
+ /** @typedef {{ pathOf: (address: string, index: number) => string | null }} SegmentPathLookup */
63
+
64
+ export class QualityController {
65
+ /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
66
+ #host;
67
+
68
+ /** A budget pass is under way; a timer tick that lands meanwhile is skipped. */
69
+ #tickRunning = false;
70
+
71
+ /** Viewers whose report is being judged now (`noteViewerReported`). @type {Set<string>} */
72
+ #judging = new Set();
73
+
74
+ /**
75
+ * @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `getSourceStats`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `expectedFirstSegmentMs`
76
+ * @param {SegmentPathLookup} host.segmentPaths - Lookup for completed
77
+ * segment paths needed by quality decisions.
78
+ */
79
+ constructor(host) {
80
+ this.#host = host;
81
+ }
82
+
83
+ /**
84
+ * One pass of the quality budget: learn what this host is doing with each
85
+ * running encode, and act on it.
86
+ *
87
+ * Public because it is an operation with a name, not an implementation
88
+ * detail of a timer — and because a loop that decides what the viewer sees
89
+ * and can only be reached through `setInterval` is a loop nothing can check.
90
+ * The timer calls exactly this.
91
+ *
92
+ * @returns {Promise<void>}
93
+ */
94
+ async runQualityBudgetOnce() {
95
+ void this.#host.reportHostLoad();
96
+ // One tick at a time. Both halves await torrent statistics per source, so a
97
+ // slow or stuck answer would otherwise let the next tick in behind it — two
98
+ // passes over the same sessions, taking the same reading twice and acting
99
+ // on the same speed twice, and an earlier tick's rates landing on top of a
100
+ // later tick's.
101
+ if (this.#tickRunning === true) {
102
+ return;
103
+ }
104
+ this.#tickRunning = true;
105
+ try {
106
+ // Taken whatever the encoder is: the torrent's price is charged against
107
+ // this rate on every host, not only on the ones that re-encode.
108
+ await this.#host.sampleDownloadRates();
109
+ if (this.#host.videoEncoder?.kind !== "software") {
110
+ return;
111
+ }
112
+ await this.#realtimeBudgetPass();
113
+ } finally {
114
+ this.#tickRunning = false;
115
+ }
116
+ }
117
+
118
+ async #realtimeBudgetPass() {
119
+ const now = Date.now();
120
+ for (const session of this.#host.outputs.values()) {
121
+ // What this file costs to decode is learned from EVERY encoding session,
122
+ // before any of the budget's own conditions are consulted. Those exist to
123
+ // decide whether to step the quality, and they exclude most of what is
124
+ // worth measuring: a rung already at the foot of its ladder has nowhere
125
+ // to step, and a 240p variant IS its whole ladder — which is exactly the
126
+ // rung the field measured at 0.95x on 2026-08-15, learning nothing from
127
+ // three minutes of it because the loop had already skipped the session as
128
+ // un-actionable.
129
+ await this.#host.encodeCost.learnFrom(session);
130
+ if (
131
+ !session ||
132
+ !this.#host.isLive(session) ||
133
+ this.#host.runStateOf(session) === ENCODE_RUN_STATE.ENDED_FAILED ||
134
+ // Nothing is encoding, so there is no speed to judge. A variant the
135
+ // viewer has switched away from is left in exactly this state, and its
136
+ // last recorded speed would otherwise buy it a step — which restarts
137
+ // the encoder it was just stopped for.
138
+ this.#host.liveRunsOf(session).length === 0 ||
139
+ // A soundtrack published on its own carries no picture, so no quality
140
+ // step is its to make; its price is learned above and that is all.
141
+ session.spec.carries === "audio-only"
142
+ ) {
143
+ continue;
144
+ }
145
+ // A viewer's link is judged on each of their reports
146
+ // (`noteViewerReported`), not here: nothing about it changes between two
147
+ // reports, so a timer would only read the same statement again.
148
+ if (await this.#checkEncoderBudget(session, now)) {
149
+ continue;
150
+ }
151
+ await this.#checkStepUp(session, now);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The encoder-speed check for one session: sustained sub-realtime, and the
157
+ * encoder — not a download-starved input — is the limit.
158
+ *
159
+ * @param {HlsSession} session
160
+ * @param {number} now
161
+ * @returns {Promise<boolean>} True when a step was asked for this tick.
162
+ */
163
+ async #checkEncoderBudget(session, now) {
164
+ if (!session.spec.transcodesVideo) {
165
+ // A copy has no encoder to make cheaper. Whatever the machine is short
166
+ // of, moving this viewer to a RE-ENCODED rung costs it more, not less —
167
+ // so the copy path's only lever is the viewer's link, above.
168
+ return false;
169
+ }
170
+ const speed = this.#host.encodeCost.recentSpeedOf(session, now, BUDGET_CHECK_INTERVAL_MS * 2);
171
+ if (speed === null) {
172
+ return false; // no measurement yet
173
+ }
174
+ if (speed >= BUDGET_SPEED_OK) {
175
+ qualityStateOf(session).budgetSlowSince = 0; // recovered — reset the slow window
176
+ return false;
177
+ }
178
+ if (speed >= BUDGET_SPEED_SLOW) {
179
+ return false; // in the hysteresis band; neither slow nor ok
180
+ }
181
+ if (qualityStateOf(session).budgetSlowSince === 0) {
182
+ qualityStateOf(session).budgetSlowSince = now;
183
+ return false;
184
+ }
185
+ if (now - qualityStateOf(session).budgetSlowSince < BUDGET_SUSTAINED_MS) {
186
+ return false; // not sustained yet
187
+ }
188
+ const bound = await this.classifyTranscodeBound(session);
189
+ if (bound === "download") {
190
+ logger.info(
191
+ `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
192
+ `"${session.file.name}"; not stepping down (torrent is the bottleneck)`
193
+ );
194
+ qualityStateOf(session).budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
195
+ return false;
196
+ }
197
+ qualityStateOf(session).budgetSlowSince = 0;
198
+ qualityStateOf(session).budgetUpSince = 0;
199
+ const boundLabel = bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
200
+ // The machine is the output's reason, so every viewer with it on screen is
201
+ // asked — each to the rung their own picture bounds a re-encode by, where
202
+ // that is lower than the next rung down (roadmap item 98).
203
+ const reasonText = `${boundLabel} speed=${speed.toFixed(2)}x`;
204
+ const base = this.#host.outputs.pictureOf(session);
205
+ const watching = this.#host.presentOn(session).filter((consumerId) => this.#onScreenHere(session, consumerId));
206
+ if (watching.length === 0) {
207
+ return this.#askLowerHeight(session, reasonText);
208
+ }
209
+ let asked = false;
210
+ for (const consumerId of watching) {
211
+ if (this.#askLowerHeight(session, reasonText, [consumerId], { cap: this.#visibleCapOf(base, consumerId) })) {
212
+ asked = true;
213
+ }
214
+ }
215
+ return asked;
216
+ }
217
+
218
+ /**
219
+ * Ask the player to move down one offered rung.
220
+ *
221
+ * THE SIZE OF THE PICTURE IS NEVER REWRITTEN UNDERNEATH A RUNNING SESSION.
222
+ * The fMP4 init segment is fetched once — a player reads `#EXT-X-MAP` and
223
+ * never asks again — and `avc1` keeps SPS and PPS in it rather than in the
224
+ * fragments, so every fragment produced after a size change is decoded
225
+ * against parameter sets describing a picture that is no longer being made.
226
+ * Measured 2026-08-21 on two files: one browser went on reporting
227
+ * `size=1280x720` for three and a half minutes over a band of macroblock
228
+ * garbage after the encoder had left for 960x540; the other errored on the
229
+ * first mismatched fragment, closed the MediaSource and sat at `size=0x0`
230
+ * for four and a half minutes. Which of the two happens is the decoder's
231
+ * choice, not ours, and no layer reported an error either time.
232
+ *
233
+ * A change of resolution is a change of VARIANT, as the standard has it.
234
+ * Every height is already published in the master with its own init, so the
235
+ * step is made by ASKING the browser to move — the same act the manual menu
236
+ * performs, which has never had this fault.
237
+ *
238
+ * @param {HlsSession} session
239
+ * @param {string} reasonText
240
+ * @param {string[] | null} [onlyFor]
241
+ * @param {{ urgent?: boolean, cap?: number | null }} [options] - `urgent`
242
+ * when the viewer's buffer would run dry first; `cap` the height the
243
+ * picture they see bounds a re-encode by, preferred where it is lower than
244
+ * the next rung down.
245
+ * @returns {boolean} True when an ask was recorded.
246
+ */
247
+ #askLowerHeight(session, reasonText, onlyFor = null, { urgent = false, cap = null } = {}) {
248
+ const base = this.#host.outputs.pictureOf(session);
249
+ const current = this.#host.outputs.variantHeightOf(session);
250
+ const offered = this.#host.qualityOffer.offeredHeights(base);
251
+ // The highest rung strictly below the one on screen that this host is still
252
+ // willing to serve. `offeredHeights` has already refused everything the
253
+ // machine cannot hold, so a rung that survives it is one worth moving to.
254
+ // Where the picture the viewer sees bounds a re-encode lower still, the
255
+ // highest rung within that bound is taken instead: they are shown nothing
256
+ // the rung between would have added (roadmap item 98).
257
+ const lower = this.#host.outputs.splicableHeights(base)
258
+ .filter((height) => height < current && offered.includes(height));
259
+ const next = (Number.isInteger(cap) && cap > 0 ? lower.find((height) => height <= cap) : undefined) ?? lower[0];
260
+ if (next === undefined) {
261
+ // Nothing lower — but "lower" is not the same question as "cheaper", and
262
+ // on a source that is COPIED the answer is above, not below. A copied
263
+ // rung costs no encoder at all, whatever its size, so when a re-encode
264
+ // cannot keep up it is both the fastest thing this host can serve AND the
265
+ // best picture it has.
266
+ //
267
+ // Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
268
+ // 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
269
+ // own 1038p sat on offer beside it, copied and free. This line printed
270
+ // fifty times — "nothing lower is on offer; leaving the picture alone" —
271
+ // and the picture stood still 161 times for 940 seconds. The rescue was
272
+ // on the screen the whole time and the rule could only look down.
273
+ const copied = this.#host.qualityOffer.copiedHeightOf(base);
274
+ if (copied > 0 && copied !== current && offered.includes(copied)) {
275
+ logger.info(
276
+ `[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
277
+ `but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
278
+ `Asking for it instead of leaving the viewer on an encode that cannot keep up`
279
+ );
280
+ return this.#askQualityHeight(base, copied, reasonText, onlyFor, urgent);
281
+ }
282
+ logger.info(
283
+ `[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
284
+ `for "${session.file.name}"; leaving the picture alone`
285
+ );
286
+ return false;
287
+ }
288
+ return this.#askQualityHeight(base, next, reasonText, onlyFor, urgent);
289
+ }
290
+
291
+ /**
292
+ * Record a request to the viewer's player to move to another variant.
293
+ *
294
+ * The proxy cannot move a player between variants; it can only say which one
295
+ * it would rather serve. The request travels in every progress report, and
296
+ * the browser honours it ONLY in automatic mode — a height the viewer picked
297
+ * by hand is theirs, and nothing here may take it away.
298
+ *
299
+ * @param {HlsSession} base
300
+ * @param {number} height
301
+ * @param {string} reasonText
302
+ * @param {string[] | null} [onlyFor] - The viewers this ask is FOR. A reason
303
+ * that belongs to one person's link is theirs alone: a thin link asks only
304
+ * the viewer on it, never everybody watching the picture. Null for a reason
305
+ * that belongs to the output — the machine cannot keep up with it — which
306
+ * is every AUTO viewer's.
307
+ * @param {boolean} [urgent] - Their buffer would run dry before anything else
308
+ * could arrive, so their page switches without waiting for a cushion.
309
+ * @returns {boolean}
310
+ */
311
+ #askQualityHeight(base, height, reasonText, onlyFor = null, urgent = false) {
312
+ if (!this.#host.outputs.publishesVariants(base)) {
313
+ // Said once for the session. Repeating it is not information: the answer
314
+ // is a property of the stream and cannot change while it plays.
315
+ if (qualityStateOf(base).saidNoVariants !== true) {
316
+ qualityStateOf(base).saidNoVariants = true;
317
+ logger.info(
318
+ `[budget] transcode ${base.id} would ask for ${height}p, but this stream publishes no ` +
319
+ `variants to move between; leaving the picture alone for the rest of the session`
320
+ );
321
+ }
322
+ return false;
323
+ }
324
+ const now = Date.now();
325
+ const asked = [];
326
+ const playing = [];
327
+ for (const consumerId of this.#host.consumersOn(base)) {
328
+ if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
329
+ continue;
330
+ }
331
+ if (onlyFor && !onlyFor.includes(consumerId)) {
332
+ continue;
333
+ }
334
+ const onScreen = this.#host.stepOnScreenOf(base, consumerId);
335
+ const current = onScreen ? this.#host.outputs.get(onScreen) : base;
336
+ const currentHeight = this.#host.outputs.variantHeightOf(current ?? base);
337
+ playing.push(currentHeight);
338
+ if (currentHeight === height) {
339
+ this.#host.dropAskOf(base, consumerId);
340
+ continue;
341
+ }
342
+ const standing = this.#host.standingAskOf(base, consumerId);
343
+ if (standing && standing.height === height && now - standing.at < QUALITY_ASK_TTL_MS) {
344
+ continue;
345
+ }
346
+ if (this.#host.askQualityOf(base, consumerId, height, reasonText, now, urgent)) {
347
+ asked.push(consumerId);
348
+ }
349
+ }
350
+ if (asked.length === 0) {
351
+ return false;
352
+ }
353
+ logger.info(
354
+ `[budget] transcode ${base.id} asks the player to move ${playing.join("p/")}p → ${height}p${urgent ? " (urgent)" : ""}: ${reasonText} ` +
355
+ `"${base.file.name}" for ${asked.length} AUTO viewer(s) ` +
356
+ `(a change of size is a change of variant — its own init describes it)`
357
+ );
358
+ return true;
359
+ }
360
+
361
+ /**
362
+ * The step BACK UP, in two stages and one rung at a time.
363
+ *
364
+ * 1. ANOTHER LIMIT OF THE SAME HEIGHT: a viewer moved down to a lower limit
365
+ * goes back up the limits of their height first, without their player
366
+ * being told (roadmap item 97, step 12). A limit is part of an output, so
367
+ * this is a move between outputs, prepared for one viewer.
368
+ * 2. ANOTHER HEIGHT, asked of the player in AUTO — only for a viewer with no
369
+ * higher limit to go to on the height they watch, and only if their own
370
+ * link admits the next height's whole load.
371
+ *
372
+ * Both follow one window: the machine and the viewer's link have held the
373
+ * output on screen, with room to spare, for four times as long as a step
374
+ * DOWN needs.
375
+ *
376
+ * @param {HlsSession} session
377
+ * @param {number} now
378
+ * @returns {Promise<void>}
379
+ */
380
+ async #checkStepUp(session, now) {
381
+ const base = this.#host.outputs.pictureOf(session);
382
+ const current = this.#host.outputs.variantHeightOf(session);
383
+ // What the machine and the link would have to look like for a step up, held
384
+ // for a window four times the one a step DOWN needs. Anything that fails
385
+ // resets it, so the window measures an unbroken stretch.
386
+ const spare = this.#roomToSpare(session, now);
387
+ // A step up being prepared for a viewer who no longer has room is let go:
388
+ // the conditions it was asked under have gone back (roadmap item 98).
389
+ for (const consumerId of this.#host.presentOn(session)) {
390
+ if (!spare.includes(consumerId)) {
391
+ this.#dropUpAsk(session, consumerId, "the room it was asked for has gone");
392
+ }
393
+ }
394
+ if (spare.length === 0) {
395
+ qualityStateOf(session).budgetUpSince = 0;
396
+ return;
397
+ }
398
+ if (qualityStateOf(session).budgetUpSince === 0) {
399
+ qualityStateOf(session).budgetUpSince = now;
400
+ return;
401
+ }
402
+ if (now - qualityStateOf(session).budgetUpSince < BUDGET_UP_SUSTAINED_MS) {
403
+ return;
404
+ }
405
+ qualityStateOf(session).budgetUpSince = 0;
406
+ const reasonText =
407
+ `the machine and the link have carried ${current}p for ` +
408
+ `${Math.round(BUDGET_UP_SUSTAINED_MS / 1000)}s with room to spare`;
409
+ const atTheirHighestLimit = [];
410
+ for (const consumerId of spare) {
411
+ const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "up", reasonText);
412
+ if (move.started) {
413
+ continue;
414
+ }
415
+ // Refused because the MACHINE holds no more encoders: a higher height
416
+ // costs more than the limit that was just refused, so it is not asked for.
417
+ if (move.noPlace) {
418
+ logger.info(`[budget] transcode ${session.id} no step up for ${consumerId}: ${move.reason}`);
419
+ continue;
420
+ }
421
+ atTheirHighestLimit.push(consumerId);
422
+ }
423
+ // One rung at a time: the lowest height above the one on screen, never
424
+ // above the source (upscaling invents detail and costs more than the
425
+ // source itself). A second step follows a second unbroken window.
426
+ const higher = this.#host.qualityOffer.nextHeightUp(base, current);
427
+ if (higher === undefined) {
428
+ return;
429
+ }
430
+ // Only the viewers whose OWN link admits the next rung, and whose picture on
431
+ // screen is not already served by the rung they are on: a re-encode is
432
+ // never made taller than the picture they see (roadmap item 98). The copy
433
+ // of the source is not a re-encode, and going back to it is not bounded.
434
+ // Another viewer's fast link or large screen says nothing about theirs.
435
+ const copied = this.#host.qualityOffer.copiedHeightOf(base);
436
+ const carriers = atTheirHighestLimit.filter((consumerId) =>
437
+ this.#linkAdmitsHeight(base, session, consumerId, higher) &&
438
+ (higher === copied || this.#visibleCapOf(base, consumerId) === null || higher <= this.#visibleCapOf(base, consumerId))
439
+ );
440
+ if (carriers.length > 0) {
441
+ this.#askQualityHeight(base, higher, reasonText, carriers);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * The viewers who have this session on screen with room to spare: the
447
+ * encoder ahead of realtime and the torrent not the limit — which are the
448
+ * OUTPUT's and hold for all of them or none — and, for each viewer on their
449
+ * own, a buffer that is not draining and no move of theirs already being
450
+ * prepared.
451
+ *
452
+ * @param {HlsSession} session
453
+ * @param {number} now
454
+ * @returns {string[]} Empty when nobody has room.
455
+ */
456
+ #roomToSpare(session, now) {
457
+ if (session.spec.transcodesVideo) {
458
+ const speed = this.#host.encodeCost.recentSpeedOf(session, now, BUDGET_CHECK_INTERVAL_MS * 2);
459
+ if (speed === null || speed < BUDGET_SPEED_OK) {
460
+ return [];
461
+ }
462
+ }
463
+ if (qualityStateOf(session).budgetSlowSince !== 0) {
464
+ return [];
465
+ }
466
+ // A buffer that drains over a whole segment's period is being spent faster
467
+ // than it is filled: there is no room for more.
468
+ return this.#host.presentOn(session).filter((consumerId) =>
469
+ !((this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec)?.slope ?? 0) < 0) &&
470
+ this.#onScreenHere(session, consumerId) &&
471
+ !this.#host.sameHeightSwitchPending(consumerId)
472
+ );
473
+ }
474
+
475
+ /**
476
+ * Whether this viewer's own link admits the whole load of a height nothing
477
+ * may have produced yet.
478
+ *
479
+ * Nothing measured their link: no ground to refuse, the same silence that
480
+ * stops `#checkViewerLink` from acting on them.
481
+ *
482
+ * @param {HlsSession} base
483
+ * @param {HlsSession} session
484
+ * @param {string} consumerId
485
+ * @param {number} height
486
+ * @returns {boolean}
487
+ */
488
+ #linkAdmitsHeight(base, session, consumerId, height) {
489
+ const report = this.#host.linkReportOf(session, consumerId);
490
+ const load = loadOf(
491
+ this.#host.qualityOffer.videoLoadFor(base, height, this.#host.videoEncoder?.kind ?? ""),
492
+ this.#host.viewerAudioLoadOf(base, consumerId)
493
+ );
494
+ return linkCouldCarry(report?.linkMbps ?? null, load).admitted;
495
+ }
496
+
497
+ /**
498
+ * A viewer reported on themselves: judge their link and the picture they
499
+ * see, for them alone, now (roadmap item 98).
500
+ *
501
+ * The report is the event that carries a change of either, so it is where
502
+ * they are judged — not a timer, which between two reports would only read
503
+ * the same statement again.
504
+ *
505
+ * @param {string} sessionId - The output the page addressed.
506
+ * @param {string} consumerId
507
+ * @returns {Promise<void>}
508
+ */
509
+ async noteViewerReported(sessionId, consumerId) {
510
+ const named = sessionId ? this.#host.outputs.get(sessionId) : null;
511
+ if (!named || !consumerId) {
512
+ return;
513
+ }
514
+ const base = this.#host.outputs.pictureOf(named);
515
+ const onScreen = this.#host.stepOnScreenOf(base, consumerId);
516
+ const session = (onScreen ? this.#host.outputs.get(onScreen) : null) ?? base;
517
+ if (!this.#host.isLive(session) || session.spec.carries === "audio-only") {
518
+ return;
519
+ }
520
+ // One judgement of a viewer at a time: reports can arrive while the last
521
+ // one is still reading its files.
522
+ if (this.#judging.has(consumerId)) {
523
+ return;
524
+ }
525
+ this.#judging.add(consumerId);
526
+ try {
527
+ const now = Date.now();
528
+ if (this.#host.videoEncoder?.kind === "software" &&
529
+ (await this.#checkViewerLink(session, consumerId, now))) {
530
+ return;
531
+ }
532
+ this.#checkVisiblePicture(session, consumerId, now);
533
+ } finally {
534
+ this.#judging.delete(consumerId);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * This viewer's link against the stream they are given.
540
+ *
541
+ * THE CONDITION, both halves measured: their link does not carry the stream
542
+ * (the reading times the safety margin is below the observed bitrate), and
543
+ * their buffer, on its present trend, would run dry before another output
544
+ * could close the piece they need (`drain-threat.js`). A buffer that falls
545
+ * without that threat moves nothing.
546
+ *
547
+ * THE LEVERS, in order: another limit of the height on their screen (roadmap
548
+ * item 97, step 12), then a lower height, asked of their player as URGENT —
549
+ * their page switches as soon as the rung is ready, without waiting for a
550
+ * cushion that is shrinking. Where neither can be prepared they stay where
551
+ * they are, with no message: what is on screen goes on being delivered, and
552
+ * a buffer that runs dry is filled again before the picture moves.
553
+ *
554
+ * @param {HlsSession} session - The output on their screen.
555
+ * @param {string} consumerId
556
+ * @param {number} now
557
+ * @returns {Promise<boolean>} True when a move was started or asked for.
558
+ */
559
+ async #checkViewerLink(session, consumerId, now) {
560
+ if (!this.#onScreenHere(session, consumerId)) {
561
+ return false;
562
+ }
563
+ const report = this.#host.linkReportOf(session, consumerId);
564
+ if (!report) {
565
+ return false; // their link has not been measured: no ground to move them
566
+ }
567
+ const observed = await this.observedStreamMbps(session);
568
+ if (observed === null) {
569
+ return false; // not enough produced material to compare against
570
+ }
571
+ const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
572
+ // How long another output of the mode on their screen has been seen taking
573
+ // to be ready here (roadmap item 97, step 14), and otherwise this host's
574
+ // time to a first segment. The observation refines WHEN a move is started;
575
+ // what the move may be — admission, the link, the viewer's mode — is
576
+ // decided below exactly as without it.
577
+ const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
578
+ const expectedMs = Number.isFinite(observedMs) ? observedMs : (this.#host.expectedFirstSegmentMs?.() ?? null);
579
+ const { threat, secondsToEmpty } = drainThreat({
580
+ bufferedSec: buffer?.bufferedSec ?? 0,
581
+ slope: buffer?.slope ?? null,
582
+ reportGapSec: buffer?.reportGapSec ?? 0,
583
+ secondsToReady: Number.isFinite(expectedMs) && expectedMs >= 0 ? expectedMs / 1000 : null
584
+ });
585
+ const carries = report.linkMbps * LINK_SAFETY >= observed;
586
+ if (carries && !threat) {
587
+ return false;
588
+ }
589
+ // Whatever else happens, a step UP prepared for them is not what they need.
590
+ this.#dropUpAsk(session, consumerId, "their buffer is draining or their link does not carry the stream");
591
+ if (this.#host.sameHeightSwitchDirection(consumerId) === "up") {
592
+ this.#host.cancelSameHeightSwitch(consumerId, "their buffer is draining or their link does not carry the stream");
593
+ }
594
+ if (carries || !threat) {
595
+ return false;
596
+ }
597
+ if (this.#host.sameHeightSwitchPending(consumerId)) {
598
+ return false; // a move down is already being prepared for them
599
+ }
600
+ const reasonText =
601
+ `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` +
602
+ `buffer=${(buffer?.bufferedSec ?? 0).toFixed(1)}s empty in ${secondsToEmpty === null ? "?" : secondsToEmpty.toFixed(1)}s ` +
603
+ `for ${consumerId}`;
604
+ const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "down", `viewer-link-bound ${reasonText}`);
605
+ if (move.started) {
606
+ return true;
607
+ }
608
+ logger.info(`[budget] transcode ${session.id} ${reasonText}: no lower limit to move to (${move.reason})`);
609
+ const base = this.#host.outputs.pictureOf(session);
610
+ if (this.#askLowerHeight(session, `viewer-link-bound ${reasonText}`, [consumerId], {
611
+ urgent: true,
612
+ cap: this.#visibleCapOf(base, consumerId)
613
+ })) {
614
+ return true;
615
+ }
616
+ logger.info(
617
+ `[budget] transcode ${session.id} ${reasonText}: nothing smaller can be prepared; ` +
618
+ `the viewer stays on what they are given`
619
+ );
620
+ return false;
621
+ }
622
+
623
+ /**
624
+ * The picture this viewer sees against the height they are given (roadmap
625
+ * item 98).
626
+ *
627
+ * A re-encode taller than the picture they see is moved down to a rung of
628
+ * the right height — but only onto one that is READY, the piece they will
629
+ * ask for next closed on it; their page then switches once they hold the
630
+ * cushion this file needs. With no such rung ready, nothing is started: the
631
+ * next judgement of their link or of the machine takes the bound into
632
+ * account (`#askLowerHeight`).
633
+ *
634
+ * A picture shorter than the one they see is moved up one rung, in the
635
+ * background, when the machine and their link have room; the request is
636
+ * dropped if that room goes.
637
+ *
638
+ * A copy of the source is left alone whatever its height: it is never
639
+ * re-encoded for being taller than the picture on screen.
640
+ *
641
+ * @param {HlsSession} session - The output on their screen.
642
+ * @param {string} consumerId
643
+ * @param {number} now
644
+ * @returns {void}
645
+ */
646
+ #checkVisiblePicture(session, consumerId, now) {
647
+ if (!session.spec.transcodesVideo || !this.#onScreenHere(session, consumerId)) {
648
+ return;
649
+ }
650
+ const base = this.#host.outputs.pictureOf(session);
651
+ const cap = this.#visibleCapOf(base, consumerId);
652
+ if (cap === null || !this.#host.outputs.publishesVariants(base)) {
653
+ return;
654
+ }
655
+ const current = this.#host.outputs.variantHeightOf(session);
656
+ const offered = this.#host.qualityOffer.offeredHeights(base);
657
+ if (current > cap) {
658
+ const target = this.#host.outputs.splicableHeights(base)
659
+ .find((height) => height <= cap && offered.includes(height));
660
+ if (target === undefined || !this.#host.heightReadyFor(base, consumerId, target)) {
661
+ return;
662
+ }
663
+ this.#askQualityHeight(base, target, `the picture they see (${cap}p) is smaller than ${current}p`, [consumerId]);
664
+ return;
665
+ }
666
+ if (current < cap) {
667
+ const higher = this.#host.qualityOffer.nextHeightUp(base, current);
668
+ const room = this.#roomToSpare(session, now).includes(consumerId);
669
+ if (higher === undefined || higher > cap || !room || !this.#linkAdmitsHeight(base, session, consumerId, higher)) {
670
+ return;
671
+ }
672
+ this.#askQualityHeight(base, higher, `the picture they see (${cap}p) is larger than ${current}p`, [consumerId]);
673
+ }
674
+ }
675
+
676
+ /**
677
+ * The height a re-encode is bounded by for this viewer, from the picture
678
+ * they see, or null when their page has not said.
679
+ *
680
+ * @param {HlsSession} base
681
+ * @param {string} consumerId
682
+ * @returns {number | null}
683
+ */
684
+ #visibleCapOf(base, consumerId) {
685
+ return visibleHeightCap(base.file.width, base.file.height, this.#host.visiblePictureOf(base, consumerId));
686
+ }
687
+
688
+ /**
689
+ * Let go of a request to move this viewer UP, if one stands.
690
+ *
691
+ * @param {HlsSession} session
692
+ * @param {string} consumerId
693
+ * @param {string} because
694
+ * @returns {void}
695
+ */
696
+ #dropUpAsk(session, consumerId, because) {
697
+ const base = this.#host.outputs.pictureOf(session);
698
+ const ask = this.#host.standingAskOf(base, consumerId);
699
+ const onScreen = this.#host.stepOnScreenOf(base, consumerId);
700
+ const current = this.#host.outputs.variantHeightOf((onScreen ? this.#host.outputs.get(onScreen) : null) ?? base);
701
+ if (ask && ask.height > current) {
702
+ this.#host.dropAskOf(base, consumerId);
703
+ logger.info(`[budget] transcode ${base.id} no longer asks ${consumerId} for ${ask.height}p: ${because}`);
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Whether this output is the one on this viewer's screen.
709
+ *
710
+ * A viewer is registered on the picture for as long as they watch any step
711
+ * of it, so being present on an output does not mean watching it. What an
712
+ * output measures — its stream, its speed — is about the people who have it
713
+ * on screen, and a decision about a viewer's link is taken against the
714
+ * output they actually receive.
715
+ *
716
+ * @param {HlsSession} session
717
+ * @param {string} consumerId
718
+ * @returns {boolean}
719
+ */
720
+ #onScreenHere(session, consumerId) {
721
+ const base = this.#host.outputs.pictureOf(session);
722
+ const onScreen = this.#host.stepOnScreenOf(base, consumerId);
723
+ return (onScreen ?? base.id) === session.id;
724
+ }
725
+
726
+ /**
727
+ * Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
728
+ * segment files (the newest file may still be being written and is
729
+ * excluded). Transcode sessions only — their segment grid is uniform, so
730
+ * bytes / (count × segDur) is exact. Returns null when there is not enough
731
+ * material to measure.
732
+ *
733
+ * @param {HlsSession} session
734
+ * @returns {Promise<number | null>}
735
+ */
736
+ async observedStreamMbps(session) {
737
+ let names;
738
+ try {
739
+ names = this.#host.producedNumbers(session).map((index) => session.segmentFormat.segmentFileName(index));
740
+ } catch {
741
+ return null;
742
+ }
743
+ const indices = [];
744
+ for (const name of names) {
745
+ const index = session.segmentFormat.segmentIndexFromName(name);
746
+ if (index >= 0) {
747
+ indices.push(index);
748
+ }
749
+ }
750
+ if (indices.length < 3) {
751
+ return null; // need ≥2 completed segments after dropping the newest
752
+ }
753
+ indices.sort((a, b) => a - b);
754
+ const completed = indices.slice(0, -1).slice(-LINK_OBSERVED_SEGMENTS);
755
+ let bytes = 0;
756
+ try {
757
+ for (const index of completed) {
758
+ const segmentPath = this.#host.segmentPaths.pathOf(session.outputKey ?? "", index);
759
+ if (!segmentPath) {
760
+ break;
761
+ }
762
+ const st = await stat(segmentPath);
763
+ bytes += st.size;
764
+ }
765
+ } catch {
766
+ return null; // a segment vanished mid-measure (seek-restart cleanup)
767
+ }
768
+ return (bytes * 8) / (completed.length * this.#host.segmentDurationSec) / 1e6;
769
+ }
770
+
771
+ /**
772
+ * Decide whether a sustained sub-realtime transcode is limited by the encoder
773
+ * (CPU) or by a download-starved input. Compares the torrent's download rate
774
+ * with the source's average byte rate; a fully-downloaded file can never be
775
+ * download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
776
+ * as CPU by the caller — the common case, logged as such).
777
+ *
778
+ * @param {HlsSession} session
779
+ * @returns {Promise<"cpu" | "download" | "unknown">}
780
+ */
781
+ async classifyTranscodeBound(session) {
782
+ if (!this.#host.getSourceStats) {
783
+ return "unknown";
784
+ }
785
+ let stats;
786
+ try {
787
+ stats = await this.#host.getSourceStats(session.file.sourceKey, session.file.fileIndex);
788
+ } catch {
789
+ return "unknown";
790
+ }
791
+ if (!stats) {
792
+ return "unknown";
793
+ }
794
+ // A fully (or almost fully) downloaded file cannot be download-bound.
795
+ if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
796
+ return "cpu";
797
+ }
798
+ const duration = Number.isFinite(session.file.durationSeconds) ? session.file.durationSeconds : 0;
799
+ const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
800
+ const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
801
+ if (duration <= 0 || length <= 0) {
802
+ return "unknown"; // cannot compute the source byte rate
803
+ }
804
+ const sourceByteRate = length / duration;
805
+ return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
806
+ }
807
+
808
+ /**
809
+ * An encoder is about to start on this output.
810
+ *
811
+ * Any start resets the cumulative `speed` ffmpeg reports, so both of this
812
+ * budget's windows over it start again: the pair of readings speed is learned
813
+ * from, and the slow window — otherwise warm-up right after a seek reads as
814
+ * sustained sub-realtime and triggers a premature step down.
815
+ *
816
+ * @param {object} output
817
+ * @returns {void}
818
+ */
819
+ noteRunStarting(output) {
820
+ const state = qualityStateOf(output);
821
+ state.learnSample = null;
822
+ state.budgetSlowSince = 0;
823
+ }
824
+
825
+ /**
826
+ * The height this proxy is asking the player to move to, or 0.
827
+ *
828
+ * Cleared the moment the viewer is on it — the request has been answered —
829
+ * and dropped when it runs out, which is the only sign this side ever gets
830
+ * that a player could not or would not follow it. A browser on a manual pick
831
+ * ignores every request by design, so an unanswered one is not an error; it
832
+ * is said once and let go, rather than repeated for the rest of the film.
833
+ *
834
+ * @param {HlsSession} named - The session the browser addressed.
835
+ * @returns {number}
836
+ */
837
+ standingAskFor(named, consumerId = "") {
838
+ const base = this.#host.outputs.pictureOf(named);
839
+ if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
840
+ this.#host.dropAskOf(base, consumerId);
841
+ return 0;
842
+ }
843
+ const ask = this.#host.standingAskOf(base, consumerId);
844
+ if (!ask) {
845
+ return 0;
846
+ }
847
+ const onScreen = this.#host.stepOnScreenOf(base, consumerId);
848
+ const current = onScreen ? this.#host.outputs.get(onScreen) : base;
849
+ if (this.#host.outputs.variantHeightOf(current ?? base) === ask.height) {
850
+ this.#host.dropAskOf(base, consumerId); // this viewer is there; nothing left to ask for
851
+ return 0;
852
+ }
853
+ if (Date.now() - ask.at > QUALITY_ASK_TTL_MS) {
854
+ this.#host.dropAskOf(base, consumerId);
855
+ logger.info(
856
+ `[budget] transcode ${base.id} asked viewer ${consumerId || "unnamed"} for ${ask.height}p and the player stayed where it was ` +
857
+ `(${ask.reason}); letting the request go`
858
+ );
859
+ return 0;
860
+ }
861
+ return ask.height;
862
+ }
863
+
864
+ /**
865
+ * Whether the request standing for this viewer is urgent — their buffer
866
+ * would run dry before anything else could arrive.
867
+ *
868
+ * @param {HlsSession} named
869
+ * @param {string} [consumerId]
870
+ * @returns {boolean}
871
+ */
872
+ standingAskIsUrgent(named, consumerId = "") {
873
+ if (this.standingAskFor(named, consumerId) === 0) {
874
+ return false;
875
+ }
876
+ return this.#host.standingAskOf(this.#host.outputs.pictureOf(named), consumerId)?.urgent === true;
877
+ }
878
+
879
+ heightsOnScreen(base) {
880
+ return [...this.variantsOnScreen(base)]
881
+ .map((id) => this.#host.outputs.get(id))
882
+ .filter((member) => member)
883
+ .map((member) => this.#host.outputs.variantHeightOf(member));
884
+ }
885
+
886
+ /**
887
+ * Every session of this family that a live viewer has on screen.
888
+ *
889
+ * The question "may this rung's encoder be stopped" has no single answer once
890
+ * two viewers watch one picture at two qualities: the rung one of them left is
891
+ * the rung the other is watching. Nothing may be stopped for being left unless
892
+ * nobody is left on it.
893
+ *
894
+ * @param {HlsSession} base
895
+ * @returns {Set<string>} Session ids.
896
+ */
897
+ variantsOnScreen(base) {
898
+ const live = this.#host.liveConsumers(base);
899
+ const onScreen = new Set();
900
+ for (const consumerId of this.#host.consumersOn(base)) {
901
+ if (consumerId && live.size > 0 && !live.has(consumerId)) {
902
+ continue;
903
+ }
904
+ // No step chosen is the picture itself, which is on screen too.
905
+ onScreen.add(this.#host.stepOnScreenOf(base, consumerId) ?? base.id);
906
+ }
907
+ if (onScreen.size === 0) {
908
+ onScreen.add(base.id);
909
+ }
910
+ return onScreen;
911
+ }
912
+
913
+ /**
914
+ * The heights this session's file will be served at, largest first, and the
915
+ * two lists a file would be served at before any session exists.
916
+ *
917
+ * Both are the quality layer's answers and are computed there. What is left
918
+ * here is the door: a route holds the session manager and asks it, and these
919
+ * go the day the route can ask the layer directly.
920
+ *
921
+ * @param {HlsSession} session
922
+ * @returns {number[]}
923
+ */
924
+ offeredHeights(session) {
925
+ return this.#host.qualityOffer.offeredHeights(session);
926
+ }
927
+
928
+ /**
929
+ * @param {object} mediaInfo
930
+ * @returns {{ copy: number[], transcode: number[] } | null}
931
+ */
932
+ predictOfferedHeights(mediaInfo) {
933
+ return this.#host.qualityOffer.predictOfferedHeights(mediaInfo);
934
+ }
935
+ }