@torrent-tv/proxy 2.89.7 → 2.89.9

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