@torrent-tv/proxy 2.87.7 → 2.87.8

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 (456) 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 +4 -0
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/diagnostics/sctpstate.gdb +31 -31
  19. package/bin/cli.js +684 -684
  20. package/bin/thread-pool.js +30 -30
  21. package/biome.json +411 -411
  22. package/docs/container-architecture.md +218 -218
  23. package/docs/download-architecture.md +261 -261
  24. package/docs/encode-architecture.md +729 -729
  25. package/docs/encode-run-state.md +96 -96
  26. package/docs/logs.md +97 -97
  27. package/docs/socks5-egress-research.md +114 -114
  28. package/docs/storage-architecture.md +267 -267
  29. package/knip.json +14 -14
  30. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  31. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  32. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  33. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  34. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  35. package/openspec/changes/binary-distribution/proposal.md +53 -53
  36. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  37. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  38. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  39. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  40. package/openspec/changes/cold-start/design.md +98 -98
  41. package/openspec/changes/cold-start/proposal.md +62 -62
  42. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  43. package/openspec/changes/cold-start/tasks.md +48 -48
  44. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  45. package/openspec/changes/disk-cap/proposal.md +37 -37
  46. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  47. package/openspec/changes/disk-cap/tasks.md +19 -19
  48. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  49. package/openspec/changes/proxy-observability/design.md +38 -38
  50. package/openspec/changes/proxy-observability/proposal.md +49 -49
  51. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  52. package/openspec/changes/proxy-observability/tasks.md +21 -21
  53. package/openspec/changes/seek-debounce/design.md +118 -118
  54. package/openspec/changes/seek-debounce/proposal.md +59 -59
  55. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  56. package/openspec/changes/seek-debounce/tasks.md +33 -33
  57. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  58. package/openspec/changes/subtitle-language/proposal.md +50 -50
  59. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  60. package/openspec/changes/subtitle-language/tasks.md +26 -26
  61. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  62. package/openspec/changes/track-selection/design.md +47 -47
  63. package/openspec/changes/track-selection/proposal.md +45 -45
  64. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  65. package/openspec/changes/track-selection/tasks.md +20 -20
  66. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  67. package/openspec/changes/transcode-quality/proposal.md +51 -51
  68. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  69. package/openspec/changes/transcode-quality/tasks.md +63 -63
  70. package/openspec/config.yaml +38 -38
  71. package/package.json +1 -1
  72. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  73. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  74. package/research/double-spawn-2026-09-10.md +171 -171
  75. package/research/handover-2026-09-13.md +209 -209
  76. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  77. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  78. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  79. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  80. package/routes/api/client-logs/post.js +86 -86
  81. package/routes/api/delivery-sink/get.js +72 -72
  82. package/routes/api/link-probe/get.js +51 -51
  83. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  84. package/routes/api/playback-plan/post.js +53 -53
  85. package/routes/api/sources/files/get.js +100 -100
  86. package/routes/api/sources/post.js +36 -36
  87. package/routes/api/sources/stats/get.js +132 -132
  88. package/routes/api/subtitles/get.js +230 -230
  89. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  90. package/routes/api/transcode-sessions/net-report/post.js +129 -129
  91. package/routes/api/transcode-sessions/post.js +160 -160
  92. package/routes/api/transcode-sessions/progress/get.js +27 -27
  93. package/routes/api/transcode-sessions/release/post.js +41 -41
  94. package/routes/api/transcode-sessions/seek/post.js +63 -63
  95. package/routes/health/get.js +13 -13
  96. package/routes/healthz/get.js +13 -13
  97. package/routes/stream/get.js +361 -361
  98. package/routes/transcode/audio-file/get.js +64 -64
  99. package/routes/transcode/audio-warm/get.js +84 -84
  100. package/routes/transcode/session-file/get.js +52 -52
  101. package/routes/transcode/variant-file/get.js +95 -95
  102. package/routes/transcode/variant-warm/get.js +84 -84
  103. package/scripts/render-run-graph.js +173 -173
  104. package/services/encode/CoverageMap.js +428 -428
  105. package/services/encode/CushionReport.js +345 -345
  106. package/services/encode/EncodeAdmission.js +332 -332
  107. package/services/encode/EncodeOrchestrator.js +1217 -1217
  108. package/services/encode/EncodePlan.js +1366 -1366
  109. package/services/encode/EncodeRun.js +776 -776
  110. package/services/encode/EncodeRuns.js +1229 -1229
  111. package/services/encode/Encoder.js +142 -142
  112. package/services/encode/EncoderSelection.js +38 -38
  113. package/services/encode/LocalObservations.js +359 -359
  114. package/services/encode/NvencEncoder.js +50 -50
  115. package/services/encode/OutputOpening.js +755 -755
  116. package/services/encode/OutputTimes.js +579 -579
  117. package/services/encode/QsvEncoder.js +58 -58
  118. package/services/encode/Renditions.js +2162 -2162
  119. package/services/encode/RunProgress.js +84 -84
  120. package/services/encode/SegmentDemand.js +95 -95
  121. package/services/encode/SoftwareEncoder.js +121 -121
  122. package/services/encode/V4l2m2mEncoder.js +53 -53
  123. package/services/encode/VaapiEncoder.js +66 -66
  124. package/services/encode/args.js +416 -416
  125. package/services/encode/available-share.js +89 -89
  126. package/services/encode/calibration.js +361 -361
  127. package/services/encode/contention.js +128 -128
  128. package/services/encode/decode-cost-fit.js +363 -363
  129. package/services/encode/encode-exit.js +114 -114
  130. package/services/encode/encode-run-state.js +551 -551
  131. package/services/encode/encoder-readings.js +45 -45
  132. package/services/encode/fingerprint.js +322 -322
  133. package/services/encode/host-load.js +174 -174
  134. package/services/encode/hwaccel.js +1894 -1894
  135. package/services/encode/index.js +9 -9
  136. package/services/encode/learned-median.js +90 -90
  137. package/services/encode/output/EncodedOutput.js +57 -57
  138. package/services/encode/output/Output.js +131 -131
  139. package/services/encode/output/OutputCatalog.js +414 -414
  140. package/services/encode/output/OutputSpec.js +390 -390
  141. package/services/encode/output/Timeline.js +308 -308
  142. package/services/encode/output/cut-grid.js +107 -107
  143. package/services/encode/output/index.js +20 -20
  144. package/services/encode/output/ladder.js +32 -32
  145. package/services/encode/output/playlists.js +284 -284
  146. package/services/encode/output/rates.js +125 -125
  147. package/services/encode/output-key-format.js +21 -21
  148. package/services/encode/quality/EncodeCost.js +1083 -1083
  149. package/services/encode/quality/HostLoad.js +525 -525
  150. package/services/encode/quality/HostTimings.js +249 -249
  151. package/services/encode/quality/OutputQualityState.js +28 -28
  152. package/services/encode/quality/QualityController.js +935 -935
  153. package/services/encode/quality/QualityOffer.js +416 -416
  154. package/services/encode/quality/drain-threat.js +47 -47
  155. package/services/encode/quality/link-budget.js +254 -254
  156. package/services/encode/quality/output-format.js +266 -266
  157. package/services/encode/quality/serving-output.js +216 -216
  158. package/services/encode/quality/visible-rung.js +57 -57
  159. package/services/encode/run-budget.js +97 -97
  160. package/services/encode/run-command.js +747 -747
  161. package/services/encode/run-costs.js +155 -155
  162. package/services/encode/run-inputs.js +42 -42
  163. package/services/encode/segment-formats/fmp4.js +437 -437
  164. package/services/encode/segment-formats/index.js +77 -77
  165. package/services/encode/segment-formats/init-compat.js +324 -324
  166. package/services/encode/segment-formats/mp4-boxes.js +311 -311
  167. package/services/encode/segment-formats/mpegts.js +134 -134
  168. package/services/encode/start-stop-cost.js +178 -178
  169. package/services/encode/throughput.js +97 -97
  170. package/services/encode/torrent-cost.js +88 -88
  171. package/services/identity/installation-id.js +38 -38
  172. package/services/media/ContainerOrchestrator.js +149 -149
  173. package/services/media/KeyframeTables.js +316 -316
  174. package/services/media/SourceFile.js +298 -298
  175. package/services/media/SubtitleCues.js +520 -520
  176. package/services/media/SubtitleOrchestrator.js +167 -167
  177. package/services/media/container/AviContainer.js +266 -266
  178. package/services/media/container/Container.js +603 -603
  179. package/services/media/container/ContainerFactory.js +56 -56
  180. package/services/media/container/KeyframeTable.js +272 -272
  181. package/services/media/container/MatroskaContainer.js +1494 -1494
  182. package/services/media/container/Mp4Container.js +1244 -1244
  183. package/services/media/container/SubtitleFileContainer.js +322 -322
  184. package/services/media/container/ebml-reader.js +159 -159
  185. package/services/media/ffmpeg-banner.js +260 -260
  186. package/services/media/keyframe-probe.js +157 -157
  187. package/services/media/media-info-probe.js +93 -93
  188. package/services/media/tracks/AudioTrack.js +131 -131
  189. package/services/media/tracks/ContainerTrack.js +75 -75
  190. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  191. package/services/media/tracks/SubtitleTrack.js +38 -38
  192. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  193. package/services/media/tracks/VideoTrack.js +33 -33
  194. package/services/media/tracks/index.js +15 -15
  195. package/services/media/tracks/language-detect.js +174 -174
  196. package/services/server/OutputLifecycle.js +418 -418
  197. package/services/server/SegmentServing.js +1146 -1146
  198. package/services/server/ViewerRequests.js +522 -522
  199. package/services/server/WaitLedger.js +142 -142
  200. package/services/server/controllers/SubtitleController.js +109 -109
  201. package/services/server/http-responses.js +60 -60
  202. package/services/server/transcode-session-files.js +325 -325
  203. package/services/server/wire-outputs.js +983 -983
  204. package/services/storage/Diagnostics.js +202 -202
  205. package/services/storage/MachineBudget.js +235 -235
  206. package/services/storage/allowance.js +107 -107
  207. package/services/storage/core-dumps.js +106 -106
  208. package/services/storage/files/CompletedFiles.js +391 -391
  209. package/services/storage/files/piece-from-whole-file.js +118 -118
  210. package/services/storage/free.js +71 -71
  211. package/services/storage/keep.js +48 -48
  212. package/services/storage/machine-memory.js +56 -56
  213. package/services/storage/memory-report.js +575 -575
  214. package/services/storage/piece-store/piece-disk-store.js +516 -516
  215. package/services/storage/piece-store/piece-lru.js +495 -495
  216. package/services/storage/piece-store/shared-piece-store.js +1880 -1880
  217. package/services/storage/returns.js +103 -103
  218. package/services/storage/segment-store/SegmentStore.js +1196 -1196
  219. package/services/storage/wire.js +175 -175
  220. package/services/torrent/Contents.js +324 -324
  221. package/services/torrent/demand/DemandRegister.js +202 -202
  222. package/services/torrent/demand/Urgency.js +137 -137
  223. package/services/torrent/demand/Window.js +118 -118
  224. package/services/torrent/demand/index.js +8 -8
  225. package/services/torrent/demand/pieces.js +140 -140
  226. package/services/torrent/download/SwarmSelection.js +386 -386
  227. package/services/torrent/download/registry.js +102 -102
  228. package/services/torrent/download/withdraw-claim.js +80 -80
  229. package/services/torrent/files.js +455 -455
  230. package/services/torrent/naming.js +619 -619
  231. package/services/torrent/piece-store-of.js +29 -29
  232. package/services/torrent/supply-margin.js +262 -262
  233. package/services/torrent/worker/background-fill.js +213 -213
  234. package/services/torrent/worker/channel.js +271 -271
  235. package/services/torrent/worker/fastest-wires.js +342 -342
  236. package/services/torrent/worker/handle-state.js +23 -23
  237. package/services/torrent/worker/held-bytes.js +155 -155
  238. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  239. package/services/torrent/worker/piece-reader.js +1078 -1078
  240. package/services/torrent/worker/resume-warm.js +107 -107
  241. package/services/torrent/worker/webrtc-shim.js +219 -219
  242. package/services/transport/data-channel-handler.js +21 -8
  243. package/services/transport/delivery-probe.js +842 -842
  244. package/services/transport/health-collector.js +53 -53
  245. package/services/transport/nat-classifier.js +231 -231
  246. package/services/transport/packet-witness.js +808 -808
  247. package/services/transport/port-mapper.js +235 -235
  248. package/services/transport/registry-api.js +81 -81
  249. package/services/transport/tunnel-client.js +447 -447
  250. package/services/transport/usrsctp-state.js +175 -175
  251. package/services/transport/webrtc-manager.js +484 -484
  252. package/services/viewer/Assignments.js +340 -340
  253. package/services/viewer/PriorityMap.js +390 -390
  254. package/services/viewer/PriorityOrchestrator.js +325 -325
  255. package/services/viewer/Viewer.js +657 -657
  256. package/services/viewer/Viewers.js +368 -368
  257. package/services/viewer/active-output.js +37 -37
  258. package/services/viewer/audio-start.js +46 -46
  259. package/services/viewer/buffer-trend.js +81 -81
  260. package/services/viewer/choices.js +647 -647
  261. package/services/viewer/positions.js +126 -126
  262. package/services/viewer/report-intake.js +46 -46
  263. package/store/source-registry.js +71 -71
  264. package/test/asset-paths.test.js +28 -28
  265. package/test/audio-track-bitrate.test.js +40 -40
  266. package/test/auto-quality-step.test.js +707 -707
  267. package/test/available-share.test.js +76 -76
  268. package/test/background-fill.test.js +190 -190
  269. package/test/client-log-file.test.js +110 -110
  270. package/test/completed-files.test.js +115 -115
  271. package/test/concurrent-cost.test.js +148 -148
  272. package/test/container-media-info.test.js +228 -228
  273. package/test/contention.test.js +76 -76
  274. package/test/core-dumps.test.js +57 -57
  275. package/test/coverage-follows-the-disk.test.js +228 -228
  276. package/test/coverage-map.test.js +195 -195
  277. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  278. package/test/cut-times-timeline.test.js +65 -65
  279. package/test/cuts-follow-published-grid.test.js +109 -109
  280. package/test/data-channel-frame.test.js +63 -63
  281. package/test/data-channel-viewer-lifecycle.test.js +92 -0
  282. package/test/dead-torrent-handle.test.js +52 -52
  283. package/test/declared-rates.test.js +116 -116
  284. package/test/declared-tracks.test.js +35 -35
  285. package/test/decode-cost-fit.test.js +202 -202
  286. package/test/decode-cost.test.js +462 -462
  287. package/test/delivery-probe.test.js +393 -393
  288. package/test/demand-register.test.js +206 -206
  289. package/test/dht-bootstrap.test.js +72 -72
  290. package/test/drain-threat.test.js +65 -65
  291. package/test/duplicate-add.test.js +48 -48
  292. package/test/encode-admission.test.js +146 -146
  293. package/test/encode-cost.test.js +283 -283
  294. package/test/encode-exit.test.js +111 -111
  295. package/test/encode-orchestrator.test.js +419 -419
  296. package/test/encode-plan-viewers.test.js +718 -718
  297. package/test/encode-plan.test.js +545 -545
  298. package/test/encode-run-state.test.js +314 -314
  299. package/test/encode-run.test.js +161 -161
  300. package/test/encode-slope.test.js +46 -46
  301. package/test/encoded-output.test.js +39 -39
  302. package/test/encoder-kinds.test.js +122 -122
  303. package/test/encoder-readings.test.js +46 -46
  304. package/test/every-claimant-is-told-its-share.test.js +152 -152
  305. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  306. package/test/failed-start-breaker.test.js +202 -202
  307. package/test/fastest-wires.test.js +143 -143
  308. package/test/ffmpeg-stream-counts.test.js +78 -78
  309. package/test/file-edges.test.js +191 -191
  310. package/test/fmp4-self-contained.test.js +80 -80
  311. package/test/fmp4-timestamps.test.js +140 -140
  312. package/test/fmp4-track-completeness.test.js +41 -41
  313. package/test/format-keyed-outputs.test.js +78 -78
  314. package/test/health-metrics.test.js +38 -38
  315. package/test/held-request-width.test.js +157 -157
  316. package/test/helpers/encode-run.js +136 -136
  317. package/test/helpers/held-file.js +29 -29
  318. package/test/helpers/manager.js +57 -57
  319. package/test/helpers/output-spec.js +63 -63
  320. package/test/host-load.test.js +68 -68
  321. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  322. package/test/init-compat.test.js +219 -219
  323. package/test/input-lost-quiets-the-plan.test.js +261 -261
  324. package/test/input-unavailable.test.js +48 -48
  325. package/test/installation-id.test.js +39 -39
  326. package/test/keeping-period.test.js +83 -83
  327. package/test/keyframe-index-accuracy.test.js +188 -188
  328. package/test/keyframe-table-budget.test.js +102 -102
  329. package/test/keyframes-belong-to-the-file.test.js +249 -249
  330. package/test/learned-median.test.js +37 -37
  331. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  332. package/test/limit-row-by-area.test.js +60 -60
  333. package/test/link-budget.test.js +111 -111
  334. package/test/link-probe.test.js +56 -56
  335. package/test/local-encoding-data.test.js +273 -273
  336. package/test/logger-repeats.test.js +134 -134
  337. package/test/look-ahead-coverage.test.js +56 -56
  338. package/test/manager-surface.test.js +292 -292
  339. package/test/matroska-cues-track.test.js +192 -192
  340. package/test/memory-budget.test.js +390 -390
  341. package/test/move-cost.test.js +216 -216
  342. package/test/mp4-subtitles.test.js +173 -173
  343. package/test/one-authority.test.js +285 -285
  344. package/test/one-budget.test.js +149 -149
  345. package/test/one-output-one-session.test.js +204 -204
  346. package/test/one-resource-divided.test.js +185 -185
  347. package/test/one-serving-output-per-height.test.js +458 -458
  348. package/test/orchestrator-wired.test.js +206 -206
  349. package/test/output-catalog.test.js +39 -39
  350. package/test/output-shape.test.js +21 -21
  351. package/test/output-spec.test.js +251 -251
  352. package/test/output-speed.test.js +88 -88
  353. package/test/outputs-being-prepared.test.js +40 -40
  354. package/test/packet-witness-ring.test.js +236 -236
  355. package/test/packet-witness.test.js +148 -148
  356. package/test/piece-disk-store.test.js +381 -381
  357. package/test/piece-from-whole-file.test.js +129 -129
  358. package/test/piece-lru.test.js +319 -319
  359. package/test/piece-reader.test.js +162 -162
  360. package/test/piece-selection-offset.test.js +85 -85
  361. package/test/piece-store-eviction.test.js +560 -560
  362. package/test/piece-store-never-refuses.test.js +153 -153
  363. package/test/piece-store-reservations.test.js +318 -318
  364. package/test/piece-store-slow-disk.test.js +159 -159
  365. package/test/piece-tail.test.js +97 -97
  366. package/test/plan-host-timings.test.js +68 -68
  367. package/test/plan-is-steady.test.js +225 -225
  368. package/test/priority-map-download.test.js +174 -174
  369. package/test/priority-map-emptied.test.js +204 -204
  370. package/test/priority-map-per-output.test.js +215 -215
  371. package/test/priority-map.test.js +163 -163
  372. package/test/probe-wedge-certainty.test.js +60 -60
  373. package/test/produced-copy-choice.test.js +213 -213
  374. package/test/published-timeline.test.js +83 -83
  375. package/test/quality-offer.test.js +162 -162
  376. package/test/quality-variants.test.js +1229 -1229
  377. package/test/rate-control-in-the-output.test.js +180 -180
  378. package/test/read-survives-withdrawal.test.js +164 -164
  379. package/test/read-window.test.js +365 -365
  380. package/test/resume-warm.test.js +39 -39
  381. package/test/run-graph-drift.test.js +37 -37
  382. package/test/run-inputs.test.js +80 -80
  383. package/test/run-intervals.test.js +100 -100
  384. package/test/run-position-follows-published-grid.test.js +89 -89
  385. package/test/run-progress.test.js +65 -65
  386. package/test/same-height-switch.test.js +664 -664
  387. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  388. package/test/seek-landing.test.js +109 -109
  389. package/test/seek-target-not-superseded.test.js +195 -195
  390. package/test/segment-closed-on-both-branches.test.js +96 -96
  391. package/test/segment-cut-times.test.js +63 -63
  392. package/test/segment-demand.test.js +80 -80
  393. package/test/segment-hold-supersede.test.js +91 -91
  394. package/test/segment-judged-by-its-own-header.test.js +221 -221
  395. package/test/segment-serve-wiring.test.js +475 -475
  396. package/test/segment-store-eviction.test.js +247 -247
  397. package/test/segment-store.test.js +323 -323
  398. package/test/segment-true-start.test.js +86 -86
  399. package/test/segments-are-shared.test.js +176 -176
  400. package/test/send-chunk-bytes.test.js +86 -86
  401. package/test/serving-existing-output.test.js +102 -102
  402. package/test/serving-output.test.js +102 -102
  403. package/test/shared-piece-store.test.js +310 -310
  404. package/test/sidecar-naming.test.js +142 -142
  405. package/test/source-file.test.js +133 -133
  406. package/test/source-files-route.test.js +103 -103
  407. package/test/source-registry.test.js +18 -18
  408. package/test/split-a-stretch.test.js +139 -139
  409. package/test/stale-request-after-seek.test.js +158 -158
  410. package/test/startup-readings.test.js +124 -124
  411. package/test/stream-route.test.js +182 -182
  412. package/test/subtitle-cue-framing.test.js +200 -200
  413. package/test/subtitle-cue-source.test.js +102 -102
  414. package/test/subtitle-cue-walk.test.js +370 -370
  415. package/test/subtitle-cursor.test.js +63 -63
  416. package/test/subtitle-defaults.test.js +118 -118
  417. package/test/subtitle-language.test.js +252 -252
  418. package/test/subtitle-track-numbering.test.js +386 -386
  419. package/test/supply-margin.test.js +177 -177
  420. package/test/swarm-follows-readers.test.js +174 -174
  421. package/test/swarm-reach.test.js +130 -130
  422. package/test/swarm-selection.test.js +220 -220
  423. package/test/tail-duplication.test.js +167 -167
  424. package/test/the-channel-has-both-ends.test.js +91 -91
  425. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  426. package/test/thread-pool.test.js +53 -53
  427. package/test/timeline.test.js +84 -84
  428. package/test/torrent-contents.test.js +235 -235
  429. package/test/torrent-cost.test.js +71 -71
  430. package/test/torrent-files.test.js +221 -221
  431. package/test/torrent-naming.test.js +255 -255
  432. package/test/tracks-begin-together.test.js +209 -209
  433. package/test/tunnel-renewal.test.js +167 -167
  434. package/test/two-viewers-one-picture.test.js +407 -407
  435. package/test/upload-hurry.test.js +186 -186
  436. package/test/usrsctp-state.test.js +131 -131
  437. package/test/video-facts.test.js +102 -102
  438. package/test/viewer-assignments.test.js +143 -143
  439. package/test/viewer-outputs.test.js +285 -285
  440. package/test/viewer-presence.test.js +116 -116
  441. package/test/viewer-requests-admission.test.js +89 -89
  442. package/test/viewer-states.test.js +189 -189
  443. package/test/viewer-subtitle-subscription.test.js +96 -96
  444. package/test/viewer.test.js +139 -139
  445. package/test/viewing-generations.test.js +390 -390
  446. package/test/visible-rung.test.js +37 -37
  447. package/test/wait-ledger.test.js +100 -100
  448. package/test/webrtc-shim.test.js +149 -149
  449. package/test/wedge-certainty.test.js +131 -131
  450. package/test/withdraw-piece-claim.test.js +212 -212
  451. package/test/worker-channel.test.js +158 -158
  452. package/utils/client-log-file.js +151 -151
  453. package/utils/logger.js +312 -312
  454. package/utils/parse-range.js +25 -25
  455. package/utils/perf.js +59 -59
  456. package/utils/torrent-source-key.js +38 -38
@@ -1,2162 +1,2162 @@
1
- /**
2
- * The steps of a picture and its soundtracks.
3
- *
4
- * A step is an output of the same picture at another size, a soundtrack an
5
- * output of one audio track; each is made the first time it is asked for, and an
6
- * output already producing the same thing answers instead. The master playlist
7
- * lists them. Which viewer is on which is the viewer's fact, stated here and
8
- * read back through the host.
9
- */
10
-
11
- import { isOutputName, OUTPUT_UNAVAILABLE } from "./output/index.js";
12
- import { masterPlaylistText, PLAYLIST_FILE_NAME } from "./output/playlists.js";
13
- import { variantHeightsFor } from "./output/ladder.js";
14
- import { isSameMaterial, outputSuits } from "./quality/serving-output.js";
15
- import { audioLoadOf, linkAnswerFigures, linkCouldCarry, loadOf, videoLoadOfLimit, videoLoadOfSpec } from "./quality/link-budget.js";
16
- import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor } from "./args.js";
17
- import { encoderInputs } from "./run-inputs.js";
18
- /**
19
- * How a base files the audio renditions it has made.
20
- *
21
- * By the track AND by how it is produced, because those are two different
22
- * encodes of it: a browser that can decode the track as it stands is served a
23
- * copy, and one that cannot is served AAC. Two viewers of one picture can
24
- * legitimately need both.
25
- *
26
- * @param {number} trackIndex
27
- * @param {boolean} transcode
28
- * @returns {string}
29
- */
30
- export function audioRenditionKey(trackIndex, transcode) {
31
- return `${Number(trackIndex) || 0}:${transcode === true ? "aac" : "copy"}`;
32
- }
33
-
34
- export class Renditions {
35
- #variantPending = new WeakMap();
36
- /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
37
- #host;
38
-
39
- /**
40
- * @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `segmentClosed`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
41
- */
42
- constructor(host) {
43
- this.#host = host;
44
- }
45
-
46
- /**
47
- * The session that produces a given height for the same file, created on
48
- * first request.
49
- *
50
- * A variant IS a session — same source, same file, a different encode — so
51
- * this makes one rather than inventing a parallel object. It is created only
52
- * when its playlist is actually asked for, which is what keeps a weak host
53
- * running one encoder: with the player's own bitrate adaptation off, no
54
- * variant is ever requested unless the viewer picked it.
55
- *
56
- * @param {string} baseSessionId
57
- * @param {number} height - Encode height; must be one of the offered rungs.
58
- * @returns {Promise<HlsSession | null>} Null when the base session is unknown,
59
- * or the height is not offered for it.
60
- */
61
- /**
62
- * The output chosen for THIS viewer at this height, if it is still here.
63
- *
64
- * THE VIEWER'S OWN CHOICE, and nothing else (roadmap item 97, step 11). It
65
- * used to be one record per file and height, read by everybody watching the
66
- * file, and then a step NAMED after the height: the first viewer's answer
67
- * became every viewer's, whatever their link or their mode. Now the choice is
68
- * recorded on the viewer, and only the suitability rule ever makes one.
69
- *
70
- * A choice whose output has gone is no answer; the height is decided again
71
- * by the same rule, never by looking for the first output of that height.
72
- * The recorded key is looked up among the outputs of THIS picture's material,
73
- * so a step of another container can never be the answer.
74
- *
75
- * @param {HlsSession} base
76
- * @param {number} height
77
- * @param {string} consumerId
78
- * @returns {HlsSession | null}
79
- */
80
- servingOutputFor(base, height, consumerId) {
81
- if (!base || !Number.isInteger(height) || height <= 0 || !consumerId) {
82
- return null;
83
- }
84
- const key = this.#host.chosenOutputOf(consumerId, height);
85
- if (!key) {
86
- return null;
87
- }
88
- return this.#interchangeableWith(base, base.spec).find((other) => other.outputKey === key) ?? null;
89
- }
90
-
91
- /**
92
- * The rule has chosen `output` for this viewer at this height: record the
93
- * choice, and on what it was judged, for their progress report.
94
- *
95
- * @param {string} consumerId
96
- * @param {number} height
97
- * @param {HlsSession} output
98
- * @param {HlsSession} base
99
- * @returns {void}
100
- */
101
- #choose(consumerId, height, output, base) {
102
- this.#host.chooseOutput(consumerId, height, output.outputKey ?? "");
103
- const answer = this.#suitsViewer(output, base, consumerId).answer;
104
- this.#host.noteServingVerdict(
105
- consumerId,
106
- answer ? { ...linkAnswerFigures(answer), outputKey: output.outputKey ?? "", height } : null
107
- );
108
- }
109
-
110
- /**
111
- * Whether an output may serve this viewer, by the one rule every path asks
112
- * (`serving-output.js`, `outputSuits`): the picture's material, the size a
113
- * viewer who picked by hand requires, and a whole load their own link admits.
114
- *
115
- * @param {HlsSession} output
116
- * @param {HlsSession} base
117
- * @param {string} consumerId
118
- * @param {{ width: number, height: number } | null} [wanted]
119
- * @returns {{ suits: boolean, answer: object | null, reason: string }}
120
- */
121
- #suitsViewer(output, base, consumerId, wanted = null) {
122
- const linkMbps = this.#host.linkMbpsOf(base, consumerId);
123
- const audioLoad = audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
124
- const encode = output.spec?.video?.encode ?? null;
125
- return outputSuits({
126
- spec: output.spec,
127
- pictureSpec: base.spec,
128
- mode: this.#host.qualityModeOf(base, consumerId),
129
- size: encode
130
- ? { width: encode.width, height: encode.height }
131
- : { width: Number(base.file.width) || 0, height: Number(base.file.height) || 0 },
132
- wanted,
133
- judge: (spec) => linkCouldCarry(
134
- linkMbps,
135
- loadOf(videoLoadOfSpec(spec, base.file.decode?.megabitsPerSecond ?? null, this.#host.observedPeakMbps?.(spec) ?? null), audioLoad)
136
- )
137
- });
138
- }
139
-
140
- /**
141
- * The soundtrack part of the load this viewer's link carries.
142
- *
143
- * @param {HlsSession} base
144
- * @param {string} consumerId
145
- * @returns {import("./quality/link-budget.js").LoadPart | null}
146
- */
147
- viewerAudioLoadOf(base, consumerId) {
148
- return audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
149
- }
150
-
151
- /**
152
- * The soundtrack THIS viewer receives with the picture, as the link sees it:
153
- * whether it is re-encoded, and the rate the file states for it.
154
- *
155
- * Their own choice where they have made one, else the picture's own track.
156
- * Counted whether it travels inside the picture or as a stream of its own:
157
- * it crosses the same link either way.
158
- *
159
- * @param {HlsSession} base
160
- * @param {string} consumerId
161
- * @returns {{ transcode: boolean, bitrateKbps: number | null }}
162
- */
163
- #viewerAudioOf(base, consumerId) {
164
- const choice = this.#host.audioChoiceOf(base, consumerId) ??
165
- { trackIndex: this.#flatAudioTrackOf(base), transcode: base.spec.transcodesAudio };
166
- const inventory = this.#host.getCachedAudioTracks?.({
167
- sourceKey: base.file.sourceKey,
168
- fileIndex: base.file.fileIndex
169
- }) ?? [];
170
- const entry = Array.isArray(inventory) ? inventory.find((one) => one?.index === choice.trackIndex) : null;
171
- return {
172
- transcode: choice.transcode === true,
173
- bitrateKbps: Number.isFinite(entry?.bitrateKbps) ? entry.bitrateKbps : null
174
- };
175
- }
176
-
177
- async resolveVariantSession(baseSessionId, height, wantedIndex = -1, consumerId = "") {
178
- if (!isOutputName(baseSessionId)) {
179
- return null;
180
- }
181
- const base = this.#host.outputs.get(baseSessionId);
182
- if (!base) {
183
- return null;
184
- }
185
- if (!Number.isInteger(height) || height <= 0) {
186
- return null;
187
- }
188
- // Only the heights the master offers. Anything else is a made-up request,
189
- // and honouring it would let a client start encoder runs at will. The
190
- // MASTER's list, not the live one: a rung is published for the session's
191
- // whole life, and refusing what we published is how a quality switch became
192
- // a 404 storm across every level.
193
- if (!this.#host.outputs.splicableHeights(base).includes(height)) {
194
- return null;
195
- }
196
- // A height is chosen FOR somebody. A request naming nobody has no link, no
197
- // mode and no soundtrack to be judged by, so it is refused rather than
198
- // answered by whatever somebody else was given.
199
- if (!consumerId) {
200
- return null;
201
- }
202
- const existing = this.servingOutputFor(base, height, consumerId);
203
- if (existing) {
204
- this.#host.outputs.touch(existing);
205
- return existing;
206
- }
207
- // hls.js asks for a new level's playlist, its init and its first segments
208
- // within the same moment. Without this every one of them would build its
209
- // own session, and the ones that lost would encode for nobody. Per VIEWER:
210
- // two viewers of one height may be given two different outputs.
211
- //
212
- // A PREPARATION HOLDS THE DECISION IT WAS MADE ON: the link reading and the
213
- // mode at its start. A request of theirs arriving meanwhile joins it and is
214
- // told on what it was decided; a newer reading is weighed by the next
215
- // decision, once this one has ended.
216
- const pendingByHeight = this.#variantPending.get(base) ?? new Map();
217
- this.#variantPending.set(base, pendingByHeight);
218
- const pendingKey = `${height}:${consumerId}`;
219
- const pending = pendingByHeight.get(pendingKey);
220
- if (pending) {
221
- this.#host.logger.info(
222
- `transcode ${base.id} ${height}p for ${consumerId} joins the preparation decided on ` +
223
- `link=${pending.linkMbps ?? "unmeasured"} mode=${pending.mode}`
224
- );
225
- return pending.creation;
226
- }
227
- const mode = this.#host.qualityModeOf(base, consumerId);
228
- const linkMbps = this.#host.linkMbpsOf(base, consumerId);
229
- if (height === this.#host.outputs.variantHeightOf(base)) {
230
- // The picture's own height is answered by the picture only when it suits
231
- // this viewer, by the same rule as any other output. A picture that does
232
- // not is not handed over for being the one addressed.
233
- const verdict = this.#suitsViewer(base, base, consumerId);
234
- if (verdict.suits) {
235
- this.#choose(consumerId, height, base, base);
236
- return base;
237
- }
238
- this.#host.logger.info(
239
- `transcode ${base.id} the picture's own ${height}p does not suit ${consumerId} (${verdict.reason}); deciding again`
240
- );
241
- }
242
- const creation = this.#host.createOrGetSession(this.#stepOpening(base, {
243
- height,
244
- // Where this variant must begin. The segment the player asked it for when
245
- // it can be known — that is the player stating outright where it will
246
- // start fetching, and it is the only figure that cannot be stale.
247
- //
248
- // The other rung's read head is NOT that figure, and using it cost a
249
- // stuck session on 2026-08-11: a 240p rung encoding at 5-6x had read 56 s
250
- // further than the picture had played, so switching back to 400p placed
251
- // that run at 3084 s while the player needed 3028 s, and no segment it
252
- // wanted was ever produced.
253
- startSeconds: this.#variantStartSeconds(
254
- base,
255
- Number.isInteger(wantedIndex) && wantedIndex >= 0 ? this.#host.outputTimes.segmentStartTime(base, wantedIndex) : undefined,
256
- consumerId
257
- ),
258
- mode,
259
- linkMbps,
260
- consumerId,
261
- capKbps: null
262
- }))
263
- .then(async (variant) => {
264
- // Making a session takes seconds — a probe and a keyframe index — and
265
- // the viewer can leave inside that window. A variant registered onto a
266
- // disposed base is reachable by nobody: the browser never learns its
267
- // id, so nothing would release it and it would hold an encoder, a temp
268
- // directory and a claim on the torrent until its own idle timer noticed
269
- // half an hour later.
270
- if (!this.#host.encodeRuns.isLive(base)) {
271
- await this.#letGoIfNobodyIsOn(
272
- variant,
273
- `the picture it was made for ended while it was being made`
274
- );
275
- return null;
276
- }
277
- // Served by the picture itself, which does not become a step. RECORDED
278
- // like any other answer: a path that answers without recording leaves
279
- // the next request to decide the same question again, and the four
280
- // addressings of one step then name different outputs.
281
- if (variant === base) {
282
- this.#choose(consumerId, height, base, base);
283
- return base;
284
- }
285
- const incumbent = await this.#adoptIfAlreadyProduced(base, height, variant, consumerId);
286
- if (incumbent) {
287
- this.#choose(consumerId, height, incumbent, base);
288
- return incumbent;
289
- }
290
- // WHATEVER IT TURNED OUT TO BE is the answer for the height asked for.
291
- // It used to be recorded only where the produced height equalled the
292
- // one asked for — which is the case that needs no record at all: a step
293
- // produced at another size is NAMED after that size, so nothing could
294
- // find it by the height it answers, and every later request decided
295
- // again.
296
- const produced = this.#host.outputs.producedHeightOf(variant);
297
- variant.variantHeight ??= produced > 0 ? produced : height;
298
- // How it came to be: a step of a picture, not a picture a browser
299
- // opened. Read where a step needs the facts of the file rather than of
300
- // its own encode.
301
- this.#host.outputs.markStep(variant);
302
- this.#choose(consumerId, height, variant, base);
303
- return variant;
304
- })
305
- .finally(() => {
306
- pendingByHeight.delete(pendingKey);
307
- });
308
- pendingByHeight.set(pendingKey, { creation, linkMbps, mode, startedAt: Date.now() });
309
- return creation;
310
- }
311
-
312
- /**
313
- * What opening a step of this picture asks of `OutputOpening`, for one
314
- * viewer, at one height and, when named, one bitrate limit.
315
- *
316
- * One place for it, because two paths open steps — a height asked for, and a
317
- * move to another limit of the height on screen — and the two must agree
318
- * about everything but the size and the limit: the cuts, where the sound
319
- * travels, the container. A step that differed from its picture in any of
320
- * those would not be a step of it.
321
- *
322
- * @param {HlsSession} base
323
- * @param {{ height: number, startSeconds: number, mode: "auto" | "manual", linkMbps: number | null, consumerId: string, capKbps: number | null }} params
324
- * @returns {object}
325
- */
326
- #stepOpening(base, { height, startSeconds, mode, linkMbps, consumerId, capKbps }) {
327
- return {
328
- sourceKey: base.file.sourceKey,
329
- fileIndex: base.file.fileIndex,
330
- transcodeVideo: true,
331
- transcodeAudio: base.spec.transcodesAudio,
332
- fileName: base.file.name,
333
- // Opened by nobody in particular: the viewer who wants this step is
334
- // registered on it by whoever asked for it.
335
- consumerId: "",
336
- targetWidth: 0,
337
- targetHeight: height,
338
- // Floored onto the ten-second grid that session keys are bucketed to:
339
- // rounding is what that bucket does, and a position rounded UP starts the
340
- // run past the viewer, so the run just spawned is killed and restarted
341
- // before it has produced anything.
342
- startPositionSeconds: Math.floor(startSeconds / 10) * 10,
343
- audioTrackIndex: this.#flatAudioTrackOf(base),
344
- // A rung is produced at exactly the size it names and the realtime budget
345
- // does not move it — otherwise two rungs could drift onto the same height
346
- // and the choice between them would mean nothing. True of EVERY rung,
347
- // including one the player moved itself onto.
348
- exactSize: true,
349
- // Whose request this is decides whether an output already here may serve
350
- // it: a size picked by hand is served exactly, the automatic choice by the
351
- // quality rules. A viewer whose page does not say is taken as picking.
352
- servingMode: mode,
353
- viewerLinkMbps: linkMbps,
354
- // A limit named outright, or null for the rule to choose one. Named, it
355
- // is answered with that limit or not at all (`output-format.js`).
356
- capKbps,
357
- // The soundtrack THIS viewer hears, which is part of what their link
358
- // has to carry and need not be the one the picture was opened with.
359
- viewerAudio: this.#viewerAudioOf(base, consumerId),
360
- // A rung of a session whose audio is published separately carries no
361
- // audio either — every rung of one master must agree about that, or
362
- // switching rung would start or stop a second copy of the same track.
363
- audioRenditions: this.servesAudioSeparately(base),
364
- // Not re-decided here: asked on its own, a variant would answer about the
365
- // rungs IT would be offered at — a 540p rung of a copied 1080p source is
366
- // offered nothing but itself, so it would conclude "audio muxed" and
367
- // start carrying a second copy of a track the player is already fetching
368
- // from the rendition.
369
- inheritedAudioSeparate: this.servesAudioSeparately(base),
370
- segmentFormatId: base.segmentFormat?.id ?? "",
371
- // Cut where the base is cut. Only for a base on the source's own keyframe
372
- // grid — a copy — where the variant has to land on those exact times to
373
- // be interchangeable with it. A base on the uniform grid needs nothing
374
- // passed: the variant computes the same even grid from the same duration.
375
- inheritedGrid: base.timeline.cutGrid === "keyframe"
376
- ? {
377
- // The table as it stands NOW, corrections included — not the index
378
- // it was first built from. This is what the new session CUTS at.
379
- boundaries: base.timeline.boundaries,
380
- // And this is what it must SAY, which is not the same thing: every
381
- // member of a family has to publish one timeline, or two sessions
382
- // stamp the same moment differently and the picture and the sound
383
- // drift apart by exactly the corrections made between their two
384
- // creations (field 2026-08-17, corrections of 0.6-2.9 s).
385
- published: base.timeline.published
386
- }
387
- : null
388
- };
389
- }
390
-
391
- /**
392
- * Begin moving this viewer onto another output of the height on their
393
- * screen, at another bitrate limit (roadmap item 97, step 12).
394
- *
395
- * THE ADDRESS DOES NOT CHANGE. Their player goes on asking `v/<height>/…`;
396
- * what changes is which output this side answers that address with, and it
397
- * changes only once the segment they will ask for next is closed on the new
398
- * output (`noteSegmentPublished`). Until then they are given what they were
399
- * given, so a move that never completes costs them nothing.
400
- *
401
- * WHAT IS MOVED BETWEEN: outputs of the same material and the same produced
402
- * size whose bounds are two neighbouring limits of `limitsFor(frame)` — the
403
- * row of the frame on screen, chosen by its area, the same row the output was
404
- * opened in (`output-format.js`). Down,
405
- * the highest lower limit their link admits; up, the next limit only, and
406
- * only if their link admits it. A copy has no limit and a hardware encode is
407
- * given none, so neither has anywhere to move to here, and the caller's other
408
- * lever — another height — is what remains.
409
- *
410
- * The output prepared may be one another viewer is already watching at that
411
- * limit: it is found by its key, and opening an output that exists returns it.
412
- *
413
- * @param {HlsSession} named - The output the browser addresses.
414
- * @param {string} consumerId
415
- * @param {"down" | "up"} direction
416
- * @param {string} reasonText - Why, for the log.
417
- * @returns {Promise<{ started: boolean, reason: string, noPlace?: boolean }>}
418
- * `noPlace` when the refusal is the machine's: it holds no more encoders.
419
- */
420
- async prepareSameHeightSwitch(named, consumerId, direction, reasonText) {
421
- const base = named ? this.#host.outputs.pictureOf(named) : null;
422
- const refuse = (reason) => ({ started: false, reason });
423
- if (!consumerId || !base) {
424
- return refuse("nobody named, or no picture");
425
- }
426
- // Only a master's variants go through the choice: without one, the player
427
- // addresses a single output by name and there is nothing to answer with
428
- // anything else.
429
- if (!this.#host.outputs.publishesVariants(base)) {
430
- return refuse("this stream publishes no variants");
431
- }
432
- if (this.#host.sameHeightSwitchOf(consumerId)) {
433
- return refuse("a move is already being prepared for this viewer");
434
- }
435
- const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
436
- const encode = current?.spec?.video?.encode ?? null;
437
- if (!encode || !encode.rateControl) {
438
- return refuse("the output on screen has no limit to change (a copy, or a hardware encode)");
439
- }
440
- const askedHeights = this.#host.heightsChosenAs(consumerId, current.outputKey ?? "");
441
- if (askedHeights.length === 0) {
442
- return refuse("no height is chosen as the output on screen");
443
- }
444
- const limits = this.#host.limitsFor({ width: encode.width, height: encode.height });
445
- const at = limits.findIndex((limit) => maxrateKbpsFor(limit) === encode.rateControl.maxrateKbps);
446
- if (at < 0) {
447
- return refuse(`the limit on screen (maxrate ${encode.rateControl.maxrateKbps}k) is not one of ${encode.height}p's`);
448
- }
449
- const linkMbps = this.#host.linkMbpsOf(base, consumerId);
450
- const audioLoad = this.viewerAudioLoadOf(base, consumerId);
451
- const admits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad)).admitted;
452
- const target = direction === "down"
453
- ? limits.slice(at + 1).find(admits)
454
- : (at > 0 && admits(limits[at - 1]) ? limits[at - 1] : undefined);
455
- if (target === undefined) {
456
- return refuse(direction === "down"
457
- ? `no lower limit of ${encode.height}p is admitted by this viewer's link`
458
- : `no higher limit of ${encode.height}p is admitted by this viewer's link, or ${encode.height}p is at its highest`);
459
- }
460
- let prepared;
461
- try {
462
- prepared = await this.#host.createOrGetSession(this.#stepOpening(base, {
463
- height: encode.height,
464
- startSeconds: this.#host.viewerPositionOf(base.id, consumerId),
465
- // Exactly the size on screen: a move between limits never changes it,
466
- // and an automatic answer could.
467
- mode: "manual",
468
- linkMbps,
469
- consumerId,
470
- capKbps: target
471
- }));
472
- } catch (error) {
473
- if (error?.code === OUTPUT_UNAVAILABLE) {
474
- return refuse(`the ${target}kbps output of ${encode.height}p does not suit this viewer: ${error.details?.reason ?? ""}`);
475
- }
476
- throw error;
477
- }
478
- // The picture and the viewer may both have moved while it was being made.
479
- if (!this.#host.encodeRuns.isLive(base) || !prepared) {
480
- return refuse("the picture ended while the output was being made");
481
- }
482
- const preparedEncode = prepared.spec?.video?.encode ?? null;
483
- if (
484
- prepared === current ||
485
- preparedEncode?.height !== encode.height ||
486
- preparedEncode?.width !== encode.width ||
487
- preparedEncode?.rateControl?.maxrateKbps !== maxrateKbpsFor(target) ||
488
- !isSameMaterial(prepared.spec, base.spec)
489
- ) {
490
- return refuse(`what was opened (${prepared.outputKey}) is not ${encode.height}p at ${target}kbps of this picture`);
491
- }
492
- // An output whose encoding has failed for good will close no segment, so a
493
- // move onto it would wait for ever — and, cancelled on that failure, be
494
- // prepared again on the next budget pass, once per cooldown.
495
- if (this.#host.encodeRuns.hasFailed(prepared)) {
496
- return refuse(`encoding ${prepared.outputKey} has failed`);
497
- }
498
- if (this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }) !== current ||
499
- this.#host.sameHeightSwitchOf(consumerId)) {
500
- return refuse("the output on this viewer's screen changed while the move was being prepared");
501
- }
502
- // A PLACE ON THIS MACHINE, asked before anything is recorded and in the same
503
- // synchronous stretch as the record (roadmap item 97, step 13). Two moves
504
- // onto two different outputs cannot then both be told there is room for
505
- // one: the second is asked after the first is written, and is counted.
506
- // Refused, nothing is recorded, and the caller goes to its other lever.
507
- const place = this.#host.admitsPreparation(prepared);
508
- if (!place.admitted) {
509
- await this.#letGoIfNobodyIsOn(prepared, `a move onto it was refused a place: ${place.reason}`);
510
- // Said as its own kind of refusal: "no limit to move to" sends the budget
511
- // to another height, and "no place on the machine" must not send it UP,
512
- // where the encoder would cost more still.
513
- return { ...refuse(`the machine has no place for ${prepared.outputKey}: ${place.reason}`), noPlace: true };
514
- }
515
- // A STEP OF THIS PICTURE, as any step made for a height is: without it the
516
- // output is a picture of its own, `pictureOf` answers with it rather than
517
- // with the picture, and the choice about to point at it is looked up among
518
- // this picture's steps and not found.
519
- if (prepared !== base) {
520
- prepared.variantHeight ??= encode.height;
521
- this.#host.outputs.markStep(prepared);
522
- }
523
- this.#host.noteSameHeightSwitch(consumerId, {
524
- askedHeights,
525
- outputId: prepared.id,
526
- outputKey: prepared.outputKey ?? "",
527
- direction,
528
- reason: reasonText,
529
- since: Date.now()
530
- });
531
- // On it for as long as the move is prepared: that is what buys it an
532
- // encoder, from where they stand. `watches` and not `placeOn`: a viewer is
533
- // one record whose position is already known, and placing them again would
534
- // restate it and clear the cushion they reported with it.
535
- this.#host.watches(prepared, consumerId);
536
- this.#host.planEncodersSoon();
537
- this.#host.logger.info(
538
- `transcode ${base.id} preparing ${consumerId}'s move ${direction} from ${current.outputKey} to ` +
539
- `${prepared.outputKey} (${encode.height}p, ${target}kbps): ${reasonText}`
540
- );
541
- // It may already be made there — another viewer on it, or material kept.
542
- this.#reconsiderSameHeightSwitch(base, consumerId, null);
543
- const active = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
544
- if (active === prepared || this.#host.sameHeightSwitchOf(consumerId)) {
545
- return { started: true, reason: "" };
546
- }
547
- return refuse("the prepared output was not usable for this viewer");
548
- }
549
-
550
- /**
551
- * Whether a move to another limit is being prepared for this viewer.
552
- *
553
- * @param {string} consumerId
554
- * @returns {boolean}
555
- */
556
- sameHeightSwitchPending(consumerId) {
557
- return Boolean(this.#host.sameHeightSwitchOf(consumerId));
558
- }
559
-
560
- /**
561
- * Which way the move being prepared for this viewer goes, or null.
562
- *
563
- * @param {string} consumerId
564
- * @returns {"down" | "up" | null}
565
- */
566
- sameHeightSwitchDirection(consumerId) {
567
- return this.#host.sameHeightSwitchOf(consumerId)?.direction ?? null;
568
- }
569
-
570
- /**
571
- * Stop preparing this viewer's move, for a reason the quality budget found:
572
- * the conditions a move up was started under have gone back (roadmap item
573
- * 98).
574
- *
575
- * @param {string} consumerId
576
- * @param {string} reason
577
- * @returns {void}
578
- */
579
- cancelSameHeightSwitch(consumerId, reason) {
580
- const move = consumerId ? this.#host.sameHeightSwitchOf(consumerId) : null;
581
- const prepared = move ? this.#host.outputs.get(move.outputId) : null;
582
- if (!move || !prepared) {
583
- if (move) {
584
- this.#host.noteSameHeightSwitch(consumerId, null);
585
- }
586
- return;
587
- }
588
- this.#cancelSameHeightSwitch(this.#host.outputs.pictureOf(prepared), consumerId, reason);
589
- }
590
-
591
- /**
592
- * Whether a step of this picture at `height` is READY for this viewer: it
593
- * exists and the piece they will ask for next is closed on it (roadmap item
594
- * 98). Ready means that piece — not the whole film.
595
- *
596
- * @param {HlsSession} base
597
- * @param {string} consumerId
598
- * @param {number} height
599
- * @returns {boolean}
600
- */
601
- heightReadyFor(base, consumerId, height) {
602
- const step = this.#interchangeableWith(base, base.spec)
603
- .find((output) => this.#host.outputs.variantHeightOf(output) === height);
604
- if (!step) {
605
- return false;
606
- }
607
- const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
608
- const askedHeights = current ? this.#host.heightsChosenAs(consumerId, current.outputKey ?? "") : [];
609
- const next = this.#nextSegmentOf(base, consumerId, askedHeights, step);
610
- return next < step.timeline.segmentCount && this.#host.segmentClosed(step.outputKey ?? "", next);
611
- }
612
-
613
- /**
614
- * A segment was closed on some output. If a viewer's move is being prepared
615
- * onto it, whether that move can be made now is asked again.
616
- *
617
- * Told by `SegmentStore.onPublished`, and nothing here polls: a move waits
618
- * for exactly the event that makes it possible.
619
- *
620
- * @param {string} key - The output the segment was closed on.
621
- * @param {number} index
622
- * @returns {void}
623
- */
624
- noteSegmentPublished(key, index) {
625
- if (!key) {
626
- return;
627
- }
628
- for (const output of this.#host.outputs.values()) {
629
- if (output.outputKey !== key) {
630
- continue;
631
- }
632
- const base = this.#host.outputs.pictureOf(output);
633
- for (const consumerId of this.#host.switchingOnto(output)) {
634
- this.#reconsiderSameHeightSwitch(base, consumerId, index);
635
- }
636
- }
637
- }
638
-
639
- /**
640
- * A viewer reported on themselves. If a move is being prepared for them,
641
- * whether it is still wanted is asked again.
642
- *
643
- * WHY THIS EVENT. A publication is the only other one, and it comes only
644
- * from an output that is producing. A move waiting on an output that closes
645
- * nothing would otherwise stand for ever: a link that recovered would not
646
- * cancel it, and the quality budget, which leaves alone a viewer with a move
647
- * being prepared, would never decide for them again. The report is what
648
- * carries a change of their link, so it is where that change is judged.
649
- *
650
- * The move can also be made here, and only on the condition every other
651
- * trigger uses: the segment they will ask for next is closed on the new
652
- * output. The report changes when the question is asked, not its answer.
653
- *
654
- * @param {string} sessionId - The output the page addressed.
655
- * @param {string} consumerId
656
- * @returns {void}
657
- */
658
- noteViewerReported(sessionId, consumerId) {
659
- const move = consumerId ? this.#host.sameHeightSwitchOf(consumerId) : null;
660
- if (!move) {
661
- return;
662
- }
663
- // The picture the move belongs to, read from the output being prepared: the
664
- // page may address a picture of another film, and a move is about one.
665
- const prepared = this.#host.outputs.get(move.outputId);
666
- const named = prepared ?? (sessionId ? this.#host.outputs.get(sessionId) : null);
667
- const base = named ? this.#host.outputs.pictureOf(named) : null;
668
- if (!base) {
669
- return;
670
- }
671
- this.#reconsiderSameHeightSwitch(base, consumerId, null);
672
- }
673
-
674
- /**
675
- * Encoding an output has failed for good. Every move being prepared onto it
676
- * is asked again, which cancels it: nothing will close the segment it waits
677
- * for, and no publication will come to say so.
678
- *
679
- * Told by `EncodeRuns` at the moment the failure is recorded.
680
- *
681
- * @param {HlsSession} output
682
- * @returns {void}
683
- */
684
- noteProductionFailed(output) {
685
- if (!output) {
686
- return;
687
- }
688
- const base = this.#host.outputs.pictureOf(output);
689
- for (const consumerId of this.#host.switchingOnto(output)) {
690
- this.#reconsiderSameHeightSwitch(base, consumerId, null);
691
- }
692
- }
693
-
694
- /**
695
- * The segment this viewer will ask for next at these heights.
696
- *
697
- * Their player fetches one level's segments in order within one viewing, so
698
- * the next is one past the highest they were given in the viewing they are in
699
- * NOW. Given nothing yet in it — straight after a seek — the next is the one
700
- * where they stand. Read at the moment of asking, so a decision taken on an
701
- * event that arrived late is taken about where they are.
702
- *
703
- * @param {HlsSession} base
704
- * @param {string} consumerId
705
- * @param {number[]} askedHeights
706
- * @param {HlsSession} prepared
707
- * @returns {number}
708
- */
709
- #nextSegmentOf(base, consumerId, askedHeights, prepared) {
710
- const highest = Math.max(-1, ...askedHeights.map((height) => this.#host.highestGivenSegmentOf(consumerId, height)));
711
- if (highest >= 0) {
712
- return highest + 1;
713
- }
714
- return this.#host.outputTimes.segmentIndexForTime(prepared, this.#host.viewerPositionOf(base.id, consumerId));
715
- }
716
-
717
- /**
718
- * Make the move being prepared for this viewer, if it can be made now; cancel
719
- * it, if it should not be made at all; otherwise leave it waiting.
720
- *
721
- * @param {HlsSession} base
722
- * @param {string} consumerId
723
- * @param {number | null} publishedIndex - The segment whose closing caused
724
- * this, or null when asked for no event in particular.
725
- * @returns {void}
726
- */
727
- #reconsiderSameHeightSwitch(base, consumerId, publishedIndex) {
728
- const move = this.#host.sameHeightSwitchOf(consumerId);
729
- if (!move) {
730
- return;
731
- }
732
- const prepared = this.#host.outputs.get(move.outputId);
733
- if (!prepared || prepared.outputKey !== move.outputKey) {
734
- this.#cancelSameHeightSwitch(base, consumerId, "the output being prepared has gone");
735
- return;
736
- }
737
- const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
738
- if (current === prepared) {
739
- this.#host.noteSameHeightSwitch(consumerId, null);
740
- return;
741
- }
742
- if (this.#host.encodeRuns.hasFailed(prepared)) {
743
- this.#cancelSameHeightSwitch(base, consumerId, "encoding the output being prepared has failed");
744
- return;
745
- }
746
- if (!this.#suitsViewer(prepared, base, consumerId).suits) {
747
- this.#cancelSameHeightSwitch(base, consumerId, "the output being prepared no longer suits this viewer's link");
748
- return;
749
- }
750
- if (move.direction === "down" && this.#suitsViewer(current, base, consumerId).suits) {
751
- this.#cancelSameHeightSwitch(base, consumerId, "this viewer's link carries the output on screen again");
752
- return;
753
- }
754
- const next = this.#nextSegmentOf(base, consumerId, move.askedHeights, prepared);
755
- if (next >= prepared.timeline.segmentCount) {
756
- this.#cancelSameHeightSwitch(base, consumerId, "there is nothing left of the film to give from it");
757
- return;
758
- }
759
- // A segment closed BEHIND the one they will ask for says nothing about it:
760
- // the move would hand them an output that does not yet have what they need.
761
- if (publishedIndex !== null && publishedIndex < next) {
762
- return;
763
- }
764
- if (!this.#host.segmentClosed(prepared.outputKey ?? "", next)) {
765
- return;
766
- }
767
- // THE CUSHION, for a move that is not urgent (roadmap item 98): a move up
768
- // is made once the viewer holds the cushion this file needs, and their
769
- // next report asks again until they do. A move DOWN is made at once — it
770
- // was started because their buffer would run dry, and a cushion that is
771
- // shrinking would be waited for for ever. A file with no cushion stated has
772
- // none to wait for.
773
- if (move.direction !== "down") {
774
- const needed = this.#host.minimumBufferSecondsFor(prepared);
775
- if (Number.isFinite(needed) && needed > 0 && this.#host.bufferedSecondsOf(consumerId) < needed) {
776
- return;
777
- }
778
- }
779
- // The player fetches the fMP4 init once and decodes every later fragment
780
- // against it. Changing bitrate outputs is safe only when the prepared
781
- // output's init says the same thing to that decoder. The new segment is
782
- // closed here, so its init is available for this check. MPEG-TS has no init
783
- // and is self-contained, so it does not use this gate.
784
- if (
785
- current &&
786
- current.outputKey !== prepared.outputKey &&
787
- current.segmentFormat?.initFileName !== null &&
788
- prepared.segmentFormat?.initFileName !== null
789
- ) {
790
- const verdict = this.#host.headersCompatible(current.outputKey, prepared.outputKey);
791
- if (verdict?.compatible !== true) {
792
- const differences = verdict?.differences?.join("; ") || "the headers are missing or incompatible";
793
- this.#cancelSameHeightSwitch(base, consumerId, `the prepared output has an incompatible init: ${differences}`);
794
- return;
795
- }
796
- }
797
- this.#completeSameHeightSwitch(base, consumerId, move, prepared, current, next);
798
- }
799
-
800
- /**
801
- * The move itself.
802
- *
803
- * ONE SYNCHRONOUS STRETCH, with no `await` anywhere in it. The choice, the
804
- * output on screen and where the viewer is registered are three records of
805
- * one fact, and a suspension between them would let a request be answered by
806
- * the new choice while the viewer is still registered only on the old output,
807
- * or the plan run on the old output's map with the viewer already gone from
808
- * the choice.
809
- *
810
- * THE OUTPUT LEFT IS NOT DISPOSED. The viewer stops watching it, its priority
811
- * map loses them, and the plan stops its encoder when nobody is left on it;
812
- * the output itself goes by the ordinary idle expiry, which keeps it while an
813
- * assignment stands — a response still being sent from it, or a segment of it
814
- * given in a viewing still inside its window. A repeat of such an address is
815
- * answered by it, because `given` is read before `chosen`.
816
- *
817
- * @param {HlsSession} base
818
- * @param {string} consumerId
819
- * @param {{ askedHeights: number[], direction: string, reason: string }} move
820
- * @param {HlsSession} prepared
821
- * @param {HlsSession} current
822
- * @param {number} next
823
- * @returns {void}
824
- */
825
- #completeSameHeightSwitch(base, consumerId, move, prepared, current, next) {
826
- for (const height of move.askedHeights) {
827
- this.#choose(consumerId, height, prepared, base);
828
- }
829
- this.#host.noteStepOnScreen(base, consumerId, prepared.id);
830
- this.#host.watches(prepared, consumerId);
831
- // The picture is never left: their soundtrack and their position are
832
- // recorded on it (`#noteVariantActive`).
833
- if (current !== base) {
834
- this.#host.viewerLeaves(current, consumerId);
835
- }
836
- this.#host.noteSameHeightSwitch(consumerId, null);
837
- this.#host.planEncodersSoon();
838
- // How long this move took to be ready, and what the viewer held when it
839
- // happened — kept for the next time this host decides when to start one.
840
- if (Number.isFinite(move.since)) {
841
- this.#host.notePreparation?.(prepared, (Date.now() - move.since) / 1000, this.#host.bufferedSecondsOf(consumerId));
842
- }
843
- this.#host.logger.info(
844
- `transcode ${base.id} moved ${consumerId} ${move.direction} from ${current.outputKey} to ` +
845
- `${prepared.outputKey} from segment #${next}: ${move.reason}`
846
- );
847
- }
848
-
849
- /**
850
- * Stop preparing this viewer's move.
851
- *
852
- * They stop watching the output being prepared — unless it is the one on
853
- * their screen or the picture itself — and nothing else: whether its encoder
854
- * goes on is the plan's, from who is left on it, and the output itself goes
855
- * by the idle expiry.
856
- *
857
- * @param {HlsSession} base
858
- * @param {string} consumerId
859
- * @param {string} reason
860
- * @returns {void}
861
- */
862
- #cancelSameHeightSwitch(base, consumerId, reason) {
863
- const move = this.#host.sameHeightSwitchOf(consumerId);
864
- if (!move) {
865
- return;
866
- }
867
- this.#host.noteSameHeightSwitch(consumerId, null);
868
- const prepared = this.#host.outputs.get(move.outputId);
869
- const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
870
- if (prepared && prepared !== current && prepared !== base) {
871
- this.#host.viewerLeaves(prepared, consumerId);
872
- }
873
- // The place the move held on this machine ended with its record, so what
874
- // may run elsewhere has changed whether or not anybody left an output.
875
- this.#host.planEncodersSoon();
876
- this.#host.logger.info(`transcode ${base.id} ${consumerId}'s move to ${move.outputKey} cancelled: ${reason}`);
877
- }
878
-
879
- /**
880
- * A session of this family already making exactly this picture, if there is
881
- * one — so that a second request for it does not start a second encoder.
882
- *
883
- * WHY IT COMPARES THE HEIGHT PRODUCED. An output is named by its whole
884
- * format, and a rung produced exactly at a height can still differ from one
885
- * already running at that height by its speed setting or its encoder. For
886
- * the viewer those two are the same picture, so the one already producing it
887
- * is used rather than a second encoder beside it. It was written when a
888
- * manual pick was clamped below the height named, which put three encoders on
889
- * one identical picture on 2026-08-28
890
- * (`research/session-pileup-variant-key-2026-08-28.md`); the clamp is gone,
891
- * and choosing an existing output by quality replaces this comparison.
892
- *
893
- * @param {HlsSession} base
894
- * @param {number} askedHeight
895
- * @param {HlsSession} candidate - The session just created for `askedHeight`.
896
- * @returns {Promise<HlsSession | null>} The incumbent to use instead, or null
897
- * to keep the one just made.
898
- */
899
- /**
900
- * Let go of an output this call has finished with — unless somebody is on it.
901
- *
902
- * WHY IT IS ASKED AT THE MOMENT OF LETTING GO, and not decided in advance.
903
- * Two of these disposals reasoned "nobody is on it yet", and neither could
904
- * know it. An output is addressed by what it PRODUCES, so opening one returns
905
- * the output that already makes that picture whenever there is one: what came
906
- * back may be a step another viewer has been watching for an hour. And even a
907
- * genuinely new one is only new for as long as nothing else has reached it —
908
- * making a step takes a probe and a keyframe index, seconds in which a second
909
- * viewer can ask for the same height and be registered onto it.
910
- *
911
- * So the question is the registry's, asked now: is anybody watching this. A
912
- * step nobody is on is stopped by the plan on its next pass anyway, because
913
- * its priority map is empty; what must never happen is taking away an output
914
- * somebody is watching, which stops their encoder and empties their screen.
915
- *
916
- * @param {HlsSession} output
917
- * @param {string} because
918
- * @returns {Promise<boolean>} Whether it went.
919
- */
920
- async #letGoIfNobodyIsOn(output, because) {
921
- // NOBODY WATCHING IS NOT THE WHOLE QUESTION. A response already begun is
922
- // still being sent from this output, and a request made moments ago may
923
- // still be repeated and has to be answered by whatever answered it first.
924
- // Both stand after the last viewer has stopped being registered on it.
925
- if (this.#host.outputStillNeeded(output)) {
926
- this.#host.logger.info(
927
- `transcode ${output.id} kept although ${because}: ` +
928
- `${this.#host.viewerCountOn(output)} viewer(s) watching it, and assignments may still stand`
929
- );
930
- return false;
931
- }
932
- this.#host.logger.info(`transcode ${output.id} disposed: ${because}`);
933
- await this.#host.disposeSession(output.id);
934
- return true;
935
- }
936
-
937
- /**
938
- * The outputs of this picture that could stand in for `spec` — the same
939
- * material, so interchangeable as far as anything but picture size goes.
940
- *
941
- * ONE RULE, ASKED BY EVERY PATH THAT PICKS AN OUTPUT. Two of them used to
942
- * gather the family for themselves, and only one checked the material: a
943
- * 720p step in MPEG-TS could be found by produced height and handed to a
944
- * picture cut for fMP4, whose player cannot even ask for its segment names.
945
- * Fixing the one path left the other open inside the same operation, which
946
- * is why the rule is stated here rather than at each caller.
947
- *
948
- * The picture itself is filtered like any other member. It passes trivially
949
- * when compared against its own identity, and comparing it is what makes
950
- * this one rule instead of two.
951
- *
952
- * @param {HlsSession} base
953
- * @param {import("./output/OutputSpec.js").OutputSpec} spec - What the
954
- * candidate must be interchangeable WITH: the picture's own identity when
955
- * answering for a height, the created output's when replacing it.
956
- * @returns {HlsSession[]}
957
- */
958
- #interchangeableWith(base, spec) {
959
- return [base, ...this.#host.outputs.stepsOf(base)]
960
- .filter((other) => other && isSameMaterial(other.spec, spec));
961
- }
962
-
963
- /**
964
- * The output that answered this address for this viewer earlier in the same
965
- * generation, if it is still here and still interchangeable with the picture.
966
- *
967
- * An answer whose output has gone — disposed on a hardware encoder's failure,
968
- * the one disposal that does not ask the assignments — is no answer, and the
969
- * address is decided afresh.
970
- *
971
- * @param {HlsSession} base
972
- * @param {string} consumerId
973
- * @param {number} generation
974
- * @param {number} height
975
- * @param {number} segmentIndex - −1 for the init.
976
- * @returns {{ state: "none" } | { state: "live", output: HlsSession } | { state: "gone", key: string }}
977
- */
978
- #givenBefore(base, consumerId, generation, height, segmentIndex) {
979
- const key = this.#host.givenOutputOf(consumerId, generation, height, segmentIndex);
980
- if (!key) {
981
- return { state: "none" };
982
- }
983
- const output = this.#interchangeableWith(base, base.spec).find((other) => other.outputKey === key) ?? null;
984
- return output ? { state: "live", output } : { state: "gone", key };
985
- }
986
-
987
- /**
988
- * A repeat of an address whose output has gone.
989
- *
990
- * NEVER DECIDED AGAIN AS IF NEW. The player holds the header of the output
991
- * that answered this address; a piece of another output under the same
992
- * address may not decode under it. So, in order:
993
- *
994
- * 1. the very piece that was given, from what is stored under the gone
995
- * output's key — its pieces and header outlive it until the disk needs the
996
- * room;
997
- * 2. a piece of the output this viewer would be given now, but only when its
998
- * header is PROVEN compatible with the gone one (`init-compat.js`);
999
- * 3. otherwise the address is lost, and the page is told so: it starts a new
1000
- * viewing where the picture is, which is the one way out that does not
1001
- * serve a piece under a header it may not match. Answering "retry" here
1002
- * instead would be answered the same way for ever.
1003
- *
1004
- * @param {HlsSession} base
1005
- * @param {number} height
1006
- * @param {string} fileName
1007
- * @param {number} segmentIndex
1008
- * @param {string} consumerId
1009
- * @param {number} generation
1010
- * @param {string} goneKey
1011
- * @returns {Promise<{ sessionId: string | null, recover?: { key: string, likeId: string }, lost?: object, unavailable?: object, error?: string }>}
1012
- */
1013
- async #answerForLostAddress(base, height, fileName, segmentIndex, consumerId, generation, goneKey) {
1014
- if (this.#host.storedPieceReady(goneKey, fileName)) {
1015
- return { sessionId: null, recover: { key: goneKey, likeId: base.id } };
1016
- }
1017
- let current = null;
1018
- try {
1019
- current = await this.resolveVariantSession(base.id, height, segmentIndex, consumerId);
1020
- } catch (error) {
1021
- if (error?.code !== OUTPUT_UNAVAILABLE) {
1022
- const message = error instanceof Error ? error.message : String(error);
1023
- return { sessionId: null, error: message };
1024
- }
1025
- }
1026
- const verdict = current ? this.#host.headersCompatible(goneKey, current.outputKey ?? "") : null;
1027
- if (current && verdict?.compatible) {
1028
- this.#host.placeViewerOn(current, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
1029
- this.#host.noteGivenOutput(consumerId, generation, height, segmentIndex, current.outputKey ?? "");
1030
- this.#host.logger.info(
1031
- `transcode ${base.id} ${fileName} for ${consumerId}: ${goneKey} has gone; served by ` +
1032
- `${current.outputKey}, whose header is compatible with it`
1033
- );
1034
- return { sessionId: current.id };
1035
- }
1036
- const reason = current
1037
- ? `the output that answered it has gone, and the one that would answer now has a header not proven compatible (${(verdict?.differences ?? []).join("; ") || "no header yet"})`
1038
- : "the output that answered it has gone, and nothing that could answer now suits this viewer";
1039
- this.#host.logger.info(`transcode ${base.id} ${fileName} for ${consumerId}: assignment lost — ${reason}`);
1040
- return { sessionId: null, lost: { reason, goneKey, height, segmentIndex, generation } };
1041
- }
1042
-
1043
- async #adoptIfAlreadyProduced(base, askedHeight, candidate, consumerId) {
1044
- const produced = this.#host.outputs.producedHeightOf(candidate);
1045
- if (produced <= 0) {
1046
- return null;
1047
- }
1048
- const seen = new Set([candidate.id]);
1049
- // The base belongs in this scan: it is a rung like any other, and when it
1050
- // is itself a re-encode the clamp can land a variant right on top of it.
1051
- // Compared against the CANDIDATE, because it is the candidate's place the
1052
- // incumbent takes — the viewer asked for what the candidate is.
1053
- for (const other of this.#interchangeableWith(base, candidate.spec)) {
1054
- if (!other || seen.has(other.id)) {
1055
- continue;
1056
- }
1057
- seen.add(other.id);
1058
- if (this.#host.outputs.producedHeightOf(other) !== produced) {
1059
- continue;
1060
- }
1061
- // THE SAME PICTURE IS NOT ENOUGH: it has to suit THIS viewer by the one
1062
- // rule every path asks — their mode and a load their own link admits. Two
1063
- // outputs of one size can differ in their limit, and the one already here
1064
- // may be more than this viewer's link carries.
1065
- const verdict = this.#suitsViewer(other, base, consumerId);
1066
- if (!verdict.suits) {
1067
- continue;
1068
- }
1069
- // Same picture, already being made. Let go of the one just created — if
1070
- // it IS one, and if it is still nobody's.
1071
- await this.#letGoIfNobodyIsOn(candidate, `${other.id.slice(0, 8)} already produces this picture`);
1072
- this.#host.logger.info(
1073
- `transcode ${base.id.slice(0, 8)} the ${askedHeight}p rung encodes at ${produced}p on this ` +
1074
- `machine, which ${other.id.slice(0, 8)} is already producing — serving it from there ` +
1075
- `instead of starting a second encoder "${base.file.name}"`
1076
- );
1077
- return other;
1078
- }
1079
- return null;
1080
- }
1081
-
1082
- /**
1083
- * Resolve one file request addressed to a variant: `v/<height>/<fileName>`
1084
- * under a session.
1085
- *
1086
- * The single entry point for the variant route, so the policy — which variant
1087
- * exists, which one the viewer is watching, which encoder runs — stays here
1088
- * rather than being spread into a route handler.
1089
- *
1090
- * @param {string} baseSessionId
1091
- * @param {number} height
1092
- * @param {string} fileName
1093
- * @param {string} [consumerId] - Which viewer is asking. One picture is shared
1094
- * by everyone watching it, and the quality each of them chose is their own.
1095
- * @param {number} [statedGeneration] - Which viewing of theirs the request was
1096
- * made in, as the page stamped it; NaN when it states none.
1097
- * @returns {Promise<{ sessionId: string | null, error?: string }>} The session
1098
- * to serve the file from; a null id means there is no such variant.
1099
- */
1100
- async resolveVariantFile(baseSessionId, height, fileName, consumerId = "", statedGeneration = Number.NaN) {
1101
- if (!isOutputName(baseSessionId)) {
1102
- return { sessionId: null };
1103
- }
1104
- const base = this.#host.outputs.get(baseSessionId);
1105
- if (!base) {
1106
- return { sessionId: null };
1107
- }
1108
- // A variant carries a media playlist, an init segment and segments. Nothing
1109
- // else lives under that path — a master there would describe variants of a
1110
- // variant.
1111
- const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1112
- const isInit = base.segmentFormat.initFileName !== null &&
1113
- fileName === base.segmentFormat.initFileName;
1114
- const isSegment = base.segmentFormat.isSegmentFileName(fileName);
1115
- if (!isPlaylist && !isInit && !isSegment) {
1116
- return { sessionId: null };
1117
- }
1118
- if (!this.#host.outputs.splicableHeights(base).includes(height)) {
1119
- return { sessionId: null };
1120
- }
1121
- // Answered from the base, and no encoder is started for it. Every variant of
1122
- // a file has the SAME media playlist — same duration, same boundaries, same
1123
- // init name — because that is exactly what makes them interchangeable. The
1124
- // player fetches a level's playlist to decide with, and creating a session
1125
- // for one it may never switch to would leave a second encoder running on a
1126
- // host that has capacity for one.
1127
- if (isPlaylist) {
1128
- return { sessionId: base.id };
1129
- }
1130
- // THE ADDRESS OF THIS ANSWER: the height asked for and the segment, the
1131
- // init being segment −1. Within one generation of this viewer's viewing a
1132
- // repeat of the same address is answered by what answered it first — not
1133
- // decided again, which could now name another output than the one whose
1134
- // bytes the player already holds for it.
1135
- // A step is asked for BY somebody; see resolveVariantSession.
1136
- if (!consumerId) {
1137
- return { sessionId: null };
1138
- }
1139
- const segmentIndex = isSegment ? base.segmentFormat.segmentIndexFromName(fileName) : -1;
1140
- const generation = this.#host.generationOfRequest(consumerId, statedGeneration);
1141
- const given = this.#givenBefore(base, consumerId, generation, height, segmentIndex);
1142
- if (given.state === "live") {
1143
- this.#host.outputs.touch(given.output);
1144
- this.#host.placeViewerOn(given.output, consumerId, this.#host.viewerPositionOf(baseSessionId, consumerId));
1145
- return { sessionId: given.output.id };
1146
- }
1147
- if (given.state === "gone") {
1148
- return this.#answerForLostAddress(base, height, fileName, segmentIndex, consumerId, generation, given.key);
1149
- }
1150
- let variant;
1151
- try {
1152
- variant = await this.resolveVariantSession(
1153
- baseSessionId,
1154
- height,
1155
- segmentIndex,
1156
- // WHOSE REQUEST THIS IS. Whether an output already here may serve a
1157
- // height depends on the asking viewer's mode, and this path dropped the
1158
- // name: the request was then taken as a size picked by hand, which is
1159
- // served exactly and reuses nothing. Whichever of the two paths creates
1160
- // the step first decides for both, so an automatic viewer whose player
1161
- // fetched before the warm-up finished got a second encoder where an
1162
- // output already here would have served them.
1163
- consumerId
1164
- );
1165
- } catch (error) {
1166
- if (error?.code === OUTPUT_UNAVAILABLE) {
1167
- return { sessionId: null, unavailable: error.details };
1168
- }
1169
- const message = error instanceof Error ? error.message : String(error);
1170
- this.#host.logger.error(
1171
- `transcode ${baseSessionId} could not prepare the ${height}p variant: ${message}` +
1172
- (error instanceof Error && error.stack ? `\n${error.stack}` : "")
1173
- );
1174
- return { sessionId: null, error: message };
1175
- }
1176
- if (!variant) {
1177
- return { sessionId: null };
1178
- }
1179
- if (consumerId) {
1180
- // The same circle as a soundtrack's: the init has to be made before a
1181
- // segment can be asked for, and nothing is made for an output nobody is
1182
- // watching. Asking for any of its files is watching it.
1183
- this.#host.placeViewerOn(variant, consumerId, this.#host.viewerPositionOf(baseSessionId, consumerId));
1184
- // Recorded AFTER placing them: a viewer this request is the first sign of
1185
- // exists only from that moment, and a record kept for nobody is lost.
1186
- this.#host.noteGivenOutput(consumerId, generation, height, segmentIndex, variant.outputKey ?? "");
1187
- }
1188
- // WHICH RUNG IS ON THE SCREEN IS NOT READ OFF THIS REQUEST. The page says
1189
- // so itself (`viewerPlays`), the moment the player has switched: a request
1190
- // for a rung's segment is the player fetching, and it used to be taken as
1191
- // the person having moved.
1192
- return { sessionId: variant.id };
1193
- }
1194
-
1195
- /**
1196
- * Prepare an audio track at a position, so a change of track is instant.
1197
- *
1198
- * The player, told to change track, discards the audio it holds and cannot
1199
- * show a frame until the new track covers the playhead — so switching first
1200
- * and producing second puts the whole of the track's cold start on screen as
1201
- * a spinner. Measured 2026-08-15: the picture stopped for as long as the
1202
- * first piece took. Prepared first, the player finds the bytes already there.
1203
- *
1204
- * The same shape as {@link prepareVariant}, and for the same reason.
1205
- *
1206
- * @param {string} baseSessionId
1207
- * @param {number} trackIndex
1208
- * @param {number} positionSeconds
1209
- * @returns {Promise<{ sessionId: string, fileName: string } | null>}
1210
- */
1211
- async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds, consumerId) {
1212
- const base = this.#host.outputs.get(baseSessionId);
1213
- // A track is prepared FOR somebody; a request naming nobody prepares nothing.
1214
- if (!consumerId || !base || !this.servesAudioSeparately(base)) {
1215
- return null;
1216
- }
1217
- if (!this.#audioRenditionsOf(base).some((track) => track.trackIndex === trackIndex)) {
1218
- return null;
1219
- }
1220
- const rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
1221
- if (!rendition) {
1222
- return null;
1223
- }
1224
- // A track prepared for a change the viewer did not make would otherwise
1225
- // encode for nobody until its own idle timer noticed — the same trap
1226
- // warming a quality rung has, and the same answer. Kept per viewer, because
1227
- // one viewer's abandoned preparation must not stop a track another viewer
1228
- // is listening to.
1229
- const stillWarming = this.#host.audioBeingWarmedOf(base, consumerId);
1230
- if (stillWarming && stillWarming !== rendition.id) {
1231
- const abandoned = this.#host.outputs.get(stillWarming);
1232
- const wanted = this.#liveAudioRenditionKeys(base);
1233
- const wantedIds = new Set(
1234
- this.#host.outputs.renditionsOf(base)
1235
- .filter((other) => wanted.has(audioRenditionKey(
1236
- this.#flatAudioTrackOf(other),
1237
- other.spec.transcodesAudio
1238
- )))
1239
- .map((other) => other.id)
1240
- );
1241
- if (abandoned && !wantedIds.has(abandoned.id)) {
1242
- // They are not listening to it, so they stop watching it. Its encoder
1243
- // follows from that and is not commanded here: nobody left on an output
1244
- // is a map with nothing in it, and the plan stops what is on it.
1245
- this.#host.viewerLeaves(abandoned, consumerId);
1246
- }
1247
- }
1248
- this.#host.noteAudioBeingWarmed(base, consumerId, rendition.id);
1249
- // Being prepared for them is watching it: it is made for this viewer, and
1250
- // when they leave it must be let go with everything else of theirs.
1251
- // Where the switch will land. An existing track was left wherever the
1252
- // viewer last was on it; saying where they are now is the whole of pointing
1253
- // it there, because the encoder follows the person and not the request.
1254
- this.#host.placeOn(rendition, consumerId, positionSeconds);
1255
- this.#host.planEncodersSoon();
1256
- const index = this.#host.outputTimes.segmentIndexForTime(rendition, positionSeconds);
1257
- return { sessionId: rendition.id, fileName: rendition.segmentFormat.segmentFileName(index) };
1258
- }
1259
-
1260
- async prepareVariant(baseSessionId, height, positionSeconds, consumerId) {
1261
- // A step is warmed FOR somebody; a request naming nobody warms nothing.
1262
- if (!consumerId || !isOutputName(baseSessionId)) {
1263
- return null;
1264
- }
1265
- const base = this.#host.outputs.get(baseSessionId);
1266
- if (!base) {
1267
- return null;
1268
- }
1269
- if (!this.#host.qualityOffer.offeredHeightsFor(base).includes(height)) {
1270
- return null;
1271
- }
1272
- const index = this.#host.outputTimes.segmentIndexForTime(base, positionSeconds);
1273
- const variant = await this.resolveVariantSession(baseSessionId, height, index, consumerId);
1274
- if (!variant) {
1275
- return null;
1276
- }
1277
- // A rung warmed for a switch that was never made. Nothing else would ever
1278
- // stop it: only becoming active stops the rung being left, so a viewer
1279
- // trying two rungs in a row would leave the first encoding for nobody until
1280
- // the look-ahead cap suspended it — three encoders at once on a host sized
1281
- // for one, which is the opposite of what warming is for.
1282
- // Kept per viewer, and stopped only if nobody has it on screen: with two
1283
- // viewers, what one of them abandons may be what the other is watching.
1284
- const stillWarming = this.#host.stepBeingWarmedOf(base, consumerId);
1285
- if (stillWarming && stillWarming !== variant.id) {
1286
- const abandoned = this.#host.outputs.get(stillWarming);
1287
- if (abandoned && !this.#host.quality.variantsOnScreen(base).has(abandoned.id)) {
1288
- this.#host.viewerLeaves(abandoned, consumerId);
1289
- }
1290
- }
1291
- // The base is not a rung being prepared for anybody — it is what the family
1292
- // is named by — so warming its own height leaves nothing outstanding.
1293
- if (variant.id === base.id) {
1294
- this.#host.noteStepBeingWarmed(base, consumerId, null);
1295
- } else {
1296
- const onScreen = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
1297
- if (variant.id !== onScreen.id) {
1298
- // A PLACE ON THIS MACHINE (roadmap item 97, step 13). The step warmed
1299
- // before this one stops holding its place first — the viewer asked for
1300
- // another rung, so it is no longer theirs to wait for — and then the
1301
- // place is asked for, in the same synchronous stretch as the record is
1302
- // written. Refused, nothing is recorded: the rung on screen goes on
1303
- // playing and the page is told why.
1304
- this.#host.noteStepBeingWarmed(base, consumerId, null);
1305
- const place = this.#host.admitsPreparation(variant);
1306
- if (!place.admitted) {
1307
- this.#host.logger.info(
1308
- `transcode ${base.id} warming ${height}p for ${consumerId} refused a place: ${place.reason}`
1309
- );
1310
- const error = new Error(`No place on this machine for ${height}p: ${place.reason}.`);
1311
- error.code = OUTPUT_UNAVAILABLE;
1312
- error.details = {
1313
- reason: `this machine cannot encode ${height}p beside what it already encodes`,
1314
- figures: { machineSpeedX: place.speedX, height }
1315
- };
1316
- throw error;
1317
- }
1318
- }
1319
- this.#host.noteStepBeingWarmed(base, consumerId, variant.id);
1320
- }
1321
- // An existing rung may be parked wherever it was left, so it is pointed at
1322
- // the switch position exactly as an activation would — the difference is
1323
- // only that the rung on screen keeps its own encoder meanwhile.
1324
- this.#host.outputs.touch(variant);
1325
- // Anything that is not the rung on screen has to be pointed at the switch
1326
- // position — INCLUDING the base. Skipping it because it is the base was a
1327
- // defect: the base is parked wherever it was when the viewer left it, and
1328
- // its encoder was stopped then. Measured 2026-08-12, warming 400p at
1329
- // 6506.5s found the base still at `run from #0`, so the segment the switch
1330
- // needed was never produced and the viewer got nothing at all.
1331
- // A rung that is not on their screen is parked where they last left it, so
1332
- // being warmed begins with saying where they are. Their being ON it is what
1333
- // buys it an encoder, and both halves are said here: a warmed rung is one
1334
- // this person is watching for as long as the warm-up lasts, which is why
1335
- // two encoders run through it.
1336
- if (variant.id !== this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }).id) {
1337
- this.#host.placeOn(variant, consumerId, this.#host.outputTimes.segmentStartTime(base, index));
1338
- this.#host.planEncodersSoon();
1339
- }
1340
- this.#host.logger.info(
1341
- `transcode ${base.id} warming ${height}p at ${positionSeconds.toFixed(1)}s (segment #${index})`
1342
- );
1343
- return { sessionId: variant.id, fileName: variant.segmentFormat.segmentFileName(index) };
1344
- }
1345
-
1346
- /**
1347
- * The rung this viewer's player is playing, as the page states it.
1348
- *
1349
- * The page says so when the player has switched (`LEVEL_SWITCHED`), with the
1350
- * height of that rung and where the picture is. A rung this proxy has not
1351
- * made is not one the player can be playing, so a height with no output
1352
- * behind it changes nothing.
1353
- *
1354
- * @param {string} baseSessionId - The output the browser addresses.
1355
- * @param {string} consumerId
1356
- * @param {number} height
1357
- * @param {number} [positionSeconds] - Where the picture is, when stated.
1358
- * @returns {boolean} Whether a rung was found for that height.
1359
- */
1360
- viewerPlays(baseSessionId, consumerId, height, positionSeconds) {
1361
- const named = this.#host.outputs.get(baseSessionId);
1362
- if (!consumerId || !named || !(height > 0)) {
1363
- return false;
1364
- }
1365
- const base = this.#host.outputs.pictureOf(named);
1366
- // THE CHOICE ALREADY MADE for this viewer at that height, read and never
1367
- // made here: the page names a height, and a height does not say which of
1368
- // two limits the viewer was given.
1369
- const rung = this.servingOutputFor(base, height, consumerId);
1370
- if (!rung) {
1371
- return false;
1372
- }
1373
- this.#noteVariantActive(base, rung, positionSeconds, consumerId);
1374
- return true;
1375
- }
1376
-
1377
- /**
1378
- * Record which variant the viewer is watching, and give it the encoder.
1379
- *
1380
- * The person is put on the new rung where their picture is, because a
1381
- * segment request steers no encoder and a rung that was watched a minute ago
1382
- * is parked wherever it was left.
1383
- *
1384
- * @param {HlsSession} base
1385
- * @param {HlsSession} variant
1386
- * @param {number} [positionSeconds] - Where the picture is, as the page
1387
- * stated it with the switch.
1388
- * @param {string} [consumerId] - Which viewer moved.
1389
- * @returns {void}
1390
- */
1391
- #noteVariantActive(base, variant, positionSeconds, consumerId) {
1392
- // Which step is on a screen is a fact about a PERSON: a request that names
1393
- // nobody, or names the family itself, says nothing about any screen.
1394
- if (!consumerId) {
1395
- return;
1396
- }
1397
- const previous = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
1398
- if (previous.id === variant.id) {
1399
- // The rung on screen asking for more of itself, which it does every few
1400
- // seconds. Nothing is being decided here — and deciding anything was the
1401
- // defect: the warm-up was cancelled by the next segment the CURRENT rung
1402
- // fetched, measured 2026-08-12 at 117 ms and 1.5 s after two warm-ups
1403
- // began, so the rung being prepared was stopped before it had encoded
1404
- // anything and the viewer waited out the full thirty-second warm-up for a
1405
- // segment nobody was making, then waited again for the switch itself.
1406
- return;
1407
- }
1408
- // Their player has moved to another rung, so a move between limits of the
1409
- // one they were on is for a height they have left.
1410
- this.#cancelSameHeightSwitch(base, consumerId, "the player moved to another rung");
1411
- // A rung is being left, so whatever was warmed is decided: either it is the
1412
- // rung now being switched to, or the viewer went somewhere else and it must
1413
- // stop like any other rung nobody is watching. Nothing else would ever stop
1414
- // it — only the rung being LEFT is stopped below.
1415
- const warmed = this.#host.stepBeingWarmedOf(base, consumerId);
1416
- const warmedSince = this.#host.stepBeingWarmedSinceOf?.(base, consumerId) ?? null;
1417
- this.#host.noteStepBeingWarmed(base, consumerId, null);
1418
- // The step warmed for this viewer is the one they moved onto: how long it
1419
- // took, and what they held when they moved.
1420
- if (warmed === variant.id && Number.isFinite(warmedSince)) {
1421
- this.#host.notePreparation?.(variant, (Date.now() - warmedSince) / 1000, this.#host.bufferedSecondsOf(consumerId));
1422
- }
1423
- if (warmed && warmed !== variant.id && warmed !== previous.id) {
1424
- const abandoned = this.#host.outputs.get(warmed);
1425
- if (abandoned && !this.#host.quality.variantsOnScreen(base).has(abandoned.id)) {
1426
- this.#host.viewerLeaves(abandoned, consumerId);
1427
- }
1428
- }
1429
- const position = this.#variantStartSeconds(base, positionSeconds, consumerId);
1430
- this.#host.noteStepOnScreen(base, consumerId, variant.id);
1431
- // The step is an output of this viewer's now.
1432
- this.#host.watches(variant, consumerId);
1433
- // And the one they came off is not — unless it is the picture itself, which
1434
- // they never stop watching: the browser addresses the picture, their chosen
1435
- // soundtrack is recorded on it, and the plan reads their position from it.
1436
- // Leaving it deleted their whole record, so a viewer who went down a step,
1437
- // back to the picture's own height and down again lost the soundtrack they
1438
- // had chosen, and the encoder making it was stopped as unwanted.
1439
- if (previous !== base && previous !== variant) {
1440
- this.#host.viewerLeaves(previous, consumerId);
1441
- }
1442
- this.#host.logger.info(
1443
- `transcode ${base.id} variant now ${this.#host.outputs.variantHeightOf(variant)}p ` +
1444
- `(was ${this.#host.outputs.variantHeightOf(previous)}p) at ${position.toFixed(1)}s` +
1445
- (consumerId ? ` for ${consumerId}` : "")
1446
- );
1447
- // Requests still held on the rung they came off are for segments nobody
1448
- // will produce now, and the player stopped waiting for them the moment it
1449
- // switched. Answering "retry" at once frees them instead of holding each for
1450
- // the full minute.
1451
- //
1452
- // WHETHER ITS ENCODER GOES ON IS NOT DECIDED HERE. It used to be stopped
1453
- // from this line whenever no viewer had it on screen — the plan, handed the
1454
- // whole film's priority map, wanted an encoder on every output of it and
1455
- // started one again on the very next pass, which this viewer's own move had
1456
- // just triggered. Each output is handed its own map now, so a rung nobody is
1457
- // on has nothing in it and the plan stops what is on it, once.
1458
- if (!this.#host.quality.variantsOnScreen(base).has(previous.id)) {
1459
- this.#host.invalidateWaits(previous);
1460
- }
1461
- if (position > 0) {
1462
- // The rung being switched TO, named literally: a warm-up may have left
1463
- // the family pointing elsewhere, and forwarding would move that one
1464
- // instead. Saying where this person is on it is the whole of pointing its
1465
- // encoder there.
1466
- this.#host.placeOn(variant, consumerId, position);
1467
- }
1468
- this.#host.planEncodersSoon();
1469
- }
1470
-
1471
- /**
1472
- * The master playlist: every resolution this file can be served at, as HLS
1473
- * variants.
1474
- *
1475
- * This is what makes a change of quality seamless. Our media playlist is VOD
1476
- * and terminated with `#EXT-X-ENDLIST`, and hls.js only re-reads a playlist
1477
- * that is live — so rewriting it underneath the player achieves nothing, and
1478
- * a switch had to tear the player down and build a new session. Offered as
1479
- * variants instead, the switch is the player's own: it fetches the other
1480
- * variant, appends it after what is already buffered, and changes the
1481
- * decoder's type if the codec parameters differ.
1482
- *
1483
- * Offered only where the variants can actually be joined, which is a question
1484
- * about the CUT GRID and not about who produces the frames:
1485
- *
1486
- * - a re-encoded session on the uniform grid — its variants are re-encoded on
1487
- * the same one, keyframes forced onto it;
1488
- * - a session cut at the source's own keyframes — a copy, which has no other
1489
- * choice — where the variants are re-encoded and forced onto those very
1490
- * times, so a rung's segment covers the same span as the copy's.
1491
- *
1492
- * What is refused is a session whose own grid is a fiction: a copy with no
1493
- * readable keyframe index falls back to an even grid that ffmpeg then does
1494
- * not cut on, and nothing can be aligned to that.
1495
- *
1496
- * @param {string} sessionId
1497
- * @returns {string | null} The playlist text, or null when there is nothing
1498
- * to choose between, or nothing to align to.
1499
- */
1500
- buildMasterPlaylist(sessionId, consumerId = "") {
1501
- if (!isOutputName(sessionId)) {
1502
- return null;
1503
- }
1504
- const session = this.#host.outputs.get(sessionId);
1505
- if (!session) {
1506
- return null;
1507
- }
1508
- if (!this.#host.outputs.publishesVariants(session)) {
1509
- return null;
1510
- }
1511
- // The audio tracks, published once for the whole file rather than muxed
1512
- // into every rung. Two things follow from that: the same track is not
1513
- // encoded once per rung on a host that struggles to encode it once, and
1514
- // changing track becomes the player switching rendition instead of this
1515
- // proxy rebuilding the session with another `audioTrackIndex`.
1516
- //
1517
- // Only for a session that asked for them. A browser that does not know
1518
- // about renditions is served audio in its stream, as before, and gets no
1519
- // `#EXT-X-MEDIA` lines to be confused by.
1520
- const renditions = this.servesAudioSeparately(session)
1521
- // Which track is marked DEFAULT is the ASKING viewer's business: one
1522
- // picture is shared by everyone watching it, and each of them may have
1523
- // chosen a different language. A default written from the session's own
1524
- // field would start the second viewer in the first viewer's language.
1525
- ? this.#audioRenditionsOf(session, this.#audioChoiceOf(session, consumerId).trackIndex)
1526
- : [];
1527
- return masterPlaylistText({
1528
- // The shape of the film and the rates it carries, asked of the layer that
1529
- // holds both. What CAN be spliced, not what is worth offering this second:
1530
- // the live judgement travels in `offeredHeights` and in every progress
1531
- // report, and letting it decide the master's existence made a live session
1532
- // answer 404 to its own published address.
1533
- ...this.#host.outputs.masterFactsOf(session),
1534
- renditions,
1535
- playlistFileName: PLAYLIST_FILE_NAME
1536
- });
1537
- }
1538
-
1539
- /**
1540
- * Whether this session's audio is published separately rather than muxed into
1541
- * its picture.
1542
- *
1543
- * Two things have to hold, and the second is why this is asked here rather
1544
- * than settled when the session was made. The browser must understand
1545
- * renditions — it says so when it creates the session, and one that does not
1546
- * has to be sent audio in the stream. AND there must be a master playlist to
1547
- * publish them in: a stream served as a single media playlist has nowhere to
1548
- * carry an `#EXT-X-MEDIA` line, so taking the audio out of it would leave the
1549
- * viewer with a picture and silence.
1550
- *
1551
- * @param {HlsSession} session
1552
- * @returns {boolean}
1553
- */
1554
- servesAudioSeparately(session) {
1555
- return session.spec.carries !== "audio-only" && session.spec.carriesAudioSeparately;
1556
- }
1557
-
1558
- inputOf(session) {
1559
- return encoderInputs({
1560
- picture: session.file,
1561
- soundtrack: session.spec.audio
1562
- ? this.#host.sourceFiles.get(session.file.sourceKey, session.spec.audioFileIndex)
1563
- : session.file,
1564
- carries: session.spec.carries,
1565
- audioSeparate: this.servesAudioSeparately(session),
1566
- sessionId: session.id,
1567
- readWindowBytes: this.#host.encodeRuns.readWindowFor(session),
1568
- baseUrl: this.#host.localBaseUrl
1569
- });
1570
- }
1571
-
1572
- /**
1573
- * One file of an audio rendition: its playlist, its init segment or one of
1574
- * its segments.
1575
- *
1576
- * A rendition is an ordinary session underneath — same source, same file,
1577
- * same cut grid, one audio track and no picture — created on the first
1578
- * request for it, exactly as a quality variant is. What differs is that the
1579
- * player fetches it ALONGSIDE a variant rather than instead of one, so both
1580
- * encoders run: a rung and the audio it is played with.
1581
- *
1582
- * @param {string} baseSessionId
1583
- * @param {number} trackIndex
1584
- * @param {string} fileName
1585
- * @param {string} [consumerId] - Who is asking. One picture serves everyone
1586
- * watching it, and which soundtrack they are listening to is theirs alone.
1587
- * @returns {Promise<{ sessionId: string | null, error?: string }>}
1588
- */
1589
- async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName, consumerId = "") {
1590
- if (!isOutputName(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
1591
- return { sessionId: null };
1592
- }
1593
- const base = this.#host.outputs.get(baseSessionId);
1594
- if (!base || !this.servesAudioSeparately(base)) {
1595
- return { sessionId: null };
1596
- }
1597
- const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1598
- const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
1599
- const isSegment = base.segmentFormat.isSegmentFileName(fileName);
1600
- if (!isPlaylist && !isInit && !isSegment) {
1601
- return { sessionId: null };
1602
- }
1603
- if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
1604
- return { sessionId: null };
1605
- }
1606
- // The playlist is answered from the base, for the same reason a variant's
1607
- // is: every rendition of a file has the same boundaries and the same
1608
- // duration — they are cut on one grid — and the player fetches the playlist
1609
- // of tracks it may never select. Starting an encoder for each would put as
1610
- // many encoders on the host as the file has languages.
1611
- if (isPlaylist) {
1612
- return { sessionId: base.id };
1613
- }
1614
- let rendition;
1615
- try {
1616
- rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
1617
- } catch (error) {
1618
- const message = error instanceof Error ? error.message : String(error);
1619
- this.#host.logger.error(
1620
- `transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
1621
- (error instanceof Error && error.stack ? `\n${error.stack}` : "")
1622
- );
1623
- return { sessionId: null, error: message };
1624
- }
1625
- if (rendition && consumerId) {
1626
- // Asking for ANY file of this soundtrack is this viewer watching it, and
1627
- // the init is the file they ask for first. Registered here rather than on
1628
- // the segment alone, because the segment cannot be asked for until the
1629
- // init has been served, and the init cannot be made unless somebody is
1630
- // watching: that circle is what left a soundtrack with no encoder, no
1631
- // init and a viewer waiting sixty seconds on 2026-09-05.
1632
- //
1633
- // WHERE they are on it is where they are on the picture: the two are
1634
- // played together.
1635
- this.#host.placeViewerOn(rendition, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
1636
- }
1637
- if (isSegment && rendition) {
1638
- this.#noteAudioTrackActive(base, trackIndex, consumerId);
1639
- }
1640
- return { sessionId: rendition?.id ?? null };
1641
- }
1642
-
1643
- /**
1644
- * A SEGMENT of this track is what says the viewer is listening to it — the
1645
- * player fetches the playlist and the init of tracks it may never choose.
1646
- *
1647
- * Every other track is then stopped. Each one is an ffmpeg process AND a
1648
- * reader holding pieces of the torrent in memory, and the store can only
1649
- * spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
1650
- * track once had three readers on one file — picture, the track they chose
1651
- * and the track they left — and at a seek all three revived their windows at
1652
- * once, every resident piece was pinned, a read ended with zero bytes, and
1653
- * every encoder took that for the end of the file and died. Playback was over
1654
- * for good; the sessions answered 500 to everything after that.
1655
- *
1656
- * Stopped, not disposed: the track keeps its place, its grid and its
1657
- * position, so switching back does not build it again — the same treatment a
1658
- * quality rung gets when the viewer moves off it.
1659
- *
1660
- * "Every other track" is every track NO LIVE VIEWER is listening to, which
1661
- * with one viewer is what it always was. It has to be asked that way now that
1662
- * two viewers share one picture: each of them fetches the sound they chose,
1663
- * and stopping "the others" per request would have them switch each other's
1664
- * soundtrack off in turn, once per segment, for the whole film.
1665
- *
1666
- * @param {HlsSession} base
1667
- * @param {number} trackIndex
1668
- * @param {string} consumerId - Who is listening. A request that names
1669
- * nobody changes nobody's track.
1670
- */
1671
- #noteAudioTrackActive(base, trackIndex, consumerId) {
1672
- if (!consumerId) {
1673
- return;
1674
- }
1675
- const previous = this.#audioChoiceOf(base, consumerId);
1676
- if (previous.trackIndex === trackIndex) {
1677
- return;
1678
- }
1679
- this.#host.chooseAudioTrack(base, consumerId, { ...previous, trackIndex });
1680
- // Every soundtrack nobody present is listening to any more stops being
1681
- // waited on.
1682
- const wanted = this.#liveAudioRenditionKeys(base);
1683
- for (const other of this.#host.outputs.renditionsOf(base)) {
1684
- if (wanted.has(audioRenditionKey(this.#flatAudioTrackOf(other), other.spec.transcodesAudio))) {
1685
- continue;
1686
- }
1687
- // Requests held on it are for segments nobody will produce now, and the
1688
- // player stopped waiting for them the moment it changed track.
1689
- if (this.#host.encodeRuns.liveRunsOf(other).length > 0) {
1690
- this.#host.invalidateWaits(other);
1691
- }
1692
- // Nobody is listening to it any more: this viewer stops watching that
1693
- // output, on both sides of the relation, and the claim their listening
1694
- // placed on it is released with them. Its encoder follows from that —
1695
- // an output with nobody on it has a map with nothing in it — and was
1696
- // additionally stopped from here, which is the same decision taken twice
1697
- // by two parties with two rules.
1698
- this.#host.viewerLeaves(other, consumerId);
1699
- }
1700
- }
1701
-
1702
- /**
1703
- * The viewers this family has heard from recently enough to still be watching.
1704
- *
1705
- * Asked of the whole family, not of one session: a viewer on a quality step
1706
- * asks that step for its segments, so the picture they started on has not
1707
- * heard from them since they switched. Their head expires by the same rule the
1708
- * encoder's own steering uses.
1709
- *
1710
- * It is what decides whether an encoder is still wanted, and it is needed
1711
- * because nothing releases a session when a channel closes (roadmap item 54)
1712
- * — without it a viewer whose tab is gone would hold a soundtrack or a rung
1713
- * for the session's whole life.
1714
- *
1715
- * @param {HlsSession} base
1716
- * @returns {Set<string>}
1717
- */
1718
- liveConsumers(base) {
1719
- const live = new Set();
1720
- for (const member of this.#host.outputs.familyOf(base)) {
1721
- for (const consumerId of this.#host.presentOn(member)) {
1722
- live.add(consumerId);
1723
- }
1724
- }
1725
- return live;
1726
- }
1727
-
1728
- /**
1729
- * What one viewer wants of the sound: which soundtrack, and whether their
1730
- * browser needs it re-encoded.
1731
- *
1732
- * @param {HlsSession} base
1733
- * @param {string} consumerId
1734
- * @returns {{ trackIndex: number, transcode: boolean }}
1735
- */
1736
- #audioChoiceOf(base, consumerId) {
1737
- const stated = this.#host.audioChoiceOf(base, consumerId);
1738
- if (stated) {
1739
- return stated;
1740
- }
1741
- // A viewer this session has not heard from by name. The session's own
1742
- // parameters are the honest fallback: they are what the request that
1743
- // created it asked for.
1744
- return {
1745
- trackIndex: this.#flatAudioTrackOf(base),
1746
- transcode: base.spec.transcodesAudio
1747
- };
1748
- }
1749
-
1750
- /**
1751
- * The renditions live viewers are listening to, as the keys they are filed
1752
- * under.
1753
- *
1754
- * A viewer counts while their head is fresh on the picture — the same
1755
- * expiry the encoder's own steering uses. Without that test a viewer whose
1756
- * tab was closed without releasing the session (roadmap item 54) would hold
1757
- * an encoder for the session's whole life.
1758
- *
1759
- * @param {HlsSession} base
1760
- * @returns {Set<string>}
1761
- */
1762
- #liveAudioRenditionKeys(base) {
1763
- const wanted = new Set();
1764
- const live = this.liveConsumers(base);
1765
- for (const consumerId of this.#host.consumersOn(base)) {
1766
- const choice = this.#audioChoiceOf(base, consumerId);
1767
- // A viewer counts while some session of the family has heard from them.
1768
- if (live.size > 0 && !live.has(consumerId)) {
1769
- continue;
1770
- }
1771
- wanted.add(audioRenditionKey(choice.trackIndex, choice.transcode));
1772
- }
1773
- return wanted;
1774
- }
1775
-
1776
- /**
1777
- * The session producing one audio track of this file, made on first request.
1778
- *
1779
- * Filed under the track AND how it has to be produced, because those are two
1780
- * different encodes: a browser that can decode this track as it stands is
1781
- * served a copy, and one that cannot is served AAC. The base cannot answer
1782
- * for either of them now that it is shared — its own `transcodeAudio` is
1783
- * whatever the first viewer's browser needed.
1784
- *
1785
- * @param {HlsSession} base
1786
- * @param {number} trackIndex
1787
- * @param {string} consumerId - Who is asking.
1788
- * @returns {Promise<HlsSession | null>}
1789
- */
1790
- async #resolveAudioRenditionSession(base, trackIndex, consumerId = "") {
1791
- const transcodeAudio = this.#audioChoiceOf(base, consumerId).transcode;
1792
- // Found by what it IS: a soundtrack of this file, this track, produced this
1793
- // way. That is what a map from a rendition key to a session id said, at the
1794
- // price of a link between two sessions' lifetimes — one that had to be
1795
- // cleaned from the other side when either ended.
1796
- const already = this.#host.outputs.renditionsOf(base).find(
1797
- (other) =>
1798
- this.#flatAudioTrackOf(other) === trackIndex &&
1799
- other.spec.transcodesAudio === transcodeAudio
1800
- );
1801
- if (already) {
1802
- this.#host.outputs.touch(already);
1803
- return already;
1804
- }
1805
- const rendition = await this.#host.createOrGetSession({
1806
- sourceKey: base.file.sourceKey,
1807
- fileIndex: base.file.fileIndex,
1808
- // No picture at all: the video flag says what to do with a video stream
1809
- // this output does not carry.
1810
- transcodeVideo: false,
1811
- transcodeAudio,
1812
- fileName: base.file.name,
1813
- // Opened by nobody in particular, as a step is.
1814
- consumerId: "",
1815
- audioTrackIndex: trackIndex,
1816
- audioOnly: true,
1817
- // Where the viewer is, so the rendition starts with the picture rather
1818
- // than at the beginning of the file. Read the same way a quality variant
1819
- // reads it: the base's own field is only written by a seek or by a
1820
- // segment IT served, so on a resume-from-position open it is still unset
1821
- // while the player is asking for segment #537 — and the audio would begin
1822
- // at zero and never catch up, since nothing treats a far request as a
1823
- // seek. The accessor falls back to the last segment actually requested.
1824
- // Where the PICTURE is, not where it has been read to.
1825
- //
1826
- // The position this class keeps is written by the segments a session
1827
- // serves, so it is the READ head, and the viewer's picture sits behind it
1828
- // by everything the player has buffered. Started at the read head, the
1829
- // audio run begins AHEAD of the viewer, and every request they then make
1830
- // is behind a run that only moves forward — field 2026-08-15, placed at
1831
- // #16 while the player asked for #10, and the audio arrived only after
1832
- // the encoder was dragged back.
1833
- //
1834
- // The distance is measured, not assumed: the browser reports how many
1835
- // seconds it holds ahead of the picture with every link report, so the
1836
- // playhead is one subtraction away. A stale report is no use — a viewer
1837
- // who seeked since then is somewhere else entirely — so an old one is
1838
- // ignored and the whole look-ahead is subtracted instead, which cannot
1839
- // leave the run ahead of them.
1840
- startPositionSeconds: this.#host.audioStartSecondsFor({
1841
- family: this.#host.outputs.familyOf(base),
1842
- openedAtSeconds: this.#host.encodeRuns.progressOf(base)?.startPositionSeconds,
1843
- segmentSeconds: this.#host.segmentDurationSec
1844
- }),
1845
- segmentFormatId: base.segmentFormat.id,
1846
- // Cut where the picture is cut. Two streams meant to be played together
1847
- // have to be divided at the same times, and the grid is the base's — the
1848
- // table as it stands now, corrections included. A base on the uniform
1849
- // grid passes nothing: the rendition computes the same even grid from the
1850
- // same duration.
1851
- inheritedGrid: base.timeline.cutGrid === "keyframe"
1852
- ? {
1853
- boundaries: base.timeline.boundaries,
1854
- published: base.timeline.published
1855
- }
1856
- : null
1857
- });
1858
- return rendition ?? null;
1859
- }
1860
-
1861
- /**
1862
- * Whether a session created with these parameters publishes its sound as its
1863
- * own stream rather than muxing it into the picture.
1864
- *
1865
- * Asked before the session exists, because the session's KEY depends on the
1866
- * answer: an output that carries no sound must not be told apart by which
1867
- * soundtrack was asked for, and an output that carries it must be.
1868
- *
1869
- * Three conditions, all of them facts about the request and the file rather
1870
- * than about the machine's load, so the answer cannot move afterwards:
1871
- *
1872
- * 1. the browser said it understands rendition groups. One that did not must
1873
- * be sent its sound inside the picture, or it gets silence;
1874
- * 2. the file has soundtracks to publish;
1875
- * 3. there is more than one height to move between, because renditions are
1876
- * published in a master playlist and a stream served as a single media
1877
- * playlist has nowhere to carry an `#EXT-X-MEDIA` line.
1878
- *
1879
- * Condition 3 is the reason a source too small for a ladder mixes its sound
1880
- * in as it always did. It is asked of the same ladder the master's rung list
1881
- * comes from; the realtime budget may still encode below the height named
1882
- * here, and cannot change the count, because what it picks is a rung of that
1883
- * same ladder.
1884
- *
1885
- * @param {{ sourceKey: string, fileIndex: number, audioRenditions: boolean, ownHeight: number }} params
1886
- * @returns {boolean}
1887
- */
1888
- audioTravelsSeparately({ sourceKey, fileIndex, audioRenditions, ownHeight }) {
1889
- if (audioRenditions !== true) {
1890
- return false;
1891
- }
1892
- const tracks = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
1893
- if (!Array.isArray(tracks) || tracks.length === 0) {
1894
- return false;
1895
- }
1896
- // The source's own height, from the probe the playback plan already ran.
1897
- // Absent, this answers "mix it in" — which is what the later computation
1898
- // answered too, since a session with no source height has an empty ladder.
1899
- const sourceHeight = Math.round(Number(this.#host.getCachedMediaInfo?.({ sourceKey, fileIndex })?.height) || 0);
1900
- const heights = new Set(variantHeightsFor(sourceHeight));
1901
- if (Number.isInteger(ownHeight) && ownHeight > 0) {
1902
- heights.add(ownHeight);
1903
- }
1904
- return heights.size >= 2;
1905
- }
1906
-
1907
- /**
1908
- * Where one numbered soundtrack actually is: which file of the torrent, and
1909
- * which `0:a:N` inside it.
1910
- *
1911
- * The number is flat across the picture's own tracks and every soundtrack
1912
- * shipped as a file beside it, so that the browser's menu, the
1913
- * `audioTrackIndex` on a session request and the `a/<n>/` path a rendition is
1914
- * published at all mean the same thing. This resolves it, once, from the
1915
- * inventory the playback plan built — the very list the menu was drawn from,
1916
- * so the two cannot disagree about what a number means.
1917
- *
1918
- * A number the inventory does not describe resolves to the picture's own file
1919
- * at that index, which is exactly what every session did before soundtracks in
1920
- * their own files existed: a plan cached by an older build carries no
1921
- * inventory, and a session created against it must keep working.
1922
- *
1923
- * @param {string} sourceKey
1924
- * @param {number} fileIndex - The PICTURE's file.
1925
- * @param {number} flatIndex
1926
- * @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string }}
1927
- */
1928
- resolveAudioSource(sourceKey, fileIndex, flatIndex) {
1929
- const inventory = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
1930
- const entry = Array.isArray(inventory)
1931
- ? inventory.find((candidate) => candidate?.index === flatIndex)
1932
- : null;
1933
- if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
1934
- return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "" };
1935
- }
1936
- return {
1937
- fileIndex: entry.fileIndex,
1938
- sourceTrackIndex: entry.sourceTrackIndex,
1939
- isSidecar: entry.fileIndex !== fileIndex,
1940
- name: typeof entry.fileName === "string" ? entry.fileName : "",
1941
- // The rate the file states for this track, or null; what a viewer's link
1942
- // is asked to carry for it when it is copied.
1943
- bitrateKbps: Number.isFinite(entry.bitrateKbps) ? entry.bitrateKbps : null
1944
- };
1945
- }
1946
-
1947
- /**
1948
- * The browser's flat soundtrack number for the audio carried by this output.
1949
- *
1950
- * `OutputSpec` keeps the stable source address: file plus `0:a:N`. The flat
1951
- * number belongs to the browser menu and is reconstructed from that menu's
1952
- * inventory when a route needs it. It is therefore not kept as a duplicate
1953
- * field on the output.
1954
- *
1955
- * @param {HlsSession} session
1956
- * @returns {number}
1957
- */
1958
- #flatAudioTrackOf(session) {
1959
- const audio = session.spec.audio;
1960
- if (!audio) {
1961
- return 0;
1962
- }
1963
- const tracks = this.#host.getCachedAudioTracks?.({
1964
- sourceKey: session.file.sourceKey,
1965
- fileIndex: session.file.fileIndex
1966
- }) ?? [];
1967
- const matching = Array.isArray(tracks)
1968
- ? tracks.find((entry) => entry?.fileIndex === audio.fileIndex && entry?.sourceTrackIndex === audio.trackIndex)
1969
- : null;
1970
- return Number.isInteger(matching?.index) ? matching.index : audio.trackIndex;
1971
- }
1972
-
1973
- /**
1974
- * Start reading where a soundtrack file's own timeline begins, if nobody has.
1975
- *
1976
- * Read by the container layer from the file's own header — the same 64 KB,
1977
- * the same reader and the same per-file cache the audio menu's track list
1978
- * comes from. A container states this, so it is read from the container and
1979
- * not measured from the media.
1980
- *
1981
- * The answer goes onto the FILE, which is where it belongs and which is what
1982
- * removed the pair of maps this used to keep beside it: a start time held per
1983
- * `sourceKey:fileIndex` is a fact of that file, and a second store of facts
1984
- * about files is a second thing that can disagree. Every session of the
1985
- * soundtrack shares the one object, so a reading that lands after a session
1986
- * has started is seen by that session too — which is what the spawn path
1987
- * needed and used to re-read a map for.
1988
- *
1989
- * Until 2.73.0 the session spawned an ffmpeg against the proxy's own
1990
- * `/stream` for it and waited up to eight seconds for the banner. Field
1991
- * 2026-09-03: that read cost 8121 ms of a cold start, three times out of
1992
- * three, while the container layer had read the same header of the same file
1993
- * in 8 ms in the same second. The eight seconds were not even spent on the
1994
- * answer — the early exit was gated on a DURATION, and a partly downloaded
1995
- * file prints `Duration: N/A` with the start time on that very line.
1996
- *
1997
- * Runs behind whoever asked, so no viewer waits for it. Once per file per
1998
- * process: a container's start time is a property of the file and cannot
1999
- * change. A reading that comes back without an answer is NOT remembered — the
2000
- * file may simply not have been downloaded far enough yet, and the next
2001
- * session asks again.
2002
- *
2003
- * @param {SourceFile} file - The soundtrack's own file.
2004
- * @returns {void}
2005
- */
2006
- warmFileStartTime(file) {
2007
- if (typeof this.#host.getContainerMediaInfo !== "function") {
2008
- return;
2009
- }
2010
- if (!(this.#host.fileStartTimeReads instanceof Set)) {
2011
- this.#host.fileStartTimeReads = new Set();
2012
- }
2013
- if (file.media?.startTime !== undefined || this.#host.fileStartTimeReads.has(file.key)) {
2014
- return;
2015
- }
2016
- this.#host.fileStartTimeReads.add(file.key);
2017
- void Promise.resolve(
2018
- this.#host.getContainerMediaInfo({ sourceKey: file.sourceKey, fileIndex: file.fileIndex })
2019
- )
2020
- .then((info) => {
2021
- if (info && Number.isFinite(info.startTimeSeconds)) {
2022
- file.learn({ startTime: info.startTimeSeconds });
2023
- this.#host.logger.info(
2024
- `transcode: soundtrack file ${file.fileIndex}'s own timeline starts at ` +
2025
- `${info.startTimeSeconds.toFixed(6)}s, read from its header`
2026
- );
2027
- }
2028
- })
2029
- .catch((error) => {
2030
- this.#host.logger.info(
2031
- `transcode: the start of soundtrack file ${file.fileIndex}'s timeline could not be read ` +
2032
- `(${error instanceof Error ? error.message : String(error)}) — the two timelines are ` +
2033
- "taken to agree until it can be"
2034
- );
2035
- })
2036
- .finally(() => {
2037
- this.#host.fileStartTimeReads.delete(file.key);
2038
- });
2039
- }
2040
-
2041
- /**
2042
- * The audio tracks of this session's file, as renditions for the master.
2043
- *
2044
- * Taken from the inventory the playback plan already probed — the same list
2045
- * the browser's audio menu is built from — so nothing is probed again here.
2046
- * The track the session was created with is the default one: it is what the
2047
- * viewer chose (or the file's first track), and a master that defaulted to
2048
- * something else would change the language on its own.
2049
- *
2050
- * @param {HlsSession} session
2051
- * @param {number} [chosenTrack] - The track to mark as the default one.
2052
- * Defaults to the session's own, which is what a caller that is only
2053
- * counting the renditions wants.
2054
- * @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
2055
- */
2056
- #audioRenditionsOf(session, chosenTrack) {
2057
- const tracks = this.#host.getCachedAudioTracks?.({
2058
- sourceKey: session.file.sourceKey,
2059
- // The PICTURE's file, which is what `file` is on every session of a
2060
- // family — a rendition is created with its base's, and only its
2061
- // `audioFile` points at the file its sound comes from. The inventory is
2062
- // keyed on the picture and spans the soundtracks beside it.
2063
- fileIndex: session.file.fileIndex
2064
- }) ?? [];
2065
- if (!Array.isArray(tracks) || tracks.length === 0) {
2066
- return [];
2067
- }
2068
- const chosen = Number.isInteger(chosenTrack) ? chosenTrack : this.#flatAudioTrackOf(session);
2069
- // One line per entry of the inventory, in its order and without omissions —
2070
- // including a track the container marks unusable. The player addresses a
2071
- // rendition by its POSITION in this list, and the browser addresses it by
2072
- // the number the inventory gave it; leaving anything out would make those two
2073
- // disagree from that point on. A track the file says not to offer is kept out
2074
- // of the VIEWER's menu, which is the browser's own business and does not
2075
- // touch the numbering.
2076
- return tracks.map((entry, order) => {
2077
- const index = Number.isInteger(entry?.index) ? entry.index : order;
2078
- const language = typeof entry?.languageBcp47 === "string" && entry.languageBcp47.length > 0
2079
- ? entry.languageBcp47
2080
- : (typeof entry?.language === "string" ? entry.language : "");
2081
- return {
2082
- trackIndex: index,
2083
- name: this.#host.audioRenditionName(
2084
- { ...entry, index, folders: Array.isArray(entry?.folders) ? entry.folders : [] },
2085
- tracks
2086
- ),
2087
- // Only what the container itself states. What a folder name suggests
2088
- // about a language is derived in the browser, where the language table
2089
- // and the viewer's own locale already are; writing a guess into
2090
- // `LANGUAGE` would put it in a playlist as though the file had said it.
2091
- language,
2092
- isDefault: index === chosen
2093
- };
2094
- });
2095
- }
2096
-
2097
- /**
2098
- * Where a person stands when they come onto a rung, in seconds.
2099
- *
2100
- * A stated position when there is one — the picture's, sent with the switch,
2101
- * or the start of the segment the player asked a new rung for. Otherwise the
2102
- * position they have stated on the rung they are on. Never the rung's READ
2103
- * head, which sits a whole buffer further on.
2104
- *
2105
- * @param {HlsSession} base
2106
- * @param {number} [positionSeconds] - A stated position, when there is one.
2107
- * @param {string} [consumerId]
2108
- * @returns {number}
2109
- */
2110
- #variantStartSeconds(base, positionSeconds, consumerId = "") {
2111
- if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
2112
- return positionSeconds;
2113
- }
2114
- return this.#host.viewerSecondsOn(this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }));
2115
- }
2116
-
2117
- /**
2118
- * The track set this session's output will carry — what the proxy DECLARES,
2119
- * and the one answer both sides must agree on.
2120
- *
2121
- * The source may hold any number of tracks: several dubs, subtitles, even a
2122
- * cover-art video stream. The output does not inherit that list — the command
2123
- * maps at most one video and at most one audio, each optional, and subtitles
2124
- * never enter the HLS output at all (they are served separately as WebVTT).
2125
- * So this is not an inference about the file; it is the proxy stating what it
2126
- * chose to produce.
2127
- *
2128
- * Used in two places, and that is the point: the init segment is checked
2129
- * against it here, and it is sent to the browser so the browser can check
2130
- * what it actually received against the same statement. Without the second
2131
- * check a missing track is only noticed by its absence, minutes later, as a
2132
- * black picture with working sound.
2133
- *
2134
- * @param {HlsSession} session
2135
- * @returns {{ video: boolean, audio: boolean }}
2136
- */
2137
- declaredTracks(session) {
2138
- const probed = this.#host.getCachedMediaInfo?.({
2139
- sourceKey: session.file.sourceKey,
2140
- fileIndex: session.file.fileIndex
2141
- }) ?? null;
2142
- // What the SOURCE has, narrowed to what this session's output carries. A
2143
- // rendition maps only audio and a stream whose audio travels separately
2144
- // maps only video, so answering from the source alone would tell the
2145
- // browser about a track that is not in the stream, and would leave
2146
- // `#initFromFirstSegment` waiting for a second track that no init will ever
2147
- // declare — its warning about a short header would then fire on every one.
2148
- const carriesVideo = session.spec.carries !== "audio-only";
2149
- const carriesAudio = !this.servesAudioSeparately(session);
2150
- // The soundtrack of this session may not be in the file that was probed. A
2151
- // release that ships its dub as a separate file often ships the picture with
2152
- // no sound of its own at all, and then the picture's probe says there is no
2153
- // audio while the output plainly carries some — which would leave the header
2154
- // check expecting one track where two arrive, and tell the browser its sound
2155
- // was lost.
2156
- const audioFromAnotherFile = session.spec.audioFileIndex !== session.file.fileIndex;
2157
- return {
2158
- video: carriesVideo && Boolean(probed?.videoCodec),
2159
- audio: carriesAudio && (audioFromAnotherFile || Boolean(probed?.audioCodec))
2160
- };
2161
- }
2162
- }
1
+ /**
2
+ * The steps of a picture and its soundtracks.
3
+ *
4
+ * A step is an output of the same picture at another size, a soundtrack an
5
+ * output of one audio track; each is made the first time it is asked for, and an
6
+ * output already producing the same thing answers instead. The master playlist
7
+ * lists them. Which viewer is on which is the viewer's fact, stated here and
8
+ * read back through the host.
9
+ */
10
+
11
+ import { isOutputName, OUTPUT_UNAVAILABLE } from "./output/index.js";
12
+ import { masterPlaylistText, PLAYLIST_FILE_NAME } from "./output/playlists.js";
13
+ import { variantHeightsFor } from "./output/ladder.js";
14
+ import { isSameMaterial, outputSuits } from "./quality/serving-output.js";
15
+ import { audioLoadOf, linkAnswerFigures, linkCouldCarry, loadOf, videoLoadOfLimit, videoLoadOfSpec } from "./quality/link-budget.js";
16
+ import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor } from "./args.js";
17
+ import { encoderInputs } from "./run-inputs.js";
18
+ /**
19
+ * How a base files the audio renditions it has made.
20
+ *
21
+ * By the track AND by how it is produced, because those are two different
22
+ * encodes of it: a browser that can decode the track as it stands is served a
23
+ * copy, and one that cannot is served AAC. Two viewers of one picture can
24
+ * legitimately need both.
25
+ *
26
+ * @param {number} trackIndex
27
+ * @param {boolean} transcode
28
+ * @returns {string}
29
+ */
30
+ export function audioRenditionKey(trackIndex, transcode) {
31
+ return `${Number(trackIndex) || 0}:${transcode === true ? "aac" : "copy"}`;
32
+ }
33
+
34
+ export class Renditions {
35
+ #variantPending = new WeakMap();
36
+ /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
37
+ #host;
38
+
39
+ /**
40
+ * @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `segmentClosed`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
41
+ */
42
+ constructor(host) {
43
+ this.#host = host;
44
+ }
45
+
46
+ /**
47
+ * The session that produces a given height for the same file, created on
48
+ * first request.
49
+ *
50
+ * A variant IS a session — same source, same file, a different encode — so
51
+ * this makes one rather than inventing a parallel object. It is created only
52
+ * when its playlist is actually asked for, which is what keeps a weak host
53
+ * running one encoder: with the player's own bitrate adaptation off, no
54
+ * variant is ever requested unless the viewer picked it.
55
+ *
56
+ * @param {string} baseSessionId
57
+ * @param {number} height - Encode height; must be one of the offered rungs.
58
+ * @returns {Promise<HlsSession | null>} Null when the base session is unknown,
59
+ * or the height is not offered for it.
60
+ */
61
+ /**
62
+ * The output chosen for THIS viewer at this height, if it is still here.
63
+ *
64
+ * THE VIEWER'S OWN CHOICE, and nothing else (roadmap item 97, step 11). It
65
+ * used to be one record per file and height, read by everybody watching the
66
+ * file, and then a step NAMED after the height: the first viewer's answer
67
+ * became every viewer's, whatever their link or their mode. Now the choice is
68
+ * recorded on the viewer, and only the suitability rule ever makes one.
69
+ *
70
+ * A choice whose output has gone is no answer; the height is decided again
71
+ * by the same rule, never by looking for the first output of that height.
72
+ * The recorded key is looked up among the outputs of THIS picture's material,
73
+ * so a step of another container can never be the answer.
74
+ *
75
+ * @param {HlsSession} base
76
+ * @param {number} height
77
+ * @param {string} consumerId
78
+ * @returns {HlsSession | null}
79
+ */
80
+ servingOutputFor(base, height, consumerId) {
81
+ if (!base || !Number.isInteger(height) || height <= 0 || !consumerId) {
82
+ return null;
83
+ }
84
+ const key = this.#host.chosenOutputOf(consumerId, height);
85
+ if (!key) {
86
+ return null;
87
+ }
88
+ return this.#interchangeableWith(base, base.spec).find((other) => other.outputKey === key) ?? null;
89
+ }
90
+
91
+ /**
92
+ * The rule has chosen `output` for this viewer at this height: record the
93
+ * choice, and on what it was judged, for their progress report.
94
+ *
95
+ * @param {string} consumerId
96
+ * @param {number} height
97
+ * @param {HlsSession} output
98
+ * @param {HlsSession} base
99
+ * @returns {void}
100
+ */
101
+ #choose(consumerId, height, output, base) {
102
+ this.#host.chooseOutput(consumerId, height, output.outputKey ?? "");
103
+ const answer = this.#suitsViewer(output, base, consumerId).answer;
104
+ this.#host.noteServingVerdict(
105
+ consumerId,
106
+ answer ? { ...linkAnswerFigures(answer), outputKey: output.outputKey ?? "", height } : null
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Whether an output may serve this viewer, by the one rule every path asks
112
+ * (`serving-output.js`, `outputSuits`): the picture's material, the size a
113
+ * viewer who picked by hand requires, and a whole load their own link admits.
114
+ *
115
+ * @param {HlsSession} output
116
+ * @param {HlsSession} base
117
+ * @param {string} consumerId
118
+ * @param {{ width: number, height: number } | null} [wanted]
119
+ * @returns {{ suits: boolean, answer: object | null, reason: string }}
120
+ */
121
+ #suitsViewer(output, base, consumerId, wanted = null) {
122
+ const linkMbps = this.#host.linkMbpsOf(base, consumerId);
123
+ const audioLoad = audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
124
+ const encode = output.spec?.video?.encode ?? null;
125
+ return outputSuits({
126
+ spec: output.spec,
127
+ pictureSpec: base.spec,
128
+ mode: this.#host.qualityModeOf(base, consumerId),
129
+ size: encode
130
+ ? { width: encode.width, height: encode.height }
131
+ : { width: Number(base.file.width) || 0, height: Number(base.file.height) || 0 },
132
+ wanted,
133
+ judge: (spec) => linkCouldCarry(
134
+ linkMbps,
135
+ loadOf(videoLoadOfSpec(spec, base.file.decode?.megabitsPerSecond ?? null, this.#host.observedPeakMbps?.(spec) ?? null), audioLoad)
136
+ )
137
+ });
138
+ }
139
+
140
+ /**
141
+ * The soundtrack part of the load this viewer's link carries.
142
+ *
143
+ * @param {HlsSession} base
144
+ * @param {string} consumerId
145
+ * @returns {import("./quality/link-budget.js").LoadPart | null}
146
+ */
147
+ viewerAudioLoadOf(base, consumerId) {
148
+ return audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
149
+ }
150
+
151
+ /**
152
+ * The soundtrack THIS viewer receives with the picture, as the link sees it:
153
+ * whether it is re-encoded, and the rate the file states for it.
154
+ *
155
+ * Their own choice where they have made one, else the picture's own track.
156
+ * Counted whether it travels inside the picture or as a stream of its own:
157
+ * it crosses the same link either way.
158
+ *
159
+ * @param {HlsSession} base
160
+ * @param {string} consumerId
161
+ * @returns {{ transcode: boolean, bitrateKbps: number | null }}
162
+ */
163
+ #viewerAudioOf(base, consumerId) {
164
+ const choice = this.#host.audioChoiceOf(base, consumerId) ??
165
+ { trackIndex: this.#flatAudioTrackOf(base), transcode: base.spec.transcodesAudio };
166
+ const inventory = this.#host.getCachedAudioTracks?.({
167
+ sourceKey: base.file.sourceKey,
168
+ fileIndex: base.file.fileIndex
169
+ }) ?? [];
170
+ const entry = Array.isArray(inventory) ? inventory.find((one) => one?.index === choice.trackIndex) : null;
171
+ return {
172
+ transcode: choice.transcode === true,
173
+ bitrateKbps: Number.isFinite(entry?.bitrateKbps) ? entry.bitrateKbps : null
174
+ };
175
+ }
176
+
177
+ async resolveVariantSession(baseSessionId, height, wantedIndex = -1, consumerId = "") {
178
+ if (!isOutputName(baseSessionId)) {
179
+ return null;
180
+ }
181
+ const base = this.#host.outputs.get(baseSessionId);
182
+ if (!base) {
183
+ return null;
184
+ }
185
+ if (!Number.isInteger(height) || height <= 0) {
186
+ return null;
187
+ }
188
+ // Only the heights the master offers. Anything else is a made-up request,
189
+ // and honouring it would let a client start encoder runs at will. The
190
+ // MASTER's list, not the live one: a rung is published for the session's
191
+ // whole life, and refusing what we published is how a quality switch became
192
+ // a 404 storm across every level.
193
+ if (!this.#host.outputs.splicableHeights(base).includes(height)) {
194
+ return null;
195
+ }
196
+ // A height is chosen FOR somebody. A request naming nobody has no link, no
197
+ // mode and no soundtrack to be judged by, so it is refused rather than
198
+ // answered by whatever somebody else was given.
199
+ if (!consumerId) {
200
+ return null;
201
+ }
202
+ const existing = this.servingOutputFor(base, height, consumerId);
203
+ if (existing) {
204
+ this.#host.outputs.touch(existing);
205
+ return existing;
206
+ }
207
+ // hls.js asks for a new level's playlist, its init and its first segments
208
+ // within the same moment. Without this every one of them would build its
209
+ // own session, and the ones that lost would encode for nobody. Per VIEWER:
210
+ // two viewers of one height may be given two different outputs.
211
+ //
212
+ // A PREPARATION HOLDS THE DECISION IT WAS MADE ON: the link reading and the
213
+ // mode at its start. A request of theirs arriving meanwhile joins it and is
214
+ // told on what it was decided; a newer reading is weighed by the next
215
+ // decision, once this one has ended.
216
+ const pendingByHeight = this.#variantPending.get(base) ?? new Map();
217
+ this.#variantPending.set(base, pendingByHeight);
218
+ const pendingKey = `${height}:${consumerId}`;
219
+ const pending = pendingByHeight.get(pendingKey);
220
+ if (pending) {
221
+ this.#host.logger.info(
222
+ `transcode ${base.id} ${height}p for ${consumerId} joins the preparation decided on ` +
223
+ `link=${pending.linkMbps ?? "unmeasured"} mode=${pending.mode}`
224
+ );
225
+ return pending.creation;
226
+ }
227
+ const mode = this.#host.qualityModeOf(base, consumerId);
228
+ const linkMbps = this.#host.linkMbpsOf(base, consumerId);
229
+ if (height === this.#host.outputs.variantHeightOf(base)) {
230
+ // The picture's own height is answered by the picture only when it suits
231
+ // this viewer, by the same rule as any other output. A picture that does
232
+ // not is not handed over for being the one addressed.
233
+ const verdict = this.#suitsViewer(base, base, consumerId);
234
+ if (verdict.suits) {
235
+ this.#choose(consumerId, height, base, base);
236
+ return base;
237
+ }
238
+ this.#host.logger.info(
239
+ `transcode ${base.id} the picture's own ${height}p does not suit ${consumerId} (${verdict.reason}); deciding again`
240
+ );
241
+ }
242
+ const creation = this.#host.createOrGetSession(this.#stepOpening(base, {
243
+ height,
244
+ // Where this variant must begin. The segment the player asked it for when
245
+ // it can be known — that is the player stating outright where it will
246
+ // start fetching, and it is the only figure that cannot be stale.
247
+ //
248
+ // The other rung's read head is NOT that figure, and using it cost a
249
+ // stuck session on 2026-08-11: a 240p rung encoding at 5-6x had read 56 s
250
+ // further than the picture had played, so switching back to 400p placed
251
+ // that run at 3084 s while the player needed 3028 s, and no segment it
252
+ // wanted was ever produced.
253
+ startSeconds: this.#variantStartSeconds(
254
+ base,
255
+ Number.isInteger(wantedIndex) && wantedIndex >= 0 ? this.#host.outputTimes.segmentStartTime(base, wantedIndex) : undefined,
256
+ consumerId
257
+ ),
258
+ mode,
259
+ linkMbps,
260
+ consumerId,
261
+ capKbps: null
262
+ }))
263
+ .then(async (variant) => {
264
+ // Making a session takes seconds — a probe and a keyframe index — and
265
+ // the viewer can leave inside that window. A variant registered onto a
266
+ // disposed base is reachable by nobody: the browser never learns its
267
+ // id, so nothing would release it and it would hold an encoder, a temp
268
+ // directory and a claim on the torrent until its own idle timer noticed
269
+ // half an hour later.
270
+ if (!this.#host.encodeRuns.isLive(base)) {
271
+ await this.#letGoIfNobodyIsOn(
272
+ variant,
273
+ `the picture it was made for ended while it was being made`
274
+ );
275
+ return null;
276
+ }
277
+ // Served by the picture itself, which does not become a step. RECORDED
278
+ // like any other answer: a path that answers without recording leaves
279
+ // the next request to decide the same question again, and the four
280
+ // addressings of one step then name different outputs.
281
+ if (variant === base) {
282
+ this.#choose(consumerId, height, base, base);
283
+ return base;
284
+ }
285
+ const incumbent = await this.#adoptIfAlreadyProduced(base, height, variant, consumerId);
286
+ if (incumbent) {
287
+ this.#choose(consumerId, height, incumbent, base);
288
+ return incumbent;
289
+ }
290
+ // WHATEVER IT TURNED OUT TO BE is the answer for the height asked for.
291
+ // It used to be recorded only where the produced height equalled the
292
+ // one asked for — which is the case that needs no record at all: a step
293
+ // produced at another size is NAMED after that size, so nothing could
294
+ // find it by the height it answers, and every later request decided
295
+ // again.
296
+ const produced = this.#host.outputs.producedHeightOf(variant);
297
+ variant.variantHeight ??= produced > 0 ? produced : height;
298
+ // How it came to be: a step of a picture, not a picture a browser
299
+ // opened. Read where a step needs the facts of the file rather than of
300
+ // its own encode.
301
+ this.#host.outputs.markStep(variant);
302
+ this.#choose(consumerId, height, variant, base);
303
+ return variant;
304
+ })
305
+ .finally(() => {
306
+ pendingByHeight.delete(pendingKey);
307
+ });
308
+ pendingByHeight.set(pendingKey, { creation, linkMbps, mode, startedAt: Date.now() });
309
+ return creation;
310
+ }
311
+
312
+ /**
313
+ * What opening a step of this picture asks of `OutputOpening`, for one
314
+ * viewer, at one height and, when named, one bitrate limit.
315
+ *
316
+ * One place for it, because two paths open steps — a height asked for, and a
317
+ * move to another limit of the height on screen — and the two must agree
318
+ * about everything but the size and the limit: the cuts, where the sound
319
+ * travels, the container. A step that differed from its picture in any of
320
+ * those would not be a step of it.
321
+ *
322
+ * @param {HlsSession} base
323
+ * @param {{ height: number, startSeconds: number, mode: "auto" | "manual", linkMbps: number | null, consumerId: string, capKbps: number | null }} params
324
+ * @returns {object}
325
+ */
326
+ #stepOpening(base, { height, startSeconds, mode, linkMbps, consumerId, capKbps }) {
327
+ return {
328
+ sourceKey: base.file.sourceKey,
329
+ fileIndex: base.file.fileIndex,
330
+ transcodeVideo: true,
331
+ transcodeAudio: base.spec.transcodesAudio,
332
+ fileName: base.file.name,
333
+ // Opened by nobody in particular: the viewer who wants this step is
334
+ // registered on it by whoever asked for it.
335
+ consumerId: "",
336
+ targetWidth: 0,
337
+ targetHeight: height,
338
+ // Floored onto the ten-second grid that session keys are bucketed to:
339
+ // rounding is what that bucket does, and a position rounded UP starts the
340
+ // run past the viewer, so the run just spawned is killed and restarted
341
+ // before it has produced anything.
342
+ startPositionSeconds: Math.floor(startSeconds / 10) * 10,
343
+ audioTrackIndex: this.#flatAudioTrackOf(base),
344
+ // A rung is produced at exactly the size it names and the realtime budget
345
+ // does not move it — otherwise two rungs could drift onto the same height
346
+ // and the choice between them would mean nothing. True of EVERY rung,
347
+ // including one the player moved itself onto.
348
+ exactSize: true,
349
+ // Whose request this is decides whether an output already here may serve
350
+ // it: a size picked by hand is served exactly, the automatic choice by the
351
+ // quality rules. A viewer whose page does not say is taken as picking.
352
+ servingMode: mode,
353
+ viewerLinkMbps: linkMbps,
354
+ // A limit named outright, or null for the rule to choose one. Named, it
355
+ // is answered with that limit or not at all (`output-format.js`).
356
+ capKbps,
357
+ // The soundtrack THIS viewer hears, which is part of what their link
358
+ // has to carry and need not be the one the picture was opened with.
359
+ viewerAudio: this.#viewerAudioOf(base, consumerId),
360
+ // A rung of a session whose audio is published separately carries no
361
+ // audio either — every rung of one master must agree about that, or
362
+ // switching rung would start or stop a second copy of the same track.
363
+ audioRenditions: this.servesAudioSeparately(base),
364
+ // Not re-decided here: asked on its own, a variant would answer about the
365
+ // rungs IT would be offered at — a 540p rung of a copied 1080p source is
366
+ // offered nothing but itself, so it would conclude "audio muxed" and
367
+ // start carrying a second copy of a track the player is already fetching
368
+ // from the rendition.
369
+ inheritedAudioSeparate: this.servesAudioSeparately(base),
370
+ segmentFormatId: base.segmentFormat?.id ?? "",
371
+ // Cut where the base is cut. Only for a base on the source's own keyframe
372
+ // grid — a copy — where the variant has to land on those exact times to
373
+ // be interchangeable with it. A base on the uniform grid needs nothing
374
+ // passed: the variant computes the same even grid from the same duration.
375
+ inheritedGrid: base.timeline.cutGrid === "keyframe"
376
+ ? {
377
+ // The table as it stands NOW, corrections included — not the index
378
+ // it was first built from. This is what the new session CUTS at.
379
+ boundaries: base.timeline.boundaries,
380
+ // And this is what it must SAY, which is not the same thing: every
381
+ // member of a family has to publish one timeline, or two sessions
382
+ // stamp the same moment differently and the picture and the sound
383
+ // drift apart by exactly the corrections made between their two
384
+ // creations (field 2026-08-17, corrections of 0.6-2.9 s).
385
+ published: base.timeline.published
386
+ }
387
+ : null
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Begin moving this viewer onto another output of the height on their
393
+ * screen, at another bitrate limit (roadmap item 97, step 12).
394
+ *
395
+ * THE ADDRESS DOES NOT CHANGE. Their player goes on asking `v/<height>/…`;
396
+ * what changes is which output this side answers that address with, and it
397
+ * changes only once the segment they will ask for next is closed on the new
398
+ * output (`noteSegmentPublished`). Until then they are given what they were
399
+ * given, so a move that never completes costs them nothing.
400
+ *
401
+ * WHAT IS MOVED BETWEEN: outputs of the same material and the same produced
402
+ * size whose bounds are two neighbouring limits of `limitsFor(frame)` — the
403
+ * row of the frame on screen, chosen by its area, the same row the output was
404
+ * opened in (`output-format.js`). Down,
405
+ * the highest lower limit their link admits; up, the next limit only, and
406
+ * only if their link admits it. A copy has no limit and a hardware encode is
407
+ * given none, so neither has anywhere to move to here, and the caller's other
408
+ * lever — another height — is what remains.
409
+ *
410
+ * The output prepared may be one another viewer is already watching at that
411
+ * limit: it is found by its key, and opening an output that exists returns it.
412
+ *
413
+ * @param {HlsSession} named - The output the browser addresses.
414
+ * @param {string} consumerId
415
+ * @param {"down" | "up"} direction
416
+ * @param {string} reasonText - Why, for the log.
417
+ * @returns {Promise<{ started: boolean, reason: string, noPlace?: boolean }>}
418
+ * `noPlace` when the refusal is the machine's: it holds no more encoders.
419
+ */
420
+ async prepareSameHeightSwitch(named, consumerId, direction, reasonText) {
421
+ const base = named ? this.#host.outputs.pictureOf(named) : null;
422
+ const refuse = (reason) => ({ started: false, reason });
423
+ if (!consumerId || !base) {
424
+ return refuse("nobody named, or no picture");
425
+ }
426
+ // Only a master's variants go through the choice: without one, the player
427
+ // addresses a single output by name and there is nothing to answer with
428
+ // anything else.
429
+ if (!this.#host.outputs.publishesVariants(base)) {
430
+ return refuse("this stream publishes no variants");
431
+ }
432
+ if (this.#host.sameHeightSwitchOf(consumerId)) {
433
+ return refuse("a move is already being prepared for this viewer");
434
+ }
435
+ const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
436
+ const encode = current?.spec?.video?.encode ?? null;
437
+ if (!encode || !encode.rateControl) {
438
+ return refuse("the output on screen has no limit to change (a copy, or a hardware encode)");
439
+ }
440
+ const askedHeights = this.#host.heightsChosenAs(consumerId, current.outputKey ?? "");
441
+ if (askedHeights.length === 0) {
442
+ return refuse("no height is chosen as the output on screen");
443
+ }
444
+ const limits = this.#host.limitsFor({ width: encode.width, height: encode.height });
445
+ const at = limits.findIndex((limit) => maxrateKbpsFor(limit) === encode.rateControl.maxrateKbps);
446
+ if (at < 0) {
447
+ return refuse(`the limit on screen (maxrate ${encode.rateControl.maxrateKbps}k) is not one of ${encode.height}p's`);
448
+ }
449
+ const linkMbps = this.#host.linkMbpsOf(base, consumerId);
450
+ const audioLoad = this.viewerAudioLoadOf(base, consumerId);
451
+ const admits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad)).admitted;
452
+ const target = direction === "down"
453
+ ? limits.slice(at + 1).find(admits)
454
+ : (at > 0 && admits(limits[at - 1]) ? limits[at - 1] : undefined);
455
+ if (target === undefined) {
456
+ return refuse(direction === "down"
457
+ ? `no lower limit of ${encode.height}p is admitted by this viewer's link`
458
+ : `no higher limit of ${encode.height}p is admitted by this viewer's link, or ${encode.height}p is at its highest`);
459
+ }
460
+ let prepared;
461
+ try {
462
+ prepared = await this.#host.createOrGetSession(this.#stepOpening(base, {
463
+ height: encode.height,
464
+ startSeconds: this.#host.viewerPositionOf(base.id, consumerId),
465
+ // Exactly the size on screen: a move between limits never changes it,
466
+ // and an automatic answer could.
467
+ mode: "manual",
468
+ linkMbps,
469
+ consumerId,
470
+ capKbps: target
471
+ }));
472
+ } catch (error) {
473
+ if (error?.code === OUTPUT_UNAVAILABLE) {
474
+ return refuse(`the ${target}kbps output of ${encode.height}p does not suit this viewer: ${error.details?.reason ?? ""}`);
475
+ }
476
+ throw error;
477
+ }
478
+ // The picture and the viewer may both have moved while it was being made.
479
+ if (!this.#host.encodeRuns.isLive(base) || !prepared) {
480
+ return refuse("the picture ended while the output was being made");
481
+ }
482
+ const preparedEncode = prepared.spec?.video?.encode ?? null;
483
+ if (
484
+ prepared === current ||
485
+ preparedEncode?.height !== encode.height ||
486
+ preparedEncode?.width !== encode.width ||
487
+ preparedEncode?.rateControl?.maxrateKbps !== maxrateKbpsFor(target) ||
488
+ !isSameMaterial(prepared.spec, base.spec)
489
+ ) {
490
+ return refuse(`what was opened (${prepared.outputKey}) is not ${encode.height}p at ${target}kbps of this picture`);
491
+ }
492
+ // An output whose encoding has failed for good will close no segment, so a
493
+ // move onto it would wait for ever — and, cancelled on that failure, be
494
+ // prepared again on the next budget pass, once per cooldown.
495
+ if (this.#host.encodeRuns.hasFailed(prepared)) {
496
+ return refuse(`encoding ${prepared.outputKey} has failed`);
497
+ }
498
+ if (this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }) !== current ||
499
+ this.#host.sameHeightSwitchOf(consumerId)) {
500
+ return refuse("the output on this viewer's screen changed while the move was being prepared");
501
+ }
502
+ // A PLACE ON THIS MACHINE, asked before anything is recorded and in the same
503
+ // synchronous stretch as the record (roadmap item 97, step 13). Two moves
504
+ // onto two different outputs cannot then both be told there is room for
505
+ // one: the second is asked after the first is written, and is counted.
506
+ // Refused, nothing is recorded, and the caller goes to its other lever.
507
+ const place = this.#host.admitsPreparation(prepared);
508
+ if (!place.admitted) {
509
+ await this.#letGoIfNobodyIsOn(prepared, `a move onto it was refused a place: ${place.reason}`);
510
+ // Said as its own kind of refusal: "no limit to move to" sends the budget
511
+ // to another height, and "no place on the machine" must not send it UP,
512
+ // where the encoder would cost more still.
513
+ return { ...refuse(`the machine has no place for ${prepared.outputKey}: ${place.reason}`), noPlace: true };
514
+ }
515
+ // A STEP OF THIS PICTURE, as any step made for a height is: without it the
516
+ // output is a picture of its own, `pictureOf` answers with it rather than
517
+ // with the picture, and the choice about to point at it is looked up among
518
+ // this picture's steps and not found.
519
+ if (prepared !== base) {
520
+ prepared.variantHeight ??= encode.height;
521
+ this.#host.outputs.markStep(prepared);
522
+ }
523
+ this.#host.noteSameHeightSwitch(consumerId, {
524
+ askedHeights,
525
+ outputId: prepared.id,
526
+ outputKey: prepared.outputKey ?? "",
527
+ direction,
528
+ reason: reasonText,
529
+ since: Date.now()
530
+ });
531
+ // On it for as long as the move is prepared: that is what buys it an
532
+ // encoder, from where they stand. `watches` and not `placeOn`: a viewer is
533
+ // one record whose position is already known, and placing them again would
534
+ // restate it and clear the cushion they reported with it.
535
+ this.#host.watches(prepared, consumerId);
536
+ this.#host.planEncodersSoon();
537
+ this.#host.logger.info(
538
+ `transcode ${base.id} preparing ${consumerId}'s move ${direction} from ${current.outputKey} to ` +
539
+ `${prepared.outputKey} (${encode.height}p, ${target}kbps): ${reasonText}`
540
+ );
541
+ // It may already be made there — another viewer on it, or material kept.
542
+ this.#reconsiderSameHeightSwitch(base, consumerId, null);
543
+ const active = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
544
+ if (active === prepared || this.#host.sameHeightSwitchOf(consumerId)) {
545
+ return { started: true, reason: "" };
546
+ }
547
+ return refuse("the prepared output was not usable for this viewer");
548
+ }
549
+
550
+ /**
551
+ * Whether a move to another limit is being prepared for this viewer.
552
+ *
553
+ * @param {string} consumerId
554
+ * @returns {boolean}
555
+ */
556
+ sameHeightSwitchPending(consumerId) {
557
+ return Boolean(this.#host.sameHeightSwitchOf(consumerId));
558
+ }
559
+
560
+ /**
561
+ * Which way the move being prepared for this viewer goes, or null.
562
+ *
563
+ * @param {string} consumerId
564
+ * @returns {"down" | "up" | null}
565
+ */
566
+ sameHeightSwitchDirection(consumerId) {
567
+ return this.#host.sameHeightSwitchOf(consumerId)?.direction ?? null;
568
+ }
569
+
570
+ /**
571
+ * Stop preparing this viewer's move, for a reason the quality budget found:
572
+ * the conditions a move up was started under have gone back (roadmap item
573
+ * 98).
574
+ *
575
+ * @param {string} consumerId
576
+ * @param {string} reason
577
+ * @returns {void}
578
+ */
579
+ cancelSameHeightSwitch(consumerId, reason) {
580
+ const move = consumerId ? this.#host.sameHeightSwitchOf(consumerId) : null;
581
+ const prepared = move ? this.#host.outputs.get(move.outputId) : null;
582
+ if (!move || !prepared) {
583
+ if (move) {
584
+ this.#host.noteSameHeightSwitch(consumerId, null);
585
+ }
586
+ return;
587
+ }
588
+ this.#cancelSameHeightSwitch(this.#host.outputs.pictureOf(prepared), consumerId, reason);
589
+ }
590
+
591
+ /**
592
+ * Whether a step of this picture at `height` is READY for this viewer: it
593
+ * exists and the piece they will ask for next is closed on it (roadmap item
594
+ * 98). Ready means that piece — not the whole film.
595
+ *
596
+ * @param {HlsSession} base
597
+ * @param {string} consumerId
598
+ * @param {number} height
599
+ * @returns {boolean}
600
+ */
601
+ heightReadyFor(base, consumerId, height) {
602
+ const step = this.#interchangeableWith(base, base.spec)
603
+ .find((output) => this.#host.outputs.variantHeightOf(output) === height);
604
+ if (!step) {
605
+ return false;
606
+ }
607
+ const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
608
+ const askedHeights = current ? this.#host.heightsChosenAs(consumerId, current.outputKey ?? "") : [];
609
+ const next = this.#nextSegmentOf(base, consumerId, askedHeights, step);
610
+ return next < step.timeline.segmentCount && this.#host.segmentClosed(step.outputKey ?? "", next);
611
+ }
612
+
613
+ /**
614
+ * A segment was closed on some output. If a viewer's move is being prepared
615
+ * onto it, whether that move can be made now is asked again.
616
+ *
617
+ * Told by `SegmentStore.onPublished`, and nothing here polls: a move waits
618
+ * for exactly the event that makes it possible.
619
+ *
620
+ * @param {string} key - The output the segment was closed on.
621
+ * @param {number} index
622
+ * @returns {void}
623
+ */
624
+ noteSegmentPublished(key, index) {
625
+ if (!key) {
626
+ return;
627
+ }
628
+ for (const output of this.#host.outputs.values()) {
629
+ if (output.outputKey !== key) {
630
+ continue;
631
+ }
632
+ const base = this.#host.outputs.pictureOf(output);
633
+ for (const consumerId of this.#host.switchingOnto(output)) {
634
+ this.#reconsiderSameHeightSwitch(base, consumerId, index);
635
+ }
636
+ }
637
+ }
638
+
639
+ /**
640
+ * A viewer reported on themselves. If a move is being prepared for them,
641
+ * whether it is still wanted is asked again.
642
+ *
643
+ * WHY THIS EVENT. A publication is the only other one, and it comes only
644
+ * from an output that is producing. A move waiting on an output that closes
645
+ * nothing would otherwise stand for ever: a link that recovered would not
646
+ * cancel it, and the quality budget, which leaves alone a viewer with a move
647
+ * being prepared, would never decide for them again. The report is what
648
+ * carries a change of their link, so it is where that change is judged.
649
+ *
650
+ * The move can also be made here, and only on the condition every other
651
+ * trigger uses: the segment they will ask for next is closed on the new
652
+ * output. The report changes when the question is asked, not its answer.
653
+ *
654
+ * @param {string} sessionId - The output the page addressed.
655
+ * @param {string} consumerId
656
+ * @returns {void}
657
+ */
658
+ noteViewerReported(sessionId, consumerId) {
659
+ const move = consumerId ? this.#host.sameHeightSwitchOf(consumerId) : null;
660
+ if (!move) {
661
+ return;
662
+ }
663
+ // The picture the move belongs to, read from the output being prepared: the
664
+ // page may address a picture of another film, and a move is about one.
665
+ const prepared = this.#host.outputs.get(move.outputId);
666
+ const named = prepared ?? (sessionId ? this.#host.outputs.get(sessionId) : null);
667
+ const base = named ? this.#host.outputs.pictureOf(named) : null;
668
+ if (!base) {
669
+ return;
670
+ }
671
+ this.#reconsiderSameHeightSwitch(base, consumerId, null);
672
+ }
673
+
674
+ /**
675
+ * Encoding an output has failed for good. Every move being prepared onto it
676
+ * is asked again, which cancels it: nothing will close the segment it waits
677
+ * for, and no publication will come to say so.
678
+ *
679
+ * Told by `EncodeRuns` at the moment the failure is recorded.
680
+ *
681
+ * @param {HlsSession} output
682
+ * @returns {void}
683
+ */
684
+ noteProductionFailed(output) {
685
+ if (!output) {
686
+ return;
687
+ }
688
+ const base = this.#host.outputs.pictureOf(output);
689
+ for (const consumerId of this.#host.switchingOnto(output)) {
690
+ this.#reconsiderSameHeightSwitch(base, consumerId, null);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * The segment this viewer will ask for next at these heights.
696
+ *
697
+ * Their player fetches one level's segments in order within one viewing, so
698
+ * the next is one past the highest they were given in the viewing they are in
699
+ * NOW. Given nothing yet in it — straight after a seek — the next is the one
700
+ * where they stand. Read at the moment of asking, so a decision taken on an
701
+ * event that arrived late is taken about where they are.
702
+ *
703
+ * @param {HlsSession} base
704
+ * @param {string} consumerId
705
+ * @param {number[]} askedHeights
706
+ * @param {HlsSession} prepared
707
+ * @returns {number}
708
+ */
709
+ #nextSegmentOf(base, consumerId, askedHeights, prepared) {
710
+ const highest = Math.max(-1, ...askedHeights.map((height) => this.#host.highestGivenSegmentOf(consumerId, height)));
711
+ if (highest >= 0) {
712
+ return highest + 1;
713
+ }
714
+ return this.#host.outputTimes.segmentIndexForTime(prepared, this.#host.viewerPositionOf(base.id, consumerId));
715
+ }
716
+
717
+ /**
718
+ * Make the move being prepared for this viewer, if it can be made now; cancel
719
+ * it, if it should not be made at all; otherwise leave it waiting.
720
+ *
721
+ * @param {HlsSession} base
722
+ * @param {string} consumerId
723
+ * @param {number | null} publishedIndex - The segment whose closing caused
724
+ * this, or null when asked for no event in particular.
725
+ * @returns {void}
726
+ */
727
+ #reconsiderSameHeightSwitch(base, consumerId, publishedIndex) {
728
+ const move = this.#host.sameHeightSwitchOf(consumerId);
729
+ if (!move) {
730
+ return;
731
+ }
732
+ const prepared = this.#host.outputs.get(move.outputId);
733
+ if (!prepared || prepared.outputKey !== move.outputKey) {
734
+ this.#cancelSameHeightSwitch(base, consumerId, "the output being prepared has gone");
735
+ return;
736
+ }
737
+ const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
738
+ if (current === prepared) {
739
+ this.#host.noteSameHeightSwitch(consumerId, null);
740
+ return;
741
+ }
742
+ if (this.#host.encodeRuns.hasFailed(prepared)) {
743
+ this.#cancelSameHeightSwitch(base, consumerId, "encoding the output being prepared has failed");
744
+ return;
745
+ }
746
+ if (!this.#suitsViewer(prepared, base, consumerId).suits) {
747
+ this.#cancelSameHeightSwitch(base, consumerId, "the output being prepared no longer suits this viewer's link");
748
+ return;
749
+ }
750
+ if (move.direction === "down" && this.#suitsViewer(current, base, consumerId).suits) {
751
+ this.#cancelSameHeightSwitch(base, consumerId, "this viewer's link carries the output on screen again");
752
+ return;
753
+ }
754
+ const next = this.#nextSegmentOf(base, consumerId, move.askedHeights, prepared);
755
+ if (next >= prepared.timeline.segmentCount) {
756
+ this.#cancelSameHeightSwitch(base, consumerId, "there is nothing left of the film to give from it");
757
+ return;
758
+ }
759
+ // A segment closed BEHIND the one they will ask for says nothing about it:
760
+ // the move would hand them an output that does not yet have what they need.
761
+ if (publishedIndex !== null && publishedIndex < next) {
762
+ return;
763
+ }
764
+ if (!this.#host.segmentClosed(prepared.outputKey ?? "", next)) {
765
+ return;
766
+ }
767
+ // THE CUSHION, for a move that is not urgent (roadmap item 98): a move up
768
+ // is made once the viewer holds the cushion this file needs, and their
769
+ // next report asks again until they do. A move DOWN is made at once — it
770
+ // was started because their buffer would run dry, and a cushion that is
771
+ // shrinking would be waited for for ever. A file with no cushion stated has
772
+ // none to wait for.
773
+ if (move.direction !== "down") {
774
+ const needed = this.#host.minimumBufferSecondsFor(prepared);
775
+ if (Number.isFinite(needed) && needed > 0 && this.#host.bufferedSecondsOf(consumerId) < needed) {
776
+ return;
777
+ }
778
+ }
779
+ // The player fetches the fMP4 init once and decodes every later fragment
780
+ // against it. Changing bitrate outputs is safe only when the prepared
781
+ // output's init says the same thing to that decoder. The new segment is
782
+ // closed here, so its init is available for this check. MPEG-TS has no init
783
+ // and is self-contained, so it does not use this gate.
784
+ if (
785
+ current &&
786
+ current.outputKey !== prepared.outputKey &&
787
+ current.segmentFormat?.initFileName !== null &&
788
+ prepared.segmentFormat?.initFileName !== null
789
+ ) {
790
+ const verdict = this.#host.headersCompatible(current.outputKey, prepared.outputKey);
791
+ if (verdict?.compatible !== true) {
792
+ const differences = verdict?.differences?.join("; ") || "the headers are missing or incompatible";
793
+ this.#cancelSameHeightSwitch(base, consumerId, `the prepared output has an incompatible init: ${differences}`);
794
+ return;
795
+ }
796
+ }
797
+ this.#completeSameHeightSwitch(base, consumerId, move, prepared, current, next);
798
+ }
799
+
800
+ /**
801
+ * The move itself.
802
+ *
803
+ * ONE SYNCHRONOUS STRETCH, with no `await` anywhere in it. The choice, the
804
+ * output on screen and where the viewer is registered are three records of
805
+ * one fact, and a suspension between them would let a request be answered by
806
+ * the new choice while the viewer is still registered only on the old output,
807
+ * or the plan run on the old output's map with the viewer already gone from
808
+ * the choice.
809
+ *
810
+ * THE OUTPUT LEFT IS NOT DISPOSED. The viewer stops watching it, its priority
811
+ * map loses them, and the plan stops its encoder when nobody is left on it;
812
+ * the output itself goes by the ordinary idle expiry, which keeps it while an
813
+ * assignment stands — a response still being sent from it, or a segment of it
814
+ * given in a viewing still inside its window. A repeat of such an address is
815
+ * answered by it, because `given` is read before `chosen`.
816
+ *
817
+ * @param {HlsSession} base
818
+ * @param {string} consumerId
819
+ * @param {{ askedHeights: number[], direction: string, reason: string }} move
820
+ * @param {HlsSession} prepared
821
+ * @param {HlsSession} current
822
+ * @param {number} next
823
+ * @returns {void}
824
+ */
825
+ #completeSameHeightSwitch(base, consumerId, move, prepared, current, next) {
826
+ for (const height of move.askedHeights) {
827
+ this.#choose(consumerId, height, prepared, base);
828
+ }
829
+ this.#host.noteStepOnScreen(base, consumerId, prepared.id);
830
+ this.#host.watches(prepared, consumerId);
831
+ // The picture is never left: their soundtrack and their position are
832
+ // recorded on it (`#noteVariantActive`).
833
+ if (current !== base) {
834
+ this.#host.viewerLeaves(current, consumerId);
835
+ }
836
+ this.#host.noteSameHeightSwitch(consumerId, null);
837
+ this.#host.planEncodersSoon();
838
+ // How long this move took to be ready, and what the viewer held when it
839
+ // happened — kept for the next time this host decides when to start one.
840
+ if (Number.isFinite(move.since)) {
841
+ this.#host.notePreparation?.(prepared, (Date.now() - move.since) / 1000, this.#host.bufferedSecondsOf(consumerId));
842
+ }
843
+ this.#host.logger.info(
844
+ `transcode ${base.id} moved ${consumerId} ${move.direction} from ${current.outputKey} to ` +
845
+ `${prepared.outputKey} from segment #${next}: ${move.reason}`
846
+ );
847
+ }
848
+
849
+ /**
850
+ * Stop preparing this viewer's move.
851
+ *
852
+ * They stop watching the output being prepared — unless it is the one on
853
+ * their screen or the picture itself — and nothing else: whether its encoder
854
+ * goes on is the plan's, from who is left on it, and the output itself goes
855
+ * by the idle expiry.
856
+ *
857
+ * @param {HlsSession} base
858
+ * @param {string} consumerId
859
+ * @param {string} reason
860
+ * @returns {void}
861
+ */
862
+ #cancelSameHeightSwitch(base, consumerId, reason) {
863
+ const move = this.#host.sameHeightSwitchOf(consumerId);
864
+ if (!move) {
865
+ return;
866
+ }
867
+ this.#host.noteSameHeightSwitch(consumerId, null);
868
+ const prepared = this.#host.outputs.get(move.outputId);
869
+ const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
870
+ if (prepared && prepared !== current && prepared !== base) {
871
+ this.#host.viewerLeaves(prepared, consumerId);
872
+ }
873
+ // The place the move held on this machine ended with its record, so what
874
+ // may run elsewhere has changed whether or not anybody left an output.
875
+ this.#host.planEncodersSoon();
876
+ this.#host.logger.info(`transcode ${base.id} ${consumerId}'s move to ${move.outputKey} cancelled: ${reason}`);
877
+ }
878
+
879
+ /**
880
+ * A session of this family already making exactly this picture, if there is
881
+ * one — so that a second request for it does not start a second encoder.
882
+ *
883
+ * WHY IT COMPARES THE HEIGHT PRODUCED. An output is named by its whole
884
+ * format, and a rung produced exactly at a height can still differ from one
885
+ * already running at that height by its speed setting or its encoder. For
886
+ * the viewer those two are the same picture, so the one already producing it
887
+ * is used rather than a second encoder beside it. It was written when a
888
+ * manual pick was clamped below the height named, which put three encoders on
889
+ * one identical picture on 2026-08-28
890
+ * (`research/session-pileup-variant-key-2026-08-28.md`); the clamp is gone,
891
+ * and choosing an existing output by quality replaces this comparison.
892
+ *
893
+ * @param {HlsSession} base
894
+ * @param {number} askedHeight
895
+ * @param {HlsSession} candidate - The session just created for `askedHeight`.
896
+ * @returns {Promise<HlsSession | null>} The incumbent to use instead, or null
897
+ * to keep the one just made.
898
+ */
899
+ /**
900
+ * Let go of an output this call has finished with — unless somebody is on it.
901
+ *
902
+ * WHY IT IS ASKED AT THE MOMENT OF LETTING GO, and not decided in advance.
903
+ * Two of these disposals reasoned "nobody is on it yet", and neither could
904
+ * know it. An output is addressed by what it PRODUCES, so opening one returns
905
+ * the output that already makes that picture whenever there is one: what came
906
+ * back may be a step another viewer has been watching for an hour. And even a
907
+ * genuinely new one is only new for as long as nothing else has reached it —
908
+ * making a step takes a probe and a keyframe index, seconds in which a second
909
+ * viewer can ask for the same height and be registered onto it.
910
+ *
911
+ * So the question is the registry's, asked now: is anybody watching this. A
912
+ * step nobody is on is stopped by the plan on its next pass anyway, because
913
+ * its priority map is empty; what must never happen is taking away an output
914
+ * somebody is watching, which stops their encoder and empties their screen.
915
+ *
916
+ * @param {HlsSession} output
917
+ * @param {string} because
918
+ * @returns {Promise<boolean>} Whether it went.
919
+ */
920
+ async #letGoIfNobodyIsOn(output, because) {
921
+ // NOBODY WATCHING IS NOT THE WHOLE QUESTION. A response already begun is
922
+ // still being sent from this output, and a request made moments ago may
923
+ // still be repeated and has to be answered by whatever answered it first.
924
+ // Both stand after the last viewer has stopped being registered on it.
925
+ if (this.#host.outputStillNeeded(output)) {
926
+ this.#host.logger.info(
927
+ `transcode ${output.id} kept although ${because}: ` +
928
+ `${this.#host.viewerCountOn(output)} viewer(s) watching it, and assignments may still stand`
929
+ );
930
+ return false;
931
+ }
932
+ this.#host.logger.info(`transcode ${output.id} disposed: ${because}`);
933
+ await this.#host.disposeSession(output.id);
934
+ return true;
935
+ }
936
+
937
+ /**
938
+ * The outputs of this picture that could stand in for `spec` — the same
939
+ * material, so interchangeable as far as anything but picture size goes.
940
+ *
941
+ * ONE RULE, ASKED BY EVERY PATH THAT PICKS AN OUTPUT. Two of them used to
942
+ * gather the family for themselves, and only one checked the material: a
943
+ * 720p step in MPEG-TS could be found by produced height and handed to a
944
+ * picture cut for fMP4, whose player cannot even ask for its segment names.
945
+ * Fixing the one path left the other open inside the same operation, which
946
+ * is why the rule is stated here rather than at each caller.
947
+ *
948
+ * The picture itself is filtered like any other member. It passes trivially
949
+ * when compared against its own identity, and comparing it is what makes
950
+ * this one rule instead of two.
951
+ *
952
+ * @param {HlsSession} base
953
+ * @param {import("./output/OutputSpec.js").OutputSpec} spec - What the
954
+ * candidate must be interchangeable WITH: the picture's own identity when
955
+ * answering for a height, the created output's when replacing it.
956
+ * @returns {HlsSession[]}
957
+ */
958
+ #interchangeableWith(base, spec) {
959
+ return [base, ...this.#host.outputs.stepsOf(base)]
960
+ .filter((other) => other && isSameMaterial(other.spec, spec));
961
+ }
962
+
963
+ /**
964
+ * The output that answered this address for this viewer earlier in the same
965
+ * generation, if it is still here and still interchangeable with the picture.
966
+ *
967
+ * An answer whose output has gone — disposed on a hardware encoder's failure,
968
+ * the one disposal that does not ask the assignments — is no answer, and the
969
+ * address is decided afresh.
970
+ *
971
+ * @param {HlsSession} base
972
+ * @param {string} consumerId
973
+ * @param {number} generation
974
+ * @param {number} height
975
+ * @param {number} segmentIndex - −1 for the init.
976
+ * @returns {{ state: "none" } | { state: "live", output: HlsSession } | { state: "gone", key: string }}
977
+ */
978
+ #givenBefore(base, consumerId, generation, height, segmentIndex) {
979
+ const key = this.#host.givenOutputOf(consumerId, generation, height, segmentIndex);
980
+ if (!key) {
981
+ return { state: "none" };
982
+ }
983
+ const output = this.#interchangeableWith(base, base.spec).find((other) => other.outputKey === key) ?? null;
984
+ return output ? { state: "live", output } : { state: "gone", key };
985
+ }
986
+
987
+ /**
988
+ * A repeat of an address whose output has gone.
989
+ *
990
+ * NEVER DECIDED AGAIN AS IF NEW. The player holds the header of the output
991
+ * that answered this address; a piece of another output under the same
992
+ * address may not decode under it. So, in order:
993
+ *
994
+ * 1. the very piece that was given, from what is stored under the gone
995
+ * output's key — its pieces and header outlive it until the disk needs the
996
+ * room;
997
+ * 2. a piece of the output this viewer would be given now, but only when its
998
+ * header is PROVEN compatible with the gone one (`init-compat.js`);
999
+ * 3. otherwise the address is lost, and the page is told so: it starts a new
1000
+ * viewing where the picture is, which is the one way out that does not
1001
+ * serve a piece under a header it may not match. Answering "retry" here
1002
+ * instead would be answered the same way for ever.
1003
+ *
1004
+ * @param {HlsSession} base
1005
+ * @param {number} height
1006
+ * @param {string} fileName
1007
+ * @param {number} segmentIndex
1008
+ * @param {string} consumerId
1009
+ * @param {number} generation
1010
+ * @param {string} goneKey
1011
+ * @returns {Promise<{ sessionId: string | null, recover?: { key: string, likeId: string }, lost?: object, unavailable?: object, error?: string }>}
1012
+ */
1013
+ async #answerForLostAddress(base, height, fileName, segmentIndex, consumerId, generation, goneKey) {
1014
+ if (this.#host.storedPieceReady(goneKey, fileName)) {
1015
+ return { sessionId: null, recover: { key: goneKey, likeId: base.id } };
1016
+ }
1017
+ let current = null;
1018
+ try {
1019
+ current = await this.resolveVariantSession(base.id, height, segmentIndex, consumerId);
1020
+ } catch (error) {
1021
+ if (error?.code !== OUTPUT_UNAVAILABLE) {
1022
+ const message = error instanceof Error ? error.message : String(error);
1023
+ return { sessionId: null, error: message };
1024
+ }
1025
+ }
1026
+ const verdict = current ? this.#host.headersCompatible(goneKey, current.outputKey ?? "") : null;
1027
+ if (current && verdict?.compatible) {
1028
+ this.#host.placeViewerOn(current, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
1029
+ this.#host.noteGivenOutput(consumerId, generation, height, segmentIndex, current.outputKey ?? "");
1030
+ this.#host.logger.info(
1031
+ `transcode ${base.id} ${fileName} for ${consumerId}: ${goneKey} has gone; served by ` +
1032
+ `${current.outputKey}, whose header is compatible with it`
1033
+ );
1034
+ return { sessionId: current.id };
1035
+ }
1036
+ const reason = current
1037
+ ? `the output that answered it has gone, and the one that would answer now has a header not proven compatible (${(verdict?.differences ?? []).join("; ") || "no header yet"})`
1038
+ : "the output that answered it has gone, and nothing that could answer now suits this viewer";
1039
+ this.#host.logger.info(`transcode ${base.id} ${fileName} for ${consumerId}: assignment lost — ${reason}`);
1040
+ return { sessionId: null, lost: { reason, goneKey, height, segmentIndex, generation } };
1041
+ }
1042
+
1043
+ async #adoptIfAlreadyProduced(base, askedHeight, candidate, consumerId) {
1044
+ const produced = this.#host.outputs.producedHeightOf(candidate);
1045
+ if (produced <= 0) {
1046
+ return null;
1047
+ }
1048
+ const seen = new Set([candidate.id]);
1049
+ // The base belongs in this scan: it is a rung like any other, and when it
1050
+ // is itself a re-encode the clamp can land a variant right on top of it.
1051
+ // Compared against the CANDIDATE, because it is the candidate's place the
1052
+ // incumbent takes — the viewer asked for what the candidate is.
1053
+ for (const other of this.#interchangeableWith(base, candidate.spec)) {
1054
+ if (!other || seen.has(other.id)) {
1055
+ continue;
1056
+ }
1057
+ seen.add(other.id);
1058
+ if (this.#host.outputs.producedHeightOf(other) !== produced) {
1059
+ continue;
1060
+ }
1061
+ // THE SAME PICTURE IS NOT ENOUGH: it has to suit THIS viewer by the one
1062
+ // rule every path asks — their mode and a load their own link admits. Two
1063
+ // outputs of one size can differ in their limit, and the one already here
1064
+ // may be more than this viewer's link carries.
1065
+ const verdict = this.#suitsViewer(other, base, consumerId);
1066
+ if (!verdict.suits) {
1067
+ continue;
1068
+ }
1069
+ // Same picture, already being made. Let go of the one just created — if
1070
+ // it IS one, and if it is still nobody's.
1071
+ await this.#letGoIfNobodyIsOn(candidate, `${other.id.slice(0, 8)} already produces this picture`);
1072
+ this.#host.logger.info(
1073
+ `transcode ${base.id.slice(0, 8)} the ${askedHeight}p rung encodes at ${produced}p on this ` +
1074
+ `machine, which ${other.id.slice(0, 8)} is already producing — serving it from there ` +
1075
+ `instead of starting a second encoder "${base.file.name}"`
1076
+ );
1077
+ return other;
1078
+ }
1079
+ return null;
1080
+ }
1081
+
1082
+ /**
1083
+ * Resolve one file request addressed to a variant: `v/<height>/<fileName>`
1084
+ * under a session.
1085
+ *
1086
+ * The single entry point for the variant route, so the policy — which variant
1087
+ * exists, which one the viewer is watching, which encoder runs — stays here
1088
+ * rather than being spread into a route handler.
1089
+ *
1090
+ * @param {string} baseSessionId
1091
+ * @param {number} height
1092
+ * @param {string} fileName
1093
+ * @param {string} [consumerId] - Which viewer is asking. One picture is shared
1094
+ * by everyone watching it, and the quality each of them chose is their own.
1095
+ * @param {number} [statedGeneration] - Which viewing of theirs the request was
1096
+ * made in, as the page stamped it; NaN when it states none.
1097
+ * @returns {Promise<{ sessionId: string | null, error?: string }>} The session
1098
+ * to serve the file from; a null id means there is no such variant.
1099
+ */
1100
+ async resolveVariantFile(baseSessionId, height, fileName, consumerId = "", statedGeneration = Number.NaN) {
1101
+ if (!isOutputName(baseSessionId)) {
1102
+ return { sessionId: null };
1103
+ }
1104
+ const base = this.#host.outputs.get(baseSessionId);
1105
+ if (!base) {
1106
+ return { sessionId: null };
1107
+ }
1108
+ // A variant carries a media playlist, an init segment and segments. Nothing
1109
+ // else lives under that path — a master there would describe variants of a
1110
+ // variant.
1111
+ const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1112
+ const isInit = base.segmentFormat.initFileName !== null &&
1113
+ fileName === base.segmentFormat.initFileName;
1114
+ const isSegment = base.segmentFormat.isSegmentFileName(fileName);
1115
+ if (!isPlaylist && !isInit && !isSegment) {
1116
+ return { sessionId: null };
1117
+ }
1118
+ if (!this.#host.outputs.splicableHeights(base).includes(height)) {
1119
+ return { sessionId: null };
1120
+ }
1121
+ // Answered from the base, and no encoder is started for it. Every variant of
1122
+ // a file has the SAME media playlist — same duration, same boundaries, same
1123
+ // init name — because that is exactly what makes them interchangeable. The
1124
+ // player fetches a level's playlist to decide with, and creating a session
1125
+ // for one it may never switch to would leave a second encoder running on a
1126
+ // host that has capacity for one.
1127
+ if (isPlaylist) {
1128
+ return { sessionId: base.id };
1129
+ }
1130
+ // THE ADDRESS OF THIS ANSWER: the height asked for and the segment, the
1131
+ // init being segment −1. Within one generation of this viewer's viewing a
1132
+ // repeat of the same address is answered by what answered it first — not
1133
+ // decided again, which could now name another output than the one whose
1134
+ // bytes the player already holds for it.
1135
+ // A step is asked for BY somebody; see resolveVariantSession.
1136
+ if (!consumerId) {
1137
+ return { sessionId: null };
1138
+ }
1139
+ const segmentIndex = isSegment ? base.segmentFormat.segmentIndexFromName(fileName) : -1;
1140
+ const generation = this.#host.generationOfRequest(consumerId, statedGeneration);
1141
+ const given = this.#givenBefore(base, consumerId, generation, height, segmentIndex);
1142
+ if (given.state === "live") {
1143
+ this.#host.outputs.touch(given.output);
1144
+ this.#host.placeViewerOn(given.output, consumerId, this.#host.viewerPositionOf(baseSessionId, consumerId));
1145
+ return { sessionId: given.output.id };
1146
+ }
1147
+ if (given.state === "gone") {
1148
+ return this.#answerForLostAddress(base, height, fileName, segmentIndex, consumerId, generation, given.key);
1149
+ }
1150
+ let variant;
1151
+ try {
1152
+ variant = await this.resolveVariantSession(
1153
+ baseSessionId,
1154
+ height,
1155
+ segmentIndex,
1156
+ // WHOSE REQUEST THIS IS. Whether an output already here may serve a
1157
+ // height depends on the asking viewer's mode, and this path dropped the
1158
+ // name: the request was then taken as a size picked by hand, which is
1159
+ // served exactly and reuses nothing. Whichever of the two paths creates
1160
+ // the step first decides for both, so an automatic viewer whose player
1161
+ // fetched before the warm-up finished got a second encoder where an
1162
+ // output already here would have served them.
1163
+ consumerId
1164
+ );
1165
+ } catch (error) {
1166
+ if (error?.code === OUTPUT_UNAVAILABLE) {
1167
+ return { sessionId: null, unavailable: error.details };
1168
+ }
1169
+ const message = error instanceof Error ? error.message : String(error);
1170
+ this.#host.logger.error(
1171
+ `transcode ${baseSessionId} could not prepare the ${height}p variant: ${message}` +
1172
+ (error instanceof Error && error.stack ? `\n${error.stack}` : "")
1173
+ );
1174
+ return { sessionId: null, error: message };
1175
+ }
1176
+ if (!variant) {
1177
+ return { sessionId: null };
1178
+ }
1179
+ if (consumerId) {
1180
+ // The same circle as a soundtrack's: the init has to be made before a
1181
+ // segment can be asked for, and nothing is made for an output nobody is
1182
+ // watching. Asking for any of its files is watching it.
1183
+ this.#host.placeViewerOn(variant, consumerId, this.#host.viewerPositionOf(baseSessionId, consumerId));
1184
+ // Recorded AFTER placing them: a viewer this request is the first sign of
1185
+ // exists only from that moment, and a record kept for nobody is lost.
1186
+ this.#host.noteGivenOutput(consumerId, generation, height, segmentIndex, variant.outputKey ?? "");
1187
+ }
1188
+ // WHICH RUNG IS ON THE SCREEN IS NOT READ OFF THIS REQUEST. The page says
1189
+ // so itself (`viewerPlays`), the moment the player has switched: a request
1190
+ // for a rung's segment is the player fetching, and it used to be taken as
1191
+ // the person having moved.
1192
+ return { sessionId: variant.id };
1193
+ }
1194
+
1195
+ /**
1196
+ * Prepare an audio track at a position, so a change of track is instant.
1197
+ *
1198
+ * The player, told to change track, discards the audio it holds and cannot
1199
+ * show a frame until the new track covers the playhead — so switching first
1200
+ * and producing second puts the whole of the track's cold start on screen as
1201
+ * a spinner. Measured 2026-08-15: the picture stopped for as long as the
1202
+ * first piece took. Prepared first, the player finds the bytes already there.
1203
+ *
1204
+ * The same shape as {@link prepareVariant}, and for the same reason.
1205
+ *
1206
+ * @param {string} baseSessionId
1207
+ * @param {number} trackIndex
1208
+ * @param {number} positionSeconds
1209
+ * @returns {Promise<{ sessionId: string, fileName: string } | null>}
1210
+ */
1211
+ async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds, consumerId) {
1212
+ const base = this.#host.outputs.get(baseSessionId);
1213
+ // A track is prepared FOR somebody; a request naming nobody prepares nothing.
1214
+ if (!consumerId || !base || !this.servesAudioSeparately(base)) {
1215
+ return null;
1216
+ }
1217
+ if (!this.#audioRenditionsOf(base).some((track) => track.trackIndex === trackIndex)) {
1218
+ return null;
1219
+ }
1220
+ const rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
1221
+ if (!rendition) {
1222
+ return null;
1223
+ }
1224
+ // A track prepared for a change the viewer did not make would otherwise
1225
+ // encode for nobody until its own idle timer noticed — the same trap
1226
+ // warming a quality rung has, and the same answer. Kept per viewer, because
1227
+ // one viewer's abandoned preparation must not stop a track another viewer
1228
+ // is listening to.
1229
+ const stillWarming = this.#host.audioBeingWarmedOf(base, consumerId);
1230
+ if (stillWarming && stillWarming !== rendition.id) {
1231
+ const abandoned = this.#host.outputs.get(stillWarming);
1232
+ const wanted = this.#liveAudioRenditionKeys(base);
1233
+ const wantedIds = new Set(
1234
+ this.#host.outputs.renditionsOf(base)
1235
+ .filter((other) => wanted.has(audioRenditionKey(
1236
+ this.#flatAudioTrackOf(other),
1237
+ other.spec.transcodesAudio
1238
+ )))
1239
+ .map((other) => other.id)
1240
+ );
1241
+ if (abandoned && !wantedIds.has(abandoned.id)) {
1242
+ // They are not listening to it, so they stop watching it. Its encoder
1243
+ // follows from that and is not commanded here: nobody left on an output
1244
+ // is a map with nothing in it, and the plan stops what is on it.
1245
+ this.#host.viewerLeaves(abandoned, consumerId);
1246
+ }
1247
+ }
1248
+ this.#host.noteAudioBeingWarmed(base, consumerId, rendition.id);
1249
+ // Being prepared for them is watching it: it is made for this viewer, and
1250
+ // when they leave it must be let go with everything else of theirs.
1251
+ // Where the switch will land. An existing track was left wherever the
1252
+ // viewer last was on it; saying where they are now is the whole of pointing
1253
+ // it there, because the encoder follows the person and not the request.
1254
+ this.#host.placeOn(rendition, consumerId, positionSeconds);
1255
+ this.#host.planEncodersSoon();
1256
+ const index = this.#host.outputTimes.segmentIndexForTime(rendition, positionSeconds);
1257
+ return { sessionId: rendition.id, fileName: rendition.segmentFormat.segmentFileName(index) };
1258
+ }
1259
+
1260
+ async prepareVariant(baseSessionId, height, positionSeconds, consumerId) {
1261
+ // A step is warmed FOR somebody; a request naming nobody warms nothing.
1262
+ if (!consumerId || !isOutputName(baseSessionId)) {
1263
+ return null;
1264
+ }
1265
+ const base = this.#host.outputs.get(baseSessionId);
1266
+ if (!base) {
1267
+ return null;
1268
+ }
1269
+ if (!this.#host.qualityOffer.offeredHeightsFor(base).includes(height)) {
1270
+ return null;
1271
+ }
1272
+ const index = this.#host.outputTimes.segmentIndexForTime(base, positionSeconds);
1273
+ const variant = await this.resolveVariantSession(baseSessionId, height, index, consumerId);
1274
+ if (!variant) {
1275
+ return null;
1276
+ }
1277
+ // A rung warmed for a switch that was never made. Nothing else would ever
1278
+ // stop it: only becoming active stops the rung being left, so a viewer
1279
+ // trying two rungs in a row would leave the first encoding for nobody until
1280
+ // the look-ahead cap suspended it — three encoders at once on a host sized
1281
+ // for one, which is the opposite of what warming is for.
1282
+ // Kept per viewer, and stopped only if nobody has it on screen: with two
1283
+ // viewers, what one of them abandons may be what the other is watching.
1284
+ const stillWarming = this.#host.stepBeingWarmedOf(base, consumerId);
1285
+ if (stillWarming && stillWarming !== variant.id) {
1286
+ const abandoned = this.#host.outputs.get(stillWarming);
1287
+ if (abandoned && !this.#host.quality.variantsOnScreen(base).has(abandoned.id)) {
1288
+ this.#host.viewerLeaves(abandoned, consumerId);
1289
+ }
1290
+ }
1291
+ // The base is not a rung being prepared for anybody — it is what the family
1292
+ // is named by — so warming its own height leaves nothing outstanding.
1293
+ if (variant.id === base.id) {
1294
+ this.#host.noteStepBeingWarmed(base, consumerId, null);
1295
+ } else {
1296
+ const onScreen = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
1297
+ if (variant.id !== onScreen.id) {
1298
+ // A PLACE ON THIS MACHINE (roadmap item 97, step 13). The step warmed
1299
+ // before this one stops holding its place first — the viewer asked for
1300
+ // another rung, so it is no longer theirs to wait for — and then the
1301
+ // place is asked for, in the same synchronous stretch as the record is
1302
+ // written. Refused, nothing is recorded: the rung on screen goes on
1303
+ // playing and the page is told why.
1304
+ this.#host.noteStepBeingWarmed(base, consumerId, null);
1305
+ const place = this.#host.admitsPreparation(variant);
1306
+ if (!place.admitted) {
1307
+ this.#host.logger.info(
1308
+ `transcode ${base.id} warming ${height}p for ${consumerId} refused a place: ${place.reason}`
1309
+ );
1310
+ const error = new Error(`No place on this machine for ${height}p: ${place.reason}.`);
1311
+ error.code = OUTPUT_UNAVAILABLE;
1312
+ error.details = {
1313
+ reason: `this machine cannot encode ${height}p beside what it already encodes`,
1314
+ figures: { machineSpeedX: place.speedX, height }
1315
+ };
1316
+ throw error;
1317
+ }
1318
+ }
1319
+ this.#host.noteStepBeingWarmed(base, consumerId, variant.id);
1320
+ }
1321
+ // An existing rung may be parked wherever it was left, so it is pointed at
1322
+ // the switch position exactly as an activation would — the difference is
1323
+ // only that the rung on screen keeps its own encoder meanwhile.
1324
+ this.#host.outputs.touch(variant);
1325
+ // Anything that is not the rung on screen has to be pointed at the switch
1326
+ // position — INCLUDING the base. Skipping it because it is the base was a
1327
+ // defect: the base is parked wherever it was when the viewer left it, and
1328
+ // its encoder was stopped then. Measured 2026-08-12, warming 400p at
1329
+ // 6506.5s found the base still at `run from #0`, so the segment the switch
1330
+ // needed was never produced and the viewer got nothing at all.
1331
+ // A rung that is not on their screen is parked where they last left it, so
1332
+ // being warmed begins with saying where they are. Their being ON it is what
1333
+ // buys it an encoder, and both halves are said here: a warmed rung is one
1334
+ // this person is watching for as long as the warm-up lasts, which is why
1335
+ // two encoders run through it.
1336
+ if (variant.id !== this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }).id) {
1337
+ this.#host.placeOn(variant, consumerId, this.#host.outputTimes.segmentStartTime(base, index));
1338
+ this.#host.planEncodersSoon();
1339
+ }
1340
+ this.#host.logger.info(
1341
+ `transcode ${base.id} warming ${height}p at ${positionSeconds.toFixed(1)}s (segment #${index})`
1342
+ );
1343
+ return { sessionId: variant.id, fileName: variant.segmentFormat.segmentFileName(index) };
1344
+ }
1345
+
1346
+ /**
1347
+ * The rung this viewer's player is playing, as the page states it.
1348
+ *
1349
+ * The page says so when the player has switched (`LEVEL_SWITCHED`), with the
1350
+ * height of that rung and where the picture is. A rung this proxy has not
1351
+ * made is not one the player can be playing, so a height with no output
1352
+ * behind it changes nothing.
1353
+ *
1354
+ * @param {string} baseSessionId - The output the browser addresses.
1355
+ * @param {string} consumerId
1356
+ * @param {number} height
1357
+ * @param {number} [positionSeconds] - Where the picture is, when stated.
1358
+ * @returns {boolean} Whether a rung was found for that height.
1359
+ */
1360
+ viewerPlays(baseSessionId, consumerId, height, positionSeconds) {
1361
+ const named = this.#host.outputs.get(baseSessionId);
1362
+ if (!consumerId || !named || !(height > 0)) {
1363
+ return false;
1364
+ }
1365
+ const base = this.#host.outputs.pictureOf(named);
1366
+ // THE CHOICE ALREADY MADE for this viewer at that height, read and never
1367
+ // made here: the page names a height, and a height does not say which of
1368
+ // two limits the viewer was given.
1369
+ const rung = this.servingOutputFor(base, height, consumerId);
1370
+ if (!rung) {
1371
+ return false;
1372
+ }
1373
+ this.#noteVariantActive(base, rung, positionSeconds, consumerId);
1374
+ return true;
1375
+ }
1376
+
1377
+ /**
1378
+ * Record which variant the viewer is watching, and give it the encoder.
1379
+ *
1380
+ * The person is put on the new rung where their picture is, because a
1381
+ * segment request steers no encoder and a rung that was watched a minute ago
1382
+ * is parked wherever it was left.
1383
+ *
1384
+ * @param {HlsSession} base
1385
+ * @param {HlsSession} variant
1386
+ * @param {number} [positionSeconds] - Where the picture is, as the page
1387
+ * stated it with the switch.
1388
+ * @param {string} [consumerId] - Which viewer moved.
1389
+ * @returns {void}
1390
+ */
1391
+ #noteVariantActive(base, variant, positionSeconds, consumerId) {
1392
+ // Which step is on a screen is a fact about a PERSON: a request that names
1393
+ // nobody, or names the family itself, says nothing about any screen.
1394
+ if (!consumerId) {
1395
+ return;
1396
+ }
1397
+ const previous = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
1398
+ if (previous.id === variant.id) {
1399
+ // The rung on screen asking for more of itself, which it does every few
1400
+ // seconds. Nothing is being decided here — and deciding anything was the
1401
+ // defect: the warm-up was cancelled by the next segment the CURRENT rung
1402
+ // fetched, measured 2026-08-12 at 117 ms and 1.5 s after two warm-ups
1403
+ // began, so the rung being prepared was stopped before it had encoded
1404
+ // anything and the viewer waited out the full thirty-second warm-up for a
1405
+ // segment nobody was making, then waited again for the switch itself.
1406
+ return;
1407
+ }
1408
+ // Their player has moved to another rung, so a move between limits of the
1409
+ // one they were on is for a height they have left.
1410
+ this.#cancelSameHeightSwitch(base, consumerId, "the player moved to another rung");
1411
+ // A rung is being left, so whatever was warmed is decided: either it is the
1412
+ // rung now being switched to, or the viewer went somewhere else and it must
1413
+ // stop like any other rung nobody is watching. Nothing else would ever stop
1414
+ // it — only the rung being LEFT is stopped below.
1415
+ const warmed = this.#host.stepBeingWarmedOf(base, consumerId);
1416
+ const warmedSince = this.#host.stepBeingWarmedSinceOf?.(base, consumerId) ?? null;
1417
+ this.#host.noteStepBeingWarmed(base, consumerId, null);
1418
+ // The step warmed for this viewer is the one they moved onto: how long it
1419
+ // took, and what they held when they moved.
1420
+ if (warmed === variant.id && Number.isFinite(warmedSince)) {
1421
+ this.#host.notePreparation?.(variant, (Date.now() - warmedSince) / 1000, this.#host.bufferedSecondsOf(consumerId));
1422
+ }
1423
+ if (warmed && warmed !== variant.id && warmed !== previous.id) {
1424
+ const abandoned = this.#host.outputs.get(warmed);
1425
+ if (abandoned && !this.#host.quality.variantsOnScreen(base).has(abandoned.id)) {
1426
+ this.#host.viewerLeaves(abandoned, consumerId);
1427
+ }
1428
+ }
1429
+ const position = this.#variantStartSeconds(base, positionSeconds, consumerId);
1430
+ this.#host.noteStepOnScreen(base, consumerId, variant.id);
1431
+ // The step is an output of this viewer's now.
1432
+ this.#host.watches(variant, consumerId);
1433
+ // And the one they came off is not — unless it is the picture itself, which
1434
+ // they never stop watching: the browser addresses the picture, their chosen
1435
+ // soundtrack is recorded on it, and the plan reads their position from it.
1436
+ // Leaving it deleted their whole record, so a viewer who went down a step,
1437
+ // back to the picture's own height and down again lost the soundtrack they
1438
+ // had chosen, and the encoder making it was stopped as unwanted.
1439
+ if (previous !== base && previous !== variant) {
1440
+ this.#host.viewerLeaves(previous, consumerId);
1441
+ }
1442
+ this.#host.logger.info(
1443
+ `transcode ${base.id} variant now ${this.#host.outputs.variantHeightOf(variant)}p ` +
1444
+ `(was ${this.#host.outputs.variantHeightOf(previous)}p) at ${position.toFixed(1)}s` +
1445
+ (consumerId ? ` for ${consumerId}` : "")
1446
+ );
1447
+ // Requests still held on the rung they came off are for segments nobody
1448
+ // will produce now, and the player stopped waiting for them the moment it
1449
+ // switched. Answering "retry" at once frees them instead of holding each for
1450
+ // the full minute.
1451
+ //
1452
+ // WHETHER ITS ENCODER GOES ON IS NOT DECIDED HERE. It used to be stopped
1453
+ // from this line whenever no viewer had it on screen — the plan, handed the
1454
+ // whole film's priority map, wanted an encoder on every output of it and
1455
+ // started one again on the very next pass, which this viewer's own move had
1456
+ // just triggered. Each output is handed its own map now, so a rung nobody is
1457
+ // on has nothing in it and the plan stops what is on it, once.
1458
+ if (!this.#host.quality.variantsOnScreen(base).has(previous.id)) {
1459
+ this.#host.invalidateWaits(previous);
1460
+ }
1461
+ if (position > 0) {
1462
+ // The rung being switched TO, named literally: a warm-up may have left
1463
+ // the family pointing elsewhere, and forwarding would move that one
1464
+ // instead. Saying where this person is on it is the whole of pointing its
1465
+ // encoder there.
1466
+ this.#host.placeOn(variant, consumerId, position);
1467
+ }
1468
+ this.#host.planEncodersSoon();
1469
+ }
1470
+
1471
+ /**
1472
+ * The master playlist: every resolution this file can be served at, as HLS
1473
+ * variants.
1474
+ *
1475
+ * This is what makes a change of quality seamless. Our media playlist is VOD
1476
+ * and terminated with `#EXT-X-ENDLIST`, and hls.js only re-reads a playlist
1477
+ * that is live — so rewriting it underneath the player achieves nothing, and
1478
+ * a switch had to tear the player down and build a new session. Offered as
1479
+ * variants instead, the switch is the player's own: it fetches the other
1480
+ * variant, appends it after what is already buffered, and changes the
1481
+ * decoder's type if the codec parameters differ.
1482
+ *
1483
+ * Offered only where the variants can actually be joined, which is a question
1484
+ * about the CUT GRID and not about who produces the frames:
1485
+ *
1486
+ * - a re-encoded session on the uniform grid — its variants are re-encoded on
1487
+ * the same one, keyframes forced onto it;
1488
+ * - a session cut at the source's own keyframes — a copy, which has no other
1489
+ * choice — where the variants are re-encoded and forced onto those very
1490
+ * times, so a rung's segment covers the same span as the copy's.
1491
+ *
1492
+ * What is refused is a session whose own grid is a fiction: a copy with no
1493
+ * readable keyframe index falls back to an even grid that ffmpeg then does
1494
+ * not cut on, and nothing can be aligned to that.
1495
+ *
1496
+ * @param {string} sessionId
1497
+ * @returns {string | null} The playlist text, or null when there is nothing
1498
+ * to choose between, or nothing to align to.
1499
+ */
1500
+ buildMasterPlaylist(sessionId, consumerId = "") {
1501
+ if (!isOutputName(sessionId)) {
1502
+ return null;
1503
+ }
1504
+ const session = this.#host.outputs.get(sessionId);
1505
+ if (!session) {
1506
+ return null;
1507
+ }
1508
+ if (!this.#host.outputs.publishesVariants(session)) {
1509
+ return null;
1510
+ }
1511
+ // The audio tracks, published once for the whole file rather than muxed
1512
+ // into every rung. Two things follow from that: the same track is not
1513
+ // encoded once per rung on a host that struggles to encode it once, and
1514
+ // changing track becomes the player switching rendition instead of this
1515
+ // proxy rebuilding the session with another `audioTrackIndex`.
1516
+ //
1517
+ // Only for a session that asked for them. A browser that does not know
1518
+ // about renditions is served audio in its stream, as before, and gets no
1519
+ // `#EXT-X-MEDIA` lines to be confused by.
1520
+ const renditions = this.servesAudioSeparately(session)
1521
+ // Which track is marked DEFAULT is the ASKING viewer's business: one
1522
+ // picture is shared by everyone watching it, and each of them may have
1523
+ // chosen a different language. A default written from the session's own
1524
+ // field would start the second viewer in the first viewer's language.
1525
+ ? this.#audioRenditionsOf(session, this.#audioChoiceOf(session, consumerId).trackIndex)
1526
+ : [];
1527
+ return masterPlaylistText({
1528
+ // The shape of the film and the rates it carries, asked of the layer that
1529
+ // holds both. What CAN be spliced, not what is worth offering this second:
1530
+ // the live judgement travels in `offeredHeights` and in every progress
1531
+ // report, and letting it decide the master's existence made a live session
1532
+ // answer 404 to its own published address.
1533
+ ...this.#host.outputs.masterFactsOf(session),
1534
+ renditions,
1535
+ playlistFileName: PLAYLIST_FILE_NAME
1536
+ });
1537
+ }
1538
+
1539
+ /**
1540
+ * Whether this session's audio is published separately rather than muxed into
1541
+ * its picture.
1542
+ *
1543
+ * Two things have to hold, and the second is why this is asked here rather
1544
+ * than settled when the session was made. The browser must understand
1545
+ * renditions — it says so when it creates the session, and one that does not
1546
+ * has to be sent audio in the stream. AND there must be a master playlist to
1547
+ * publish them in: a stream served as a single media playlist has nowhere to
1548
+ * carry an `#EXT-X-MEDIA` line, so taking the audio out of it would leave the
1549
+ * viewer with a picture and silence.
1550
+ *
1551
+ * @param {HlsSession} session
1552
+ * @returns {boolean}
1553
+ */
1554
+ servesAudioSeparately(session) {
1555
+ return session.spec.carries !== "audio-only" && session.spec.carriesAudioSeparately;
1556
+ }
1557
+
1558
+ inputOf(session) {
1559
+ return encoderInputs({
1560
+ picture: session.file,
1561
+ soundtrack: session.spec.audio
1562
+ ? this.#host.sourceFiles.get(session.file.sourceKey, session.spec.audioFileIndex)
1563
+ : session.file,
1564
+ carries: session.spec.carries,
1565
+ audioSeparate: this.servesAudioSeparately(session),
1566
+ sessionId: session.id,
1567
+ readWindowBytes: this.#host.encodeRuns.readWindowFor(session),
1568
+ baseUrl: this.#host.localBaseUrl
1569
+ });
1570
+ }
1571
+
1572
+ /**
1573
+ * One file of an audio rendition: its playlist, its init segment or one of
1574
+ * its segments.
1575
+ *
1576
+ * A rendition is an ordinary session underneath — same source, same file,
1577
+ * same cut grid, one audio track and no picture — created on the first
1578
+ * request for it, exactly as a quality variant is. What differs is that the
1579
+ * player fetches it ALONGSIDE a variant rather than instead of one, so both
1580
+ * encoders run: a rung and the audio it is played with.
1581
+ *
1582
+ * @param {string} baseSessionId
1583
+ * @param {number} trackIndex
1584
+ * @param {string} fileName
1585
+ * @param {string} [consumerId] - Who is asking. One picture serves everyone
1586
+ * watching it, and which soundtrack they are listening to is theirs alone.
1587
+ * @returns {Promise<{ sessionId: string | null, error?: string }>}
1588
+ */
1589
+ async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName, consumerId = "") {
1590
+ if (!isOutputName(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
1591
+ return { sessionId: null };
1592
+ }
1593
+ const base = this.#host.outputs.get(baseSessionId);
1594
+ if (!base || !this.servesAudioSeparately(base)) {
1595
+ return { sessionId: null };
1596
+ }
1597
+ const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1598
+ const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
1599
+ const isSegment = base.segmentFormat.isSegmentFileName(fileName);
1600
+ if (!isPlaylist && !isInit && !isSegment) {
1601
+ return { sessionId: null };
1602
+ }
1603
+ if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
1604
+ return { sessionId: null };
1605
+ }
1606
+ // The playlist is answered from the base, for the same reason a variant's
1607
+ // is: every rendition of a file has the same boundaries and the same
1608
+ // duration — they are cut on one grid — and the player fetches the playlist
1609
+ // of tracks it may never select. Starting an encoder for each would put as
1610
+ // many encoders on the host as the file has languages.
1611
+ if (isPlaylist) {
1612
+ return { sessionId: base.id };
1613
+ }
1614
+ let rendition;
1615
+ try {
1616
+ rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
1617
+ } catch (error) {
1618
+ const message = error instanceof Error ? error.message : String(error);
1619
+ this.#host.logger.error(
1620
+ `transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
1621
+ (error instanceof Error && error.stack ? `\n${error.stack}` : "")
1622
+ );
1623
+ return { sessionId: null, error: message };
1624
+ }
1625
+ if (rendition && consumerId) {
1626
+ // Asking for ANY file of this soundtrack is this viewer watching it, and
1627
+ // the init is the file they ask for first. Registered here rather than on
1628
+ // the segment alone, because the segment cannot be asked for until the
1629
+ // init has been served, and the init cannot be made unless somebody is
1630
+ // watching: that circle is what left a soundtrack with no encoder, no
1631
+ // init and a viewer waiting sixty seconds on 2026-09-05.
1632
+ //
1633
+ // WHERE they are on it is where they are on the picture: the two are
1634
+ // played together.
1635
+ this.#host.placeViewerOn(rendition, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
1636
+ }
1637
+ if (isSegment && rendition) {
1638
+ this.#noteAudioTrackActive(base, trackIndex, consumerId);
1639
+ }
1640
+ return { sessionId: rendition?.id ?? null };
1641
+ }
1642
+
1643
+ /**
1644
+ * A SEGMENT of this track is what says the viewer is listening to it — the
1645
+ * player fetches the playlist and the init of tracks it may never choose.
1646
+ *
1647
+ * Every other track is then stopped. Each one is an ffmpeg process AND a
1648
+ * reader holding pieces of the torrent in memory, and the store can only
1649
+ * spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
1650
+ * track once had three readers on one file — picture, the track they chose
1651
+ * and the track they left — and at a seek all three revived their windows at
1652
+ * once, every resident piece was pinned, a read ended with zero bytes, and
1653
+ * every encoder took that for the end of the file and died. Playback was over
1654
+ * for good; the sessions answered 500 to everything after that.
1655
+ *
1656
+ * Stopped, not disposed: the track keeps its place, its grid and its
1657
+ * position, so switching back does not build it again — the same treatment a
1658
+ * quality rung gets when the viewer moves off it.
1659
+ *
1660
+ * "Every other track" is every track NO LIVE VIEWER is listening to, which
1661
+ * with one viewer is what it always was. It has to be asked that way now that
1662
+ * two viewers share one picture: each of them fetches the sound they chose,
1663
+ * and stopping "the others" per request would have them switch each other's
1664
+ * soundtrack off in turn, once per segment, for the whole film.
1665
+ *
1666
+ * @param {HlsSession} base
1667
+ * @param {number} trackIndex
1668
+ * @param {string} consumerId - Who is listening. A request that names
1669
+ * nobody changes nobody's track.
1670
+ */
1671
+ #noteAudioTrackActive(base, trackIndex, consumerId) {
1672
+ if (!consumerId) {
1673
+ return;
1674
+ }
1675
+ const previous = this.#audioChoiceOf(base, consumerId);
1676
+ if (previous.trackIndex === trackIndex) {
1677
+ return;
1678
+ }
1679
+ this.#host.chooseAudioTrack(base, consumerId, { ...previous, trackIndex });
1680
+ // Every soundtrack nobody present is listening to any more stops being
1681
+ // waited on.
1682
+ const wanted = this.#liveAudioRenditionKeys(base);
1683
+ for (const other of this.#host.outputs.renditionsOf(base)) {
1684
+ if (wanted.has(audioRenditionKey(this.#flatAudioTrackOf(other), other.spec.transcodesAudio))) {
1685
+ continue;
1686
+ }
1687
+ // Requests held on it are for segments nobody will produce now, and the
1688
+ // player stopped waiting for them the moment it changed track.
1689
+ if (this.#host.encodeRuns.liveRunsOf(other).length > 0) {
1690
+ this.#host.invalidateWaits(other);
1691
+ }
1692
+ // Nobody is listening to it any more: this viewer stops watching that
1693
+ // output, on both sides of the relation, and the claim their listening
1694
+ // placed on it is released with them. Its encoder follows from that —
1695
+ // an output with nobody on it has a map with nothing in it — and was
1696
+ // additionally stopped from here, which is the same decision taken twice
1697
+ // by two parties with two rules.
1698
+ this.#host.viewerLeaves(other, consumerId);
1699
+ }
1700
+ }
1701
+
1702
+ /**
1703
+ * The viewers this family has heard from recently enough to still be watching.
1704
+ *
1705
+ * Asked of the whole family, not of one session: a viewer on a quality step
1706
+ * asks that step for its segments, so the picture they started on has not
1707
+ * heard from them since they switched. Their head expires by the same rule the
1708
+ * encoder's own steering uses.
1709
+ *
1710
+ * It is what decides whether an encoder is still wanted, and it is needed
1711
+ * because nothing releases a session when a channel closes (roadmap item 54)
1712
+ * — without it a viewer whose tab is gone would hold a soundtrack or a rung
1713
+ * for the session's whole life.
1714
+ *
1715
+ * @param {HlsSession} base
1716
+ * @returns {Set<string>}
1717
+ */
1718
+ liveConsumers(base) {
1719
+ const live = new Set();
1720
+ for (const member of this.#host.outputs.familyOf(base)) {
1721
+ for (const consumerId of this.#host.presentOn(member)) {
1722
+ live.add(consumerId);
1723
+ }
1724
+ }
1725
+ return live;
1726
+ }
1727
+
1728
+ /**
1729
+ * What one viewer wants of the sound: which soundtrack, and whether their
1730
+ * browser needs it re-encoded.
1731
+ *
1732
+ * @param {HlsSession} base
1733
+ * @param {string} consumerId
1734
+ * @returns {{ trackIndex: number, transcode: boolean }}
1735
+ */
1736
+ #audioChoiceOf(base, consumerId) {
1737
+ const stated = this.#host.audioChoiceOf(base, consumerId);
1738
+ if (stated) {
1739
+ return stated;
1740
+ }
1741
+ // A viewer this session has not heard from by name. The session's own
1742
+ // parameters are the honest fallback: they are what the request that
1743
+ // created it asked for.
1744
+ return {
1745
+ trackIndex: this.#flatAudioTrackOf(base),
1746
+ transcode: base.spec.transcodesAudio
1747
+ };
1748
+ }
1749
+
1750
+ /**
1751
+ * The renditions live viewers are listening to, as the keys they are filed
1752
+ * under.
1753
+ *
1754
+ * A viewer counts while their head is fresh on the picture — the same
1755
+ * expiry the encoder's own steering uses. Without that test a viewer whose
1756
+ * tab was closed without releasing the session (roadmap item 54) would hold
1757
+ * an encoder for the session's whole life.
1758
+ *
1759
+ * @param {HlsSession} base
1760
+ * @returns {Set<string>}
1761
+ */
1762
+ #liveAudioRenditionKeys(base) {
1763
+ const wanted = new Set();
1764
+ const live = this.liveConsumers(base);
1765
+ for (const consumerId of this.#host.consumersOn(base)) {
1766
+ const choice = this.#audioChoiceOf(base, consumerId);
1767
+ // A viewer counts while some session of the family has heard from them.
1768
+ if (live.size > 0 && !live.has(consumerId)) {
1769
+ continue;
1770
+ }
1771
+ wanted.add(audioRenditionKey(choice.trackIndex, choice.transcode));
1772
+ }
1773
+ return wanted;
1774
+ }
1775
+
1776
+ /**
1777
+ * The session producing one audio track of this file, made on first request.
1778
+ *
1779
+ * Filed under the track AND how it has to be produced, because those are two
1780
+ * different encodes: a browser that can decode this track as it stands is
1781
+ * served a copy, and one that cannot is served AAC. The base cannot answer
1782
+ * for either of them now that it is shared — its own `transcodeAudio` is
1783
+ * whatever the first viewer's browser needed.
1784
+ *
1785
+ * @param {HlsSession} base
1786
+ * @param {number} trackIndex
1787
+ * @param {string} consumerId - Who is asking.
1788
+ * @returns {Promise<HlsSession | null>}
1789
+ */
1790
+ async #resolveAudioRenditionSession(base, trackIndex, consumerId = "") {
1791
+ const transcodeAudio = this.#audioChoiceOf(base, consumerId).transcode;
1792
+ // Found by what it IS: a soundtrack of this file, this track, produced this
1793
+ // way. That is what a map from a rendition key to a session id said, at the
1794
+ // price of a link between two sessions' lifetimes — one that had to be
1795
+ // cleaned from the other side when either ended.
1796
+ const already = this.#host.outputs.renditionsOf(base).find(
1797
+ (other) =>
1798
+ this.#flatAudioTrackOf(other) === trackIndex &&
1799
+ other.spec.transcodesAudio === transcodeAudio
1800
+ );
1801
+ if (already) {
1802
+ this.#host.outputs.touch(already);
1803
+ return already;
1804
+ }
1805
+ const rendition = await this.#host.createOrGetSession({
1806
+ sourceKey: base.file.sourceKey,
1807
+ fileIndex: base.file.fileIndex,
1808
+ // No picture at all: the video flag says what to do with a video stream
1809
+ // this output does not carry.
1810
+ transcodeVideo: false,
1811
+ transcodeAudio,
1812
+ fileName: base.file.name,
1813
+ // Opened by nobody in particular, as a step is.
1814
+ consumerId: "",
1815
+ audioTrackIndex: trackIndex,
1816
+ audioOnly: true,
1817
+ // Where the viewer is, so the rendition starts with the picture rather
1818
+ // than at the beginning of the file. Read the same way a quality variant
1819
+ // reads it: the base's own field is only written by a seek or by a
1820
+ // segment IT served, so on a resume-from-position open it is still unset
1821
+ // while the player is asking for segment #537 — and the audio would begin
1822
+ // at zero and never catch up, since nothing treats a far request as a
1823
+ // seek. The accessor falls back to the last segment actually requested.
1824
+ // Where the PICTURE is, not where it has been read to.
1825
+ //
1826
+ // The position this class keeps is written by the segments a session
1827
+ // serves, so it is the READ head, and the viewer's picture sits behind it
1828
+ // by everything the player has buffered. Started at the read head, the
1829
+ // audio run begins AHEAD of the viewer, and every request they then make
1830
+ // is behind a run that only moves forward — field 2026-08-15, placed at
1831
+ // #16 while the player asked for #10, and the audio arrived only after
1832
+ // the encoder was dragged back.
1833
+ //
1834
+ // The distance is measured, not assumed: the browser reports how many
1835
+ // seconds it holds ahead of the picture with every link report, so the
1836
+ // playhead is one subtraction away. A stale report is no use — a viewer
1837
+ // who seeked since then is somewhere else entirely — so an old one is
1838
+ // ignored and the whole look-ahead is subtracted instead, which cannot
1839
+ // leave the run ahead of them.
1840
+ startPositionSeconds: this.#host.audioStartSecondsFor({
1841
+ family: this.#host.outputs.familyOf(base),
1842
+ openedAtSeconds: this.#host.encodeRuns.progressOf(base)?.startPositionSeconds,
1843
+ segmentSeconds: this.#host.segmentDurationSec
1844
+ }),
1845
+ segmentFormatId: base.segmentFormat.id,
1846
+ // Cut where the picture is cut. Two streams meant to be played together
1847
+ // have to be divided at the same times, and the grid is the base's — the
1848
+ // table as it stands now, corrections included. A base on the uniform
1849
+ // grid passes nothing: the rendition computes the same even grid from the
1850
+ // same duration.
1851
+ inheritedGrid: base.timeline.cutGrid === "keyframe"
1852
+ ? {
1853
+ boundaries: base.timeline.boundaries,
1854
+ published: base.timeline.published
1855
+ }
1856
+ : null
1857
+ });
1858
+ return rendition ?? null;
1859
+ }
1860
+
1861
+ /**
1862
+ * Whether a session created with these parameters publishes its sound as its
1863
+ * own stream rather than muxing it into the picture.
1864
+ *
1865
+ * Asked before the session exists, because the session's KEY depends on the
1866
+ * answer: an output that carries no sound must not be told apart by which
1867
+ * soundtrack was asked for, and an output that carries it must be.
1868
+ *
1869
+ * Three conditions, all of them facts about the request and the file rather
1870
+ * than about the machine's load, so the answer cannot move afterwards:
1871
+ *
1872
+ * 1. the browser said it understands rendition groups. One that did not must
1873
+ * be sent its sound inside the picture, or it gets silence;
1874
+ * 2. the file has soundtracks to publish;
1875
+ * 3. there is more than one height to move between, because renditions are
1876
+ * published in a master playlist and a stream served as a single media
1877
+ * playlist has nowhere to carry an `#EXT-X-MEDIA` line.
1878
+ *
1879
+ * Condition 3 is the reason a source too small for a ladder mixes its sound
1880
+ * in as it always did. It is asked of the same ladder the master's rung list
1881
+ * comes from; the realtime budget may still encode below the height named
1882
+ * here, and cannot change the count, because what it picks is a rung of that
1883
+ * same ladder.
1884
+ *
1885
+ * @param {{ sourceKey: string, fileIndex: number, audioRenditions: boolean, ownHeight: number }} params
1886
+ * @returns {boolean}
1887
+ */
1888
+ audioTravelsSeparately({ sourceKey, fileIndex, audioRenditions, ownHeight }) {
1889
+ if (audioRenditions !== true) {
1890
+ return false;
1891
+ }
1892
+ const tracks = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
1893
+ if (!Array.isArray(tracks) || tracks.length === 0) {
1894
+ return false;
1895
+ }
1896
+ // The source's own height, from the probe the playback plan already ran.
1897
+ // Absent, this answers "mix it in" — which is what the later computation
1898
+ // answered too, since a session with no source height has an empty ladder.
1899
+ const sourceHeight = Math.round(Number(this.#host.getCachedMediaInfo?.({ sourceKey, fileIndex })?.height) || 0);
1900
+ const heights = new Set(variantHeightsFor(sourceHeight));
1901
+ if (Number.isInteger(ownHeight) && ownHeight > 0) {
1902
+ heights.add(ownHeight);
1903
+ }
1904
+ return heights.size >= 2;
1905
+ }
1906
+
1907
+ /**
1908
+ * Where one numbered soundtrack actually is: which file of the torrent, and
1909
+ * which `0:a:N` inside it.
1910
+ *
1911
+ * The number is flat across the picture's own tracks and every soundtrack
1912
+ * shipped as a file beside it, so that the browser's menu, the
1913
+ * `audioTrackIndex` on a session request and the `a/<n>/` path a rendition is
1914
+ * published at all mean the same thing. This resolves it, once, from the
1915
+ * inventory the playback plan built — the very list the menu was drawn from,
1916
+ * so the two cannot disagree about what a number means.
1917
+ *
1918
+ * A number the inventory does not describe resolves to the picture's own file
1919
+ * at that index, which is exactly what every session did before soundtracks in
1920
+ * their own files existed: a plan cached by an older build carries no
1921
+ * inventory, and a session created against it must keep working.
1922
+ *
1923
+ * @param {string} sourceKey
1924
+ * @param {number} fileIndex - The PICTURE's file.
1925
+ * @param {number} flatIndex
1926
+ * @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string }}
1927
+ */
1928
+ resolveAudioSource(sourceKey, fileIndex, flatIndex) {
1929
+ const inventory = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
1930
+ const entry = Array.isArray(inventory)
1931
+ ? inventory.find((candidate) => candidate?.index === flatIndex)
1932
+ : null;
1933
+ if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
1934
+ return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "" };
1935
+ }
1936
+ return {
1937
+ fileIndex: entry.fileIndex,
1938
+ sourceTrackIndex: entry.sourceTrackIndex,
1939
+ isSidecar: entry.fileIndex !== fileIndex,
1940
+ name: typeof entry.fileName === "string" ? entry.fileName : "",
1941
+ // The rate the file states for this track, or null; what a viewer's link
1942
+ // is asked to carry for it when it is copied.
1943
+ bitrateKbps: Number.isFinite(entry.bitrateKbps) ? entry.bitrateKbps : null
1944
+ };
1945
+ }
1946
+
1947
+ /**
1948
+ * The browser's flat soundtrack number for the audio carried by this output.
1949
+ *
1950
+ * `OutputSpec` keeps the stable source address: file plus `0:a:N`. The flat
1951
+ * number belongs to the browser menu and is reconstructed from that menu's
1952
+ * inventory when a route needs it. It is therefore not kept as a duplicate
1953
+ * field on the output.
1954
+ *
1955
+ * @param {HlsSession} session
1956
+ * @returns {number}
1957
+ */
1958
+ #flatAudioTrackOf(session) {
1959
+ const audio = session.spec.audio;
1960
+ if (!audio) {
1961
+ return 0;
1962
+ }
1963
+ const tracks = this.#host.getCachedAudioTracks?.({
1964
+ sourceKey: session.file.sourceKey,
1965
+ fileIndex: session.file.fileIndex
1966
+ }) ?? [];
1967
+ const matching = Array.isArray(tracks)
1968
+ ? tracks.find((entry) => entry?.fileIndex === audio.fileIndex && entry?.sourceTrackIndex === audio.trackIndex)
1969
+ : null;
1970
+ return Number.isInteger(matching?.index) ? matching.index : audio.trackIndex;
1971
+ }
1972
+
1973
+ /**
1974
+ * Start reading where a soundtrack file's own timeline begins, if nobody has.
1975
+ *
1976
+ * Read by the container layer from the file's own header — the same 64 KB,
1977
+ * the same reader and the same per-file cache the audio menu's track list
1978
+ * comes from. A container states this, so it is read from the container and
1979
+ * not measured from the media.
1980
+ *
1981
+ * The answer goes onto the FILE, which is where it belongs and which is what
1982
+ * removed the pair of maps this used to keep beside it: a start time held per
1983
+ * `sourceKey:fileIndex` is a fact of that file, and a second store of facts
1984
+ * about files is a second thing that can disagree. Every session of the
1985
+ * soundtrack shares the one object, so a reading that lands after a session
1986
+ * has started is seen by that session too — which is what the spawn path
1987
+ * needed and used to re-read a map for.
1988
+ *
1989
+ * Until 2.73.0 the session spawned an ffmpeg against the proxy's own
1990
+ * `/stream` for it and waited up to eight seconds for the banner. Field
1991
+ * 2026-09-03: that read cost 8121 ms of a cold start, three times out of
1992
+ * three, while the container layer had read the same header of the same file
1993
+ * in 8 ms in the same second. The eight seconds were not even spent on the
1994
+ * answer — the early exit was gated on a DURATION, and a partly downloaded
1995
+ * file prints `Duration: N/A` with the start time on that very line.
1996
+ *
1997
+ * Runs behind whoever asked, so no viewer waits for it. Once per file per
1998
+ * process: a container's start time is a property of the file and cannot
1999
+ * change. A reading that comes back without an answer is NOT remembered — the
2000
+ * file may simply not have been downloaded far enough yet, and the next
2001
+ * session asks again.
2002
+ *
2003
+ * @param {SourceFile} file - The soundtrack's own file.
2004
+ * @returns {void}
2005
+ */
2006
+ warmFileStartTime(file) {
2007
+ if (typeof this.#host.getContainerMediaInfo !== "function") {
2008
+ return;
2009
+ }
2010
+ if (!(this.#host.fileStartTimeReads instanceof Set)) {
2011
+ this.#host.fileStartTimeReads = new Set();
2012
+ }
2013
+ if (file.media?.startTime !== undefined || this.#host.fileStartTimeReads.has(file.key)) {
2014
+ return;
2015
+ }
2016
+ this.#host.fileStartTimeReads.add(file.key);
2017
+ void Promise.resolve(
2018
+ this.#host.getContainerMediaInfo({ sourceKey: file.sourceKey, fileIndex: file.fileIndex })
2019
+ )
2020
+ .then((info) => {
2021
+ if (info && Number.isFinite(info.startTimeSeconds)) {
2022
+ file.learn({ startTime: info.startTimeSeconds });
2023
+ this.#host.logger.info(
2024
+ `transcode: soundtrack file ${file.fileIndex}'s own timeline starts at ` +
2025
+ `${info.startTimeSeconds.toFixed(6)}s, read from its header`
2026
+ );
2027
+ }
2028
+ })
2029
+ .catch((error) => {
2030
+ this.#host.logger.info(
2031
+ `transcode: the start of soundtrack file ${file.fileIndex}'s timeline could not be read ` +
2032
+ `(${error instanceof Error ? error.message : String(error)}) — the two timelines are ` +
2033
+ "taken to agree until it can be"
2034
+ );
2035
+ })
2036
+ .finally(() => {
2037
+ this.#host.fileStartTimeReads.delete(file.key);
2038
+ });
2039
+ }
2040
+
2041
+ /**
2042
+ * The audio tracks of this session's file, as renditions for the master.
2043
+ *
2044
+ * Taken from the inventory the playback plan already probed — the same list
2045
+ * the browser's audio menu is built from — so nothing is probed again here.
2046
+ * The track the session was created with is the default one: it is what the
2047
+ * viewer chose (or the file's first track), and a master that defaulted to
2048
+ * something else would change the language on its own.
2049
+ *
2050
+ * @param {HlsSession} session
2051
+ * @param {number} [chosenTrack] - The track to mark as the default one.
2052
+ * Defaults to the session's own, which is what a caller that is only
2053
+ * counting the renditions wants.
2054
+ * @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
2055
+ */
2056
+ #audioRenditionsOf(session, chosenTrack) {
2057
+ const tracks = this.#host.getCachedAudioTracks?.({
2058
+ sourceKey: session.file.sourceKey,
2059
+ // The PICTURE's file, which is what `file` is on every session of a
2060
+ // family — a rendition is created with its base's, and only its
2061
+ // `audioFile` points at the file its sound comes from. The inventory is
2062
+ // keyed on the picture and spans the soundtracks beside it.
2063
+ fileIndex: session.file.fileIndex
2064
+ }) ?? [];
2065
+ if (!Array.isArray(tracks) || tracks.length === 0) {
2066
+ return [];
2067
+ }
2068
+ const chosen = Number.isInteger(chosenTrack) ? chosenTrack : this.#flatAudioTrackOf(session);
2069
+ // One line per entry of the inventory, in its order and without omissions —
2070
+ // including a track the container marks unusable. The player addresses a
2071
+ // rendition by its POSITION in this list, and the browser addresses it by
2072
+ // the number the inventory gave it; leaving anything out would make those two
2073
+ // disagree from that point on. A track the file says not to offer is kept out
2074
+ // of the VIEWER's menu, which is the browser's own business and does not
2075
+ // touch the numbering.
2076
+ return tracks.map((entry, order) => {
2077
+ const index = Number.isInteger(entry?.index) ? entry.index : order;
2078
+ const language = typeof entry?.languageBcp47 === "string" && entry.languageBcp47.length > 0
2079
+ ? entry.languageBcp47
2080
+ : (typeof entry?.language === "string" ? entry.language : "");
2081
+ return {
2082
+ trackIndex: index,
2083
+ name: this.#host.audioRenditionName(
2084
+ { ...entry, index, folders: Array.isArray(entry?.folders) ? entry.folders : [] },
2085
+ tracks
2086
+ ),
2087
+ // Only what the container itself states. What a folder name suggests
2088
+ // about a language is derived in the browser, where the language table
2089
+ // and the viewer's own locale already are; writing a guess into
2090
+ // `LANGUAGE` would put it in a playlist as though the file had said it.
2091
+ language,
2092
+ isDefault: index === chosen
2093
+ };
2094
+ });
2095
+ }
2096
+
2097
+ /**
2098
+ * Where a person stands when they come onto a rung, in seconds.
2099
+ *
2100
+ * A stated position when there is one — the picture's, sent with the switch,
2101
+ * or the start of the segment the player asked a new rung for. Otherwise the
2102
+ * position they have stated on the rung they are on. Never the rung's READ
2103
+ * head, which sits a whole buffer further on.
2104
+ *
2105
+ * @param {HlsSession} base
2106
+ * @param {number} [positionSeconds] - A stated position, when there is one.
2107
+ * @param {string} [consumerId]
2108
+ * @returns {number}
2109
+ */
2110
+ #variantStartSeconds(base, positionSeconds, consumerId = "") {
2111
+ if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
2112
+ return positionSeconds;
2113
+ }
2114
+ return this.#host.viewerSecondsOn(this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }));
2115
+ }
2116
+
2117
+ /**
2118
+ * The track set this session's output will carry — what the proxy DECLARES,
2119
+ * and the one answer both sides must agree on.
2120
+ *
2121
+ * The source may hold any number of tracks: several dubs, subtitles, even a
2122
+ * cover-art video stream. The output does not inherit that list — the command
2123
+ * maps at most one video and at most one audio, each optional, and subtitles
2124
+ * never enter the HLS output at all (they are served separately as WebVTT).
2125
+ * So this is not an inference about the file; it is the proxy stating what it
2126
+ * chose to produce.
2127
+ *
2128
+ * Used in two places, and that is the point: the init segment is checked
2129
+ * against it here, and it is sent to the browser so the browser can check
2130
+ * what it actually received against the same statement. Without the second
2131
+ * check a missing track is only noticed by its absence, minutes later, as a
2132
+ * black picture with working sound.
2133
+ *
2134
+ * @param {HlsSession} session
2135
+ * @returns {{ video: boolean, audio: boolean }}
2136
+ */
2137
+ declaredTracks(session) {
2138
+ const probed = this.#host.getCachedMediaInfo?.({
2139
+ sourceKey: session.file.sourceKey,
2140
+ fileIndex: session.file.fileIndex
2141
+ }) ?? null;
2142
+ // What the SOURCE has, narrowed to what this session's output carries. A
2143
+ // rendition maps only audio and a stream whose audio travels separately
2144
+ // maps only video, so answering from the source alone would tell the
2145
+ // browser about a track that is not in the stream, and would leave
2146
+ // `#initFromFirstSegment` waiting for a second track that no init will ever
2147
+ // declare — its warning about a short header would then fire on every one.
2148
+ const carriesVideo = session.spec.carries !== "audio-only";
2149
+ const carriesAudio = !this.servesAudioSeparately(session);
2150
+ // The soundtrack of this session may not be in the file that was probed. A
2151
+ // release that ships its dub as a separate file often ships the picture with
2152
+ // no sound of its own at all, and then the picture's probe says there is no
2153
+ // audio while the output plainly carries some — which would leave the header
2154
+ // check expecting one track where two arrive, and tell the browser its sound
2155
+ // was lost.
2156
+ const audioFromAnotherFile = session.spec.audioFileIndex !== session.file.fileIndex;
2157
+ return {
2158
+ video: carriesVideo && Boolean(probed?.videoCodec),
2159
+ audio: carriesAudio && (audioFromAnotherFile || Boolean(probed?.audioCodec))
2160
+ };
2161
+ }
2162
+ }