@torrent-tv/proxy 2.88.4 → 2.88.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (489) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2269 -2258
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -0
  19. package/assets/calibration/audio/aac.m4a +0 -0
  20. package/assets/calibration/audio/ac3.ac3 +0 -0
  21. package/assets/calibration/audio/alac.m4a +0 -0
  22. package/assets/calibration/audio/dts.dts +0 -0
  23. package/assets/calibration/audio/eac3.eac3 +0 -0
  24. package/assets/calibration/audio/flac.flac +0 -0
  25. package/assets/calibration/audio/manifest.json +98 -0
  26. package/assets/calibration/audio/mp2.mp2 +0 -0
  27. package/assets/calibration/audio/mp3.mp3 +0 -0
  28. package/assets/calibration/audio/opus.opus +0 -0
  29. package/assets/calibration/audio/pcm.wav +0 -0
  30. package/assets/calibration/audio/truehd.thd +0 -0
  31. package/assets/calibration/audio/vorbis.ogg +0 -0
  32. package/assets/diagnostics/sctpstate.gdb +31 -31
  33. package/bin/cli.js +684 -684
  34. package/bin/thread-pool.js +30 -30
  35. package/biome.json +412 -411
  36. package/docs/browser-proxy-contract.md +155 -152
  37. package/docs/container-architecture.md +218 -218
  38. package/docs/download-architecture.md +261 -261
  39. package/docs/encode-architecture.md +729 -729
  40. package/docs/encode-run-state.md +96 -96
  41. package/docs/logs.md +97 -97
  42. package/docs/playback-start-forecast.md +56 -0
  43. package/docs/socks5-egress-research.md +114 -114
  44. package/docs/storage-architecture.md +267 -267
  45. package/knip.json +14 -14
  46. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  47. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  48. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  49. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  50. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  51. package/openspec/changes/binary-distribution/proposal.md +53 -53
  52. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  53. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  54. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  55. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  56. package/openspec/changes/cold-start/design.md +98 -98
  57. package/openspec/changes/cold-start/proposal.md +62 -62
  58. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  59. package/openspec/changes/cold-start/tasks.md +48 -48
  60. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  61. package/openspec/changes/disk-cap/proposal.md +37 -37
  62. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  63. package/openspec/changes/disk-cap/tasks.md +19 -19
  64. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  65. package/openspec/changes/proxy-observability/design.md +38 -38
  66. package/openspec/changes/proxy-observability/proposal.md +49 -49
  67. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  68. package/openspec/changes/proxy-observability/tasks.md +21 -21
  69. package/openspec/changes/seek-debounce/design.md +118 -118
  70. package/openspec/changes/seek-debounce/proposal.md +59 -59
  71. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  72. package/openspec/changes/seek-debounce/tasks.md +33 -33
  73. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  74. package/openspec/changes/subtitle-language/proposal.md +50 -50
  75. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  76. package/openspec/changes/subtitle-language/tasks.md +26 -26
  77. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  78. package/openspec/changes/track-selection/design.md +47 -47
  79. package/openspec/changes/track-selection/proposal.md +45 -45
  80. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  81. package/openspec/changes/track-selection/tasks.md +20 -20
  82. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  83. package/openspec/changes/transcode-quality/proposal.md +51 -51
  84. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  85. package/openspec/changes/transcode-quality/tasks.md +63 -63
  86. package/openspec/config.yaml +38 -38
  87. package/package.json +1 -1
  88. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  89. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  90. package/research/double-spawn-2026-09-10.md +171 -171
  91. package/research/handover-2026-09-13.md +209 -209
  92. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  93. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  94. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  95. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  96. package/routes/api/client-logs/post.js +86 -86
  97. package/routes/api/delivery-sink/get.js +72 -72
  98. package/routes/api/link-probe/get.js +51 -51
  99. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  100. package/routes/api/playback-plan/post.js +53 -53
  101. package/routes/api/sources/post.js +36 -36
  102. package/routes/api/sources/stats/get.js +132 -132
  103. package/routes/api/sources/warm/post.js +217 -217
  104. package/routes/api/subtitles/get.js +230 -230
  105. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  106. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  107. package/routes/api/transcode-sessions/post.js +178 -178
  108. package/routes/api/transcode-sessions/progress/get.js +27 -27
  109. package/routes/api/transcode-sessions/release/post.js +41 -41
  110. package/routes/api/transcode-sessions/seek/post.js +63 -63
  111. package/routes/health/get.js +13 -13
  112. package/routes/healthz/get.js +13 -13
  113. package/routes/stream/get.js +380 -380
  114. package/routes/transcode/audio-file/get.js +64 -64
  115. package/routes/transcode/audio-warm/get.js +95 -95
  116. package/routes/transcode/session-file/get.js +52 -52
  117. package/routes/transcode/variant-file/get.js +95 -95
  118. package/routes/transcode/variant-warm/get.js +84 -84
  119. package/scripts/build-audio-samples.mjs +31 -0
  120. package/scripts/render-run-graph.js +173 -173
  121. package/server.js +730 -725
  122. package/services/encode/CoverageMap.js +428 -428
  123. package/services/encode/CushionReport.js +345 -345
  124. package/services/encode/EncodeAdmission.js +376 -376
  125. package/services/encode/EncodeOrchestrator.js +1217 -1217
  126. package/services/encode/EncodePlan.js +1366 -1366
  127. package/services/encode/EncodeRun.js +813 -813
  128. package/services/encode/EncodeRuns.js +1239 -1235
  129. package/services/encode/Encoder.js +142 -142
  130. package/services/encode/EncoderSelection.js +38 -38
  131. package/services/encode/LocalObservations.js +359 -359
  132. package/services/encode/NvencEncoder.js +50 -50
  133. package/services/encode/OutputOpening.js +783 -783
  134. package/services/encode/OutputTimes.js +579 -579
  135. package/services/encode/QsvEncoder.js +58 -58
  136. package/services/encode/Renditions.js +2282 -2282
  137. package/services/encode/RunProgress.js +84 -84
  138. package/services/encode/SegmentDemand.js +95 -95
  139. package/services/encode/SoftwareEncoder.js +121 -121
  140. package/services/encode/V4l2m2mEncoder.js +53 -53
  141. package/services/encode/VaapiEncoder.js +66 -66
  142. package/services/encode/args.js +418 -418
  143. package/services/encode/audio-calibration.js +67 -0
  144. package/services/encode/audio-work-rate.js +15 -0
  145. package/services/encode/available-share.js +89 -89
  146. package/services/encode/calibration.js +361 -361
  147. package/services/encode/contention.js +128 -128
  148. package/services/encode/decode-cost-fit.js +363 -363
  149. package/services/encode/encode-exit.js +114 -114
  150. package/services/encode/encode-run-state.js +551 -551
  151. package/services/encode/encoder-readings.js +45 -45
  152. package/services/encode/fingerprint.js +322 -322
  153. package/services/encode/host-load.js +174 -174
  154. package/services/encode/hwaccel.js +1894 -1894
  155. package/services/encode/index.js +9 -9
  156. package/services/encode/learned-median.js +90 -90
  157. package/services/encode/output/EncodedOutput.js +57 -57
  158. package/services/encode/output/Output.js +131 -131
  159. package/services/encode/output/OutputCatalog.js +414 -414
  160. package/services/encode/output/OutputSpec.js +390 -390
  161. package/services/encode/output/Timeline.js +308 -308
  162. package/services/encode/output/cut-grid.js +107 -107
  163. package/services/encode/output/index.js +24 -20
  164. package/services/encode/output/ladder.js +32 -32
  165. package/services/encode/output/playlists.js +284 -284
  166. package/services/encode/output/rates.js +125 -125
  167. package/services/encode/output-key-format.js +21 -21
  168. package/services/encode/quality/EncodeCost.js +1167 -1150
  169. package/services/encode/quality/HostLoad.js +525 -525
  170. package/services/encode/quality/HostTimings.js +249 -249
  171. package/services/encode/quality/OutputQualityState.js +28 -28
  172. package/services/encode/quality/QualityController.js +935 -935
  173. package/services/encode/quality/QualityOffer.js +416 -416
  174. package/services/encode/quality/drain-threat.js +47 -47
  175. package/services/encode/quality/link-budget.js +349 -349
  176. package/services/encode/quality/output-format.js +319 -319
  177. package/services/encode/quality/serving-output.js +216 -216
  178. package/services/encode/quality/visible-rung.js +57 -57
  179. package/services/encode/run-budget.js +97 -97
  180. package/services/encode/run-command.js +755 -754
  181. package/services/encode/run-costs.js +155 -155
  182. package/services/encode/run-inputs.js +42 -42
  183. package/services/encode/segment-formats/fmp4.js +458 -456
  184. package/services/encode/segment-formats/index.js +85 -85
  185. package/services/encode/segment-formats/init-compat.js +324 -324
  186. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  187. package/services/encode/segment-formats/mpegts.js +134 -134
  188. package/services/encode/start-stop-cost.js +178 -178
  189. package/services/encode/throughput.js +97 -97
  190. package/services/encode/torrent-cost.js +88 -88
  191. package/services/identity/installation-id.js +38 -38
  192. package/services/media/ContainerOrchestrator.js +149 -149
  193. package/services/media/KeyframeTables.js +316 -316
  194. package/services/media/SourceFile.js +298 -298
  195. package/services/media/SubtitleCues.js +520 -520
  196. package/services/media/SubtitleOrchestrator.js +167 -167
  197. package/services/media/audio-inventory.js +299 -298
  198. package/services/media/container/AviContainer.js +266 -266
  199. package/services/media/container/Container.js +609 -609
  200. package/services/media/container/ContainerFactory.js +56 -56
  201. package/services/media/container/KeyframeTable.js +272 -272
  202. package/services/media/container/MatroskaContainer.js +1494 -1494
  203. package/services/media/container/Mp4Container.js +1344 -1344
  204. package/services/media/container/ebml-reader.js +159 -159
  205. package/services/media/ffmpeg-banner.js +260 -260
  206. package/services/media/keyframe-probe.js +157 -157
  207. package/services/media/media-info-probe.js +93 -93
  208. package/services/media/playback-planner.js +931 -931
  209. package/services/media/tracks/AudioTrack.js +143 -143
  210. package/services/media/tracks/ContainerTrack.js +75 -75
  211. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  212. package/services/media/tracks/SubtitleTrack.js +38 -38
  213. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  214. package/services/media/tracks/VideoTrack.js +33 -33
  215. package/services/media/tracks/audio-bound.js +233 -233
  216. package/services/media/tracks/index.js +15 -15
  217. package/services/media/tracks/language-detect.js +174 -174
  218. package/services/server/OutputLifecycle.js +418 -418
  219. package/services/server/SegmentServing.js +1150 -1150
  220. package/services/server/ViewerRequests.js +786 -760
  221. package/services/server/WaitLedger.js +142 -142
  222. package/services/server/controllers/PlaybackController.js +40 -40
  223. package/services/server/controllers/SubtitleController.js +109 -109
  224. package/services/server/http-responses.js +60 -60
  225. package/services/server/playback-readiness.js +516 -708
  226. package/services/server/transcode-session-files.js +325 -325
  227. package/services/server/wire-outputs.js +1020 -995
  228. package/services/storage/Diagnostics.js +202 -202
  229. package/services/storage/MachineBudget.js +235 -235
  230. package/services/storage/allowance.js +107 -107
  231. package/services/storage/core-dumps.js +106 -106
  232. package/services/storage/files/CompletedFiles.js +391 -391
  233. package/services/storage/files/piece-from-whole-file.js +118 -118
  234. package/services/storage/free.js +71 -71
  235. package/services/storage/keep.js +48 -48
  236. package/services/storage/machine-memory.js +56 -56
  237. package/services/storage/memory-report.js +575 -575
  238. package/services/storage/piece-store/piece-disk-store.js +539 -539
  239. package/services/storage/piece-store/piece-lru.js +495 -495
  240. package/services/storage/piece-store/shared-piece-store.js +1937 -1931
  241. package/services/storage/returns.js +103 -103
  242. package/services/storage/segment-store/SegmentStore.js +1255 -1240
  243. package/services/storage/wire.js +175 -175
  244. package/services/torrent/demand/DemandRegister.js +202 -202
  245. package/services/torrent/demand/Urgency.js +137 -137
  246. package/services/torrent/demand/Window.js +118 -118
  247. package/services/torrent/demand/index.js +8 -8
  248. package/services/torrent/demand/pieces.js +140 -140
  249. package/services/torrent/download/SwarmSelection.js +386 -386
  250. package/services/torrent/download/registry.js +102 -102
  251. package/services/torrent/download/withdraw-claim.js +80 -80
  252. package/services/torrent/files.js +455 -455
  253. package/services/torrent/naming.js +619 -619
  254. package/services/torrent/piece-store-of.js +29 -29
  255. package/services/torrent/supply-margin.js +262 -262
  256. package/services/torrent/torrent-pool.js +2688 -2675
  257. package/services/torrent/worker/background-fill.js +213 -213
  258. package/services/torrent/worker/channel.js +271 -271
  259. package/services/torrent/worker/client.js +765 -765
  260. package/services/torrent/worker/fastest-wires.js +342 -342
  261. package/services/torrent/worker/handle-state.js +23 -23
  262. package/services/torrent/worker/held-bytes.js +155 -155
  263. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  264. package/services/torrent/worker/piece-reader.js +1078 -1078
  265. package/services/torrent/worker/pool-adapter.js +371 -371
  266. package/services/torrent/worker/protocol.js +184 -184
  267. package/services/torrent/worker/resume-warm.js +107 -107
  268. package/services/torrent/worker/webrtc-shim.js +219 -219
  269. package/services/torrent/worker/worker.js +1129 -1129
  270. package/services/transport/data-channel-handler.js +1369 -1369
  271. package/services/transport/delivery-probe.js +842 -842
  272. package/services/transport/health-collector.js +53 -53
  273. package/services/transport/nat-classifier.js +231 -231
  274. package/services/transport/packet-witness.js +808 -808
  275. package/services/transport/port-mapper.js +235 -235
  276. package/services/transport/registry-api.js +81 -81
  277. package/services/transport/tunnel-client.js +447 -447
  278. package/services/transport/usrsctp-state.js +175 -175
  279. package/services/transport/webrtc-manager.js +484 -484
  280. package/services/viewer/Assignments.js +340 -340
  281. package/services/viewer/PriorityMap.js +390 -390
  282. package/services/viewer/PriorityOrchestrator.js +325 -325
  283. package/services/viewer/Viewer.js +691 -691
  284. package/services/viewer/Viewers.js +368 -368
  285. package/services/viewer/active-output.js +37 -37
  286. package/services/viewer/audio-start.js +46 -46
  287. package/services/viewer/buffer-trend.js +81 -81
  288. package/services/viewer/choices.js +647 -647
  289. package/services/viewer/positions.js +126 -126
  290. package/services/viewer/report-intake.js +46 -46
  291. package/store/source-registry.js +71 -71
  292. package/test/asset-paths.test.js +28 -28
  293. package/test/audio-calibration.test.js +23 -0
  294. package/test/audio-inventory.test.js +212 -212
  295. package/test/audio-track-bitrate.test.js +40 -40
  296. package/test/auto-quality-step.test.js +707 -707
  297. package/test/available-share.test.js +76 -76
  298. package/test/background-fill.test.js +190 -190
  299. package/test/client-log-file.test.js +110 -110
  300. package/test/completed-files.test.js +115 -115
  301. package/test/concurrent-cost.test.js +148 -148
  302. package/test/container-media-info.test.js +228 -228
  303. package/test/contention.test.js +76 -76
  304. package/test/core-dumps.test.js +57 -57
  305. package/test/coverage-follows-the-disk.test.js +228 -228
  306. package/test/coverage-map.test.js +195 -195
  307. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  308. package/test/cut-times-timeline.test.js +65 -65
  309. package/test/cuts-follow-published-grid.test.js +109 -109
  310. package/test/data-channel-frame.test.js +63 -63
  311. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  312. package/test/dead-torrent-handle.test.js +52 -52
  313. package/test/declared-rates.test.js +116 -116
  314. package/test/declared-tracks.test.js +43 -43
  315. package/test/decode-cost-fit.test.js +202 -202
  316. package/test/decode-cost.test.js +462 -462
  317. package/test/delivery-probe.test.js +393 -393
  318. package/test/demand-register.test.js +206 -206
  319. package/test/dht-bootstrap.test.js +72 -72
  320. package/test/drain-threat.test.js +65 -65
  321. package/test/duplicate-add.test.js +48 -48
  322. package/test/encode-admission.test.js +146 -146
  323. package/test/encode-cost.test.js +283 -283
  324. package/test/encode-exit.test.js +111 -111
  325. package/test/encode-orchestrator.test.js +419 -419
  326. package/test/encode-plan-viewers.test.js +718 -718
  327. package/test/encode-plan.test.js +545 -545
  328. package/test/encode-run-state.test.js +314 -314
  329. package/test/encode-run.test.js +162 -162
  330. package/test/encode-slope.test.js +46 -46
  331. package/test/encoded-output.test.js +39 -39
  332. package/test/encoder-kinds.test.js +122 -122
  333. package/test/encoder-readings.test.js +46 -46
  334. package/test/every-claimant-is-told-its-share.test.js +152 -152
  335. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  336. package/test/failed-start-breaker.test.js +202 -202
  337. package/test/fastest-wires.test.js +143 -143
  338. package/test/ffmpeg-stream-counts.test.js +78 -78
  339. package/test/file-edges.test.js +191 -191
  340. package/test/fmp4-presentation-clock.test.js +162 -147
  341. package/test/fmp4-self-contained.test.js +80 -80
  342. package/test/fmp4-timestamps.test.js +140 -140
  343. package/test/fmp4-track-completeness.test.js +41 -41
  344. package/test/format-keyed-outputs.test.js +78 -78
  345. package/test/health-metrics.test.js +38 -38
  346. package/test/held-request-width.test.js +157 -157
  347. package/test/helpers/encode-run.js +136 -136
  348. package/test/helpers/held-file.js +29 -29
  349. package/test/helpers/manager.js +57 -57
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +102 -102
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +112 -112
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +292 -292
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/memory-budget.test.js +390 -390
  372. package/test/move-cost.test.js +216 -216
  373. package/test/mp4-subtitles.test.js +173 -173
  374. package/test/one-authority.test.js +285 -285
  375. package/test/one-budget.test.js +149 -149
  376. package/test/one-output-one-session.test.js +273 -270
  377. package/test/one-resource-divided.test.js +185 -185
  378. package/test/one-serving-output-per-height.test.js +458 -458
  379. package/test/orchestrator-wired.test.js +206 -206
  380. package/test/output-catalog.test.js +39 -39
  381. package/test/output-shape.test.js +21 -21
  382. package/test/output-spec.test.js +251 -251
  383. package/test/output-speed.test.js +88 -88
  384. package/test/outputs-being-prepared.test.js +40 -40
  385. package/test/packet-witness-ring.test.js +236 -236
  386. package/test/packet-witness.test.js +148 -148
  387. package/test/piece-disk-store.test.js +401 -401
  388. package/test/piece-from-whole-file.test.js +129 -129
  389. package/test/piece-lru.test.js +319 -319
  390. package/test/piece-reader.test.js +162 -162
  391. package/test/piece-selection-offset.test.js +85 -85
  392. package/test/piece-store-eviction.test.js +595 -595
  393. package/test/piece-store-never-refuses.test.js +153 -153
  394. package/test/piece-store-reservations.test.js +403 -403
  395. package/test/piece-store-slow-disk.test.js +159 -159
  396. package/test/piece-tail.test.js +97 -97
  397. package/test/plan-host-timings.test.js +68 -68
  398. package/test/plan-is-steady.test.js +225 -225
  399. package/test/playback-readiness.test.js +483 -453
  400. package/test/playback-start-regressions.test.js +99 -99
  401. package/test/priority-map-download.test.js +174 -174
  402. package/test/priority-map-emptied.test.js +204 -204
  403. package/test/priority-map-per-output.test.js +215 -215
  404. package/test/priority-map.test.js +163 -163
  405. package/test/probe-wedge-certainty.test.js +60 -60
  406. package/test/produced-copy-choice.test.js +213 -213
  407. package/test/published-timeline.test.js +83 -83
  408. package/test/quality-offer.test.js +162 -162
  409. package/test/quality-variants.test.js +1257 -1257
  410. package/test/rate-control-in-the-output.test.js +180 -180
  411. package/test/read-survives-withdrawal.test.js +164 -164
  412. package/test/read-window.test.js +365 -365
  413. package/test/resume-warm.test.js +39 -39
  414. package/test/run-graph-drift.test.js +37 -37
  415. package/test/run-inputs.test.js +80 -80
  416. package/test/run-intervals.test.js +100 -100
  417. package/test/run-position-follows-published-grid.test.js +89 -89
  418. package/test/run-progress.test.js +65 -65
  419. package/test/same-height-switch.test.js +664 -664
  420. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  421. package/test/seek-landing.test.js +109 -109
  422. package/test/seek-target-not-superseded.test.js +195 -195
  423. package/test/segment-closed-on-both-branches.test.js +96 -96
  424. package/test/segment-cut-times.test.js +63 -63
  425. package/test/segment-demand.test.js +80 -80
  426. package/test/segment-hold-supersede.test.js +91 -91
  427. package/test/segment-judged-by-its-own-header.test.js +221 -221
  428. package/test/segment-serve-wiring.test.js +475 -475
  429. package/test/segment-store-eviction.test.js +247 -247
  430. package/test/segment-store.test.js +352 -323
  431. package/test/segment-true-start.test.js +86 -86
  432. package/test/segments-are-shared.test.js +176 -176
  433. package/test/send-chunk-bytes.test.js +86 -86
  434. package/test/serving-existing-output.test.js +105 -105
  435. package/test/serving-output.test.js +102 -102
  436. package/test/shared-piece-store.test.js +310 -310
  437. package/test/sidecar-naming.test.js +142 -142
  438. package/test/soundtrack-rate-bound.test.js +289 -289
  439. package/test/source-file.test.js +133 -133
  440. package/test/source-registry.test.js +18 -18
  441. package/test/split-a-stretch.test.js +139 -139
  442. package/test/stale-request-after-seek.test.js +158 -158
  443. package/test/startup-readings.test.js +124 -124
  444. package/test/stream-route.test.js +209 -209
  445. package/test/subtitle-cue-framing.test.js +200 -200
  446. package/test/subtitle-cue-source.test.js +102 -102
  447. package/test/subtitle-cue-walk.test.js +370 -370
  448. package/test/subtitle-cursor.test.js +63 -63
  449. package/test/subtitle-defaults.test.js +118 -118
  450. package/test/subtitle-language.test.js +252 -252
  451. package/test/subtitle-track-numbering.test.js +386 -386
  452. package/test/supply-margin.test.js +177 -177
  453. package/test/swarm-follows-readers.test.js +174 -174
  454. package/test/swarm-reach.test.js +130 -130
  455. package/test/swarm-selection.test.js +220 -220
  456. package/test/tail-duplication.test.js +167 -167
  457. package/test/the-channel-has-both-ends.test.js +91 -91
  458. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  459. package/test/thread-pool.test.js +53 -53
  460. package/test/timeline.test.js +84 -84
  461. package/test/torrent-contents.test.js +235 -235
  462. package/test/torrent-cost.test.js +71 -71
  463. package/test/torrent-files.test.js +221 -221
  464. package/test/torrent-naming.test.js +255 -255
  465. package/test/tracks-begin-together.test.js +209 -209
  466. package/test/tunnel-renewal.test.js +167 -167
  467. package/test/two-viewers-one-picture.test.js +504 -504
  468. package/test/upload-hurry.test.js +186 -186
  469. package/test/usrsctp-state.test.js +131 -131
  470. package/test/video-facts.test.js +102 -102
  471. package/test/viewer-assignments.test.js +143 -143
  472. package/test/viewer-outputs.test.js +285 -285
  473. package/test/viewer-presence.test.js +116 -116
  474. package/test/viewer-requests-admission.test.js +89 -89
  475. package/test/viewer-states.test.js +189 -189
  476. package/test/viewer-subtitle-subscription.test.js +96 -96
  477. package/test/viewer.test.js +139 -139
  478. package/test/viewing-generations.test.js +390 -390
  479. package/test/visible-rung.test.js +37 -37
  480. package/test/wait-ledger.test.js +100 -100
  481. package/test/webrtc-shim.test.js +149 -149
  482. package/test/wedge-certainty.test.js +131 -131
  483. package/test/withdraw-piece-claim.test.js +212 -212
  484. package/test/worker-channel.test.js +158 -158
  485. package/utils/client-log-file.js +151 -151
  486. package/utils/logger.js +312 -312
  487. package/utils/parse-range.js +25 -25
  488. package/utils/perf.js +59 -59
  489. package/utils/torrent-source-key.js +38 -38
@@ -1,2282 +1,2282 @@
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) {
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
- readWindowBytes: this.#host.encodeRuns.readWindowFor(session),
1674
- baseUrl: this.#host.localBaseUrl
1675
- });
1676
- }
1677
-
1678
- /**
1679
- * One file of an audio rendition: its playlist, its init segment or one of
1680
- * its segments.
1681
- *
1682
- * A rendition is an ordinary session underneath — same source, same file,
1683
- * same cut grid, one audio track and no picture — created on the first
1684
- * request for it, exactly as a quality variant is. What differs is that the
1685
- * player fetches it ALONGSIDE a variant rather than instead of one, so both
1686
- * encoders run: a rung and the audio it is played with.
1687
- *
1688
- * @param {string} baseSessionId
1689
- * @param {number} trackIndex
1690
- * @param {string} fileName
1691
- * @param {string} [consumerId] - Who is asking. One picture serves everyone
1692
- * watching it, and which soundtrack they are listening to is theirs alone.
1693
- * @returns {Promise<{ sessionId: string | null, error?: string }>}
1694
- */
1695
- async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName, consumerId = "") {
1696
- if (!isOutputName(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
1697
- return { sessionId: null };
1698
- }
1699
- const base = this.#host.outputs.get(baseSessionId);
1700
- if (!base || !this.servesAudioSeparately(base)) {
1701
- return { sessionId: null };
1702
- }
1703
- const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1704
- const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
1705
- const isSegment = base.segmentFormat.isSegmentFileName(fileName);
1706
- if (!isPlaylist && !isInit && !isSegment) {
1707
- return { sessionId: null };
1708
- }
1709
- if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
1710
- return { sessionId: null };
1711
- }
1712
- // The playlist is answered from the base, for the same reason a variant's
1713
- // is: every rendition of a file has the same boundaries and the same
1714
- // duration — they are cut on one grid — and the player fetches the playlist
1715
- // of tracks it may never select. Starting an encoder for each would put as
1716
- // many encoders on the host as the file has languages.
1717
- if (isPlaylist) {
1718
- return { sessionId: base.id };
1719
- }
1720
- let rendition;
1721
- try {
1722
- rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
1723
- } catch (error) {
1724
- const message = error instanceof Error ? error.message : String(error);
1725
- this.#host.logger.error(
1726
- `transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
1727
- (error instanceof Error && error.stack ? `\n${error.stack}` : "")
1728
- );
1729
- return { sessionId: null, error: message };
1730
- }
1731
- if (rendition && consumerId) {
1732
- // Asking for ANY file of this soundtrack is this viewer watching it, and
1733
- // the init is the file they ask for first. Registered here rather than on
1734
- // the segment alone, because the segment cannot be asked for until the
1735
- // init has been served, and the init cannot be made unless somebody is
1736
- // watching: that circle is what left a soundtrack with no encoder, no
1737
- // init and a viewer waiting sixty seconds on 2026-09-05.
1738
- //
1739
- // WHERE they are on it is where they are on the picture: the two are
1740
- // played together.
1741
- this.#host.placeViewerOn(rendition, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
1742
- }
1743
- if (isSegment && rendition) {
1744
- this.#noteAudioTrackActive(base, rendition, consumerId);
1745
- }
1746
- return { sessionId: rendition?.id ?? null };
1747
- }
1748
-
1749
- /**
1750
- * A SEGMENT of this track is what says the viewer is listening to it — the
1751
- * player fetches the playlist and the init of tracks it may never choose.
1752
- *
1753
- * Every other track is then stopped. Each one is an ffmpeg process AND a
1754
- * reader holding pieces of the torrent in memory, and the store can only
1755
- * spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
1756
- * track once had three readers on one file — picture, the track they chose
1757
- * and the track they left — and at a seek all three revived their windows at
1758
- * once, every resident piece was pinned, a read ended with zero bytes, and
1759
- * every encoder took that for the end of the file and died. Playback was over
1760
- * for good; the sessions answered 500 to everything after that.
1761
- *
1762
- * Stopped, not disposed: the track keeps its place, its grid and its
1763
- * position, so switching back does not build it again — the same treatment a
1764
- * quality rung gets when the viewer moves off it.
1765
- *
1766
- * "Every other track" is every track NO LIVE VIEWER is listening to, which
1767
- * with one viewer is what it always was. It has to be asked that way now that
1768
- * two viewers share one picture: each of them fetches the sound they chose,
1769
- * and stopping "the others" per request would have them switch each other's
1770
- * soundtrack off in turn, once per segment, for the whole film.
1771
- *
1772
- * What is recorded is the soundtrack they are SENT — its track and the mode
1773
- * the output that serves it is produced in — so every later reading of
1774
- * their load counts the output that actually reaches them.
1775
- *
1776
- * @param {HlsSession} base
1777
- * @param {HlsSession} rendition - The output the segment came from.
1778
- * @param {string} consumerId - Who is listening. A request that names
1779
- * nobody changes nobody's track.
1780
- */
1781
- #noteAudioTrackActive(base, rendition, consumerId) {
1782
- if (!consumerId) {
1783
- return;
1784
- }
1785
- const trackIndex = this.#flatAudioTrackOf(rendition);
1786
- const transcode = rendition.spec.transcodesAudio === true;
1787
- const previous = this.#audioChoiceOf(base, consumerId);
1788
- if (previous.trackIndex === trackIndex && previous.transcode === transcode) {
1789
- return;
1790
- }
1791
- this.#host.chooseAudioTrack(base, consumerId, { trackIndex, transcode });
1792
- // Every soundtrack nobody present is listening to any more stops being
1793
- // waited on.
1794
- const wanted = this.#liveAudioRenditionKeys(base);
1795
- for (const other of this.#host.outputs.renditionsOf(base)) {
1796
- if (wanted.has(audioRenditionKey(this.#flatAudioTrackOf(other), other.spec.transcodesAudio))) {
1797
- continue;
1798
- }
1799
- // Requests held on it are for segments nobody will produce now, and the
1800
- // player stopped waiting for them the moment it changed track.
1801
- if (this.#host.encodeRuns.liveRunsOf(other).length > 0) {
1802
- this.#host.invalidateWaits(other);
1803
- }
1804
- // Nobody is listening to it any more: this viewer stops watching that
1805
- // output, on both sides of the relation, and the claim their listening
1806
- // placed on it is released with them. Its encoder follows from that —
1807
- // an output with nobody on it has a map with nothing in it — and was
1808
- // additionally stopped from here, which is the same decision taken twice
1809
- // by two parties with two rules.
1810
- this.#host.viewerLeaves(other, consumerId);
1811
- }
1812
- }
1813
-
1814
- /**
1815
- * The viewers this family has heard from recently enough to still be watching.
1816
- *
1817
- * Asked of the whole family, not of one session: a viewer on a quality step
1818
- * asks that step for its segments, so the picture they started on has not
1819
- * heard from them since they switched. Their head expires by the same rule the
1820
- * encoder's own steering uses.
1821
- *
1822
- * It is what decides whether an encoder is still wanted, and it is needed
1823
- * because nothing releases a session when a channel closes (roadmap item 54)
1824
- * — without it a viewer whose tab is gone would hold a soundtrack or a rung
1825
- * for the session's whole life.
1826
- *
1827
- * @param {HlsSession} base
1828
- * @returns {Set<string>}
1829
- */
1830
- liveConsumers(base) {
1831
- const live = new Set();
1832
- for (const member of this.#host.outputs.familyOf(base)) {
1833
- for (const consumerId of this.#host.presentOn(member)) {
1834
- live.add(consumerId);
1835
- }
1836
- }
1837
- return live;
1838
- }
1839
-
1840
- /**
1841
- * What one viewer wants of the sound: which soundtrack, and whether their
1842
- * browser needs it re-encoded.
1843
- *
1844
- * @param {HlsSession} base
1845
- * @param {string} consumerId
1846
- * @returns {{ trackIndex: number, transcode: boolean }}
1847
- */
1848
- #audioChoiceOf(base, consumerId) {
1849
- const stated = this.#host.audioChoiceOf(base, consumerId);
1850
- if (stated) {
1851
- return stated;
1852
- }
1853
- // A viewer this session has not heard from by name. The session's own
1854
- // parameters are the honest fallback: they are what the request that
1855
- // created it asked for.
1856
- return {
1857
- trackIndex: this.#flatAudioTrackOf(base),
1858
- transcode: base.spec.transcodesAudio
1859
- };
1860
- }
1861
-
1862
- /**
1863
- * The renditions live viewers are listening to, as the keys they are filed
1864
- * under.
1865
- *
1866
- * A viewer counts while their head is fresh on the picture — the same
1867
- * expiry the encoder's own steering uses. Without that test a viewer whose
1868
- * tab was closed without releasing the session (roadmap item 54) would hold
1869
- * an encoder for the session's whole life.
1870
- *
1871
- * @param {HlsSession} base
1872
- * @returns {Set<string>}
1873
- */
1874
- #liveAudioRenditionKeys(base) {
1875
- const wanted = new Set();
1876
- const live = this.liveConsumers(base);
1877
- for (const consumerId of this.#host.consumersOn(base)) {
1878
- const choice = this.#audioChoiceOf(base, consumerId);
1879
- // A viewer counts while some session of the family has heard from them.
1880
- if (live.size > 0 && !live.has(consumerId)) {
1881
- continue;
1882
- }
1883
- wanted.add(audioRenditionKey(choice.trackIndex, choice.transcode));
1884
- }
1885
- return wanted;
1886
- }
1887
-
1888
- /**
1889
- * The session producing one audio track of this file, made on first request.
1890
- *
1891
- * Filed under the track AND how it has to be produced, because those are two
1892
- * different encodes: a browser that can decode this track as it stands is
1893
- * served a copy, and one that cannot is served AAC — and so is a track with
1894
- * no stated rate, whose copy the viewer's link could not be asked about. The
1895
- * base cannot answer for either of them now that it is shared — its own
1896
- * `transcodeAudio` is whatever the first viewer's browser needed.
1897
- *
1898
- * @param {HlsSession} base
1899
- * @param {number} trackIndex
1900
- * @param {string} consumerId - Who is asking.
1901
- * @param {boolean | null} [browserPlays] - What the page stated, or null.
1902
- * @returns {Promise<HlsSession | null>} Null as well when the track cannot be
1903
- * served to this viewer at all.
1904
- */
1905
- async #resolveAudioRenditionSession(base, trackIndex, consumerId = "", browserPlays = null) {
1906
- const transcodeAudio = this.#audioModeFor(base, consumerId, trackIndex, browserPlays);
1907
- if (transcodeAudio === null) {
1908
- return null;
1909
- }
1910
- // Found by what it IS: a soundtrack of this file, this track, produced this
1911
- // way. That is what a map from a rendition key to a session id said, at the
1912
- // price of a link between two sessions' lifetimes — one that had to be
1913
- // cleaned from the other side when either ended.
1914
- const already = this.#host.outputs.renditionsOf(base).find(
1915
- (other) =>
1916
- this.#flatAudioTrackOf(other) === trackIndex &&
1917
- other.spec.transcodesAudio === transcodeAudio
1918
- );
1919
- if (already) {
1920
- this.#host.outputs.touch(already);
1921
- return already;
1922
- }
1923
- const rendition = await this.#host.createOrGetSession({
1924
- sourceKey: base.file.sourceKey,
1925
- fileIndex: base.file.fileIndex,
1926
- // No picture at all: the video flag says what to do with a video stream
1927
- // this output does not carry.
1928
- transcodeVideo: false,
1929
- transcodeAudio,
1930
- fileName: base.file.name,
1931
- // Opened by nobody in particular, as a step is.
1932
- consumerId: "",
1933
- audioTrackIndex: trackIndex,
1934
- audioOnly: true,
1935
- // Where the viewer is, so the rendition starts with the picture rather
1936
- // than at the beginning of the file. Read the same way a quality variant
1937
- // reads it: the base's own field is only written by a seek or by a
1938
- // segment IT served, so on a resume-from-position open it is still unset
1939
- // while the player is asking for segment #537 — and the audio would begin
1940
- // at zero and never catch up, since nothing treats a far request as a
1941
- // seek. The accessor falls back to the last segment actually requested.
1942
- // Where the PICTURE is, not where it has been read to.
1943
- //
1944
- // The position this class keeps is written by the segments a session
1945
- // serves, so it is the READ head, and the viewer's picture sits behind it
1946
- // by everything the player has buffered. Started at the read head, the
1947
- // audio run begins AHEAD of the viewer, and every request they then make
1948
- // is behind a run that only moves forward — field 2026-08-15, placed at
1949
- // #16 while the player asked for #10, and the audio arrived only after
1950
- // the encoder was dragged back.
1951
- //
1952
- // The distance is measured, not assumed: the browser reports how many
1953
- // seconds it holds ahead of the picture with every link report, so the
1954
- // playhead is one subtraction away. A stale report is no use — a viewer
1955
- // who seeked since then is somewhere else entirely — so an old one is
1956
- // ignored and the whole look-ahead is subtracted instead, which cannot
1957
- // leave the run ahead of them.
1958
- startPositionSeconds: this.#host.audioStartSecondsFor({
1959
- family: this.#host.outputs.familyOf(base),
1960
- openedAtSeconds: this.#host.encodeRuns.progressOf(base)?.startPositionSeconds,
1961
- segmentSeconds: this.#host.segmentDurationSec
1962
- }),
1963
- segmentFormatId: base.segmentFormat.id,
1964
- // Cut where the picture is cut. Two streams meant to be played together
1965
- // have to be divided at the same times, and the grid is the base's — the
1966
- // table as it stands now, corrections included. A base on the uniform
1967
- // grid passes nothing: the rendition computes the same even grid from the
1968
- // same duration.
1969
- inheritedGrid: base.timeline.cutGrid === "keyframe"
1970
- ? {
1971
- boundaries: base.timeline.boundaries,
1972
- published: base.timeline.published
1973
- }
1974
- : null
1975
- });
1976
- return rendition ?? null;
1977
- }
1978
-
1979
- /**
1980
- * Whether a session created with these parameters publishes its sound as its
1981
- * own stream rather than muxing it into the picture.
1982
- *
1983
- * Asked before the session exists, because the session's KEY depends on the
1984
- * answer: an output that carries no sound must not be told apart by which
1985
- * soundtrack was asked for, and an output that carries it must be.
1986
- *
1987
- * Three conditions, all of them facts about the request and the file rather
1988
- * than about the machine's load, so the answer cannot move afterwards:
1989
- *
1990
- * 1. the browser said it understands rendition groups. One that did not must
1991
- * be sent its sound inside the picture, or it gets silence;
1992
- * 2. the file has soundtracks to publish;
1993
- * 3. there is more than one height to move between, because renditions are
1994
- * published in a master playlist and a stream served as a single media
1995
- * playlist has nowhere to carry an `#EXT-X-MEDIA` line.
1996
- *
1997
- * Condition 3 is the reason a source too small for a ladder mixes its sound
1998
- * in as it always did. It is asked of the same ladder the master's rung list
1999
- * comes from; the realtime budget may still encode below the height named
2000
- * here, and cannot change the count, because what it picks is a rung of that
2001
- * same ladder.
2002
- *
2003
- * @param {{ sourceKey: string, fileIndex: number, audioRenditions: boolean, ownHeight: number }} params
2004
- * @returns {boolean}
2005
- */
2006
- audioTravelsSeparately({ sourceKey, fileIndex, audioRenditions, ownHeight }) {
2007
- if (audioRenditions !== true) {
2008
- return false;
2009
- }
2010
- const tracks = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
2011
- if (!Array.isArray(tracks) || tracks.length === 0) {
2012
- return false;
2013
- }
2014
- // The source's own height, from the probe the playback plan already ran.
2015
- // Absent, this answers "mix it in" — which is what the later computation
2016
- // answered too, since a session with no source height has an empty ladder.
2017
- const sourceHeight = Math.round(Number(this.#host.getCachedMediaInfo?.({ sourceKey, fileIndex })?.height) || 0);
2018
- const heights = new Set(variantHeightsFor(sourceHeight));
2019
- if (Number.isInteger(ownHeight) && ownHeight > 0) {
2020
- heights.add(ownHeight);
2021
- }
2022
- return heights.size >= 2;
2023
- }
2024
-
2025
- /**
2026
- * Where one numbered soundtrack actually is: which file of the torrent, and
2027
- * which `0:a:N` inside it.
2028
- *
2029
- * The number is flat across the picture's own tracks and every soundtrack
2030
- * shipped as a file beside it, so that the browser's menu, the
2031
- * `audioTrackIndex` on a session request and the `a/<n>/` path a rendition is
2032
- * published at all mean the same thing. This resolves it, once, from the
2033
- * inventory the playback plan built — the very list the menu was drawn from,
2034
- * so the two cannot disagree about what a number means.
2035
- *
2036
- * A number the inventory does not describe resolves to the picture's own file
2037
- * at that index, which is exactly what every session did before soundtracks in
2038
- * their own files existed: a plan cached by an older build carries no
2039
- * inventory, and a session created against it must keep working.
2040
- *
2041
- * @param {string} sourceKey
2042
- * @param {number} fileIndex - The PICTURE's file.
2043
- * @param {number} flatIndex
2044
- * @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string, entry: object | null }}
2045
- */
2046
- resolveAudioSource(sourceKey, fileIndex, flatIndex) {
2047
- const inventory = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
2048
- const entry = Array.isArray(inventory)
2049
- ? inventory.find((candidate) => candidate?.index === flatIndex)
2050
- : null;
2051
- if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
2052
- return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "", entry: null };
2053
- }
2054
- return {
2055
- fileIndex: entry.fileIndex,
2056
- sourceTrackIndex: entry.sourceTrackIndex,
2057
- isSidecar: entry.fileIndex !== fileIndex,
2058
- name: typeof entry.fileName === "string" ? entry.fileName : "",
2059
- // The whole entry, which is what says what the track weighs on a link —
2060
- // the bound its codec allows, the rate the file states — and decides how
2061
- // it is produced. Handed on whole, so no conversion between here and the
2062
- // link's question can drop a field of it.
2063
- entry
2064
- };
2065
- }
2066
-
2067
- /**
2068
- * The browser's flat soundtrack number for the audio carried by this output.
2069
- *
2070
- * `OutputSpec` keeps the stable source address: file plus `0:a:N`. The flat
2071
- * number belongs to the browser menu and is reconstructed from that menu's
2072
- * inventory when a route needs it. It is therefore not kept as a duplicate
2073
- * field on the output.
2074
- *
2075
- * @param {HlsSession} session
2076
- * @returns {number}
2077
- */
2078
- #flatAudioTrackOf(session) {
2079
- const audio = session.spec.audio;
2080
- if (!audio) {
2081
- return 0;
2082
- }
2083
- const tracks = this.#host.getCachedAudioTracks?.({
2084
- sourceKey: session.file.sourceKey,
2085
- fileIndex: session.file.fileIndex
2086
- }) ?? [];
2087
- const matching = Array.isArray(tracks)
2088
- ? tracks.find((entry) => entry?.fileIndex === audio.fileIndex && entry?.sourceTrackIndex === audio.trackIndex)
2089
- : null;
2090
- return Number.isInteger(matching?.index) ? matching.index : audio.trackIndex;
2091
- }
2092
-
2093
- /**
2094
- * Start reading where a soundtrack file's own timeline begins, if nobody has.
2095
- *
2096
- * Read by the container layer from the file's own header — the same 64 KB,
2097
- * the same reader and the same per-file cache the audio menu's track list
2098
- * comes from. A container states this, so it is read from the container and
2099
- * not measured from the media.
2100
- *
2101
- * The answer goes onto the FILE, which is where it belongs and which is what
2102
- * removed the pair of maps this used to keep beside it: a start time held per
2103
- * `sourceKey:fileIndex` is a fact of that file, and a second store of facts
2104
- * about files is a second thing that can disagree. Every session of the
2105
- * soundtrack shares the one object, so a reading that lands after a session
2106
- * has started is seen by that session too — which is what the spawn path
2107
- * needed and used to re-read a map for.
2108
- *
2109
- * Until 2.73.0 the session spawned an ffmpeg against the proxy's own
2110
- * `/stream` for it and waited up to eight seconds for the banner. Field
2111
- * 2026-09-03: that read cost 8121 ms of a cold start, three times out of
2112
- * three, while the container layer had read the same header of the same file
2113
- * in 8 ms in the same second. The eight seconds were not even spent on the
2114
- * answer — the early exit was gated on a DURATION, and a partly downloaded
2115
- * file prints `Duration: N/A` with the start time on that very line.
2116
- *
2117
- * Runs behind whoever asked, so no viewer waits for it. Once per file per
2118
- * process: a container's start time is a property of the file and cannot
2119
- * change. A reading that comes back without an answer is NOT remembered — the
2120
- * file may simply not have been downloaded far enough yet, and the next
2121
- * session asks again.
2122
- *
2123
- * @param {SourceFile} file - The soundtrack's own file.
2124
- * @returns {void}
2125
- */
2126
- warmFileStartTime(file) {
2127
- if (typeof this.#host.getContainerMediaInfo !== "function") {
2128
- return;
2129
- }
2130
- if (!(this.#host.fileStartTimeReads instanceof Set)) {
2131
- this.#host.fileStartTimeReads = new Set();
2132
- }
2133
- if (file.media?.startTime !== undefined || this.#host.fileStartTimeReads.has(file.key)) {
2134
- return;
2135
- }
2136
- this.#host.fileStartTimeReads.add(file.key);
2137
- void Promise.resolve(
2138
- this.#host.getContainerMediaInfo({ sourceKey: file.sourceKey, fileIndex: file.fileIndex })
2139
- )
2140
- .then((info) => {
2141
- if (info && Number.isFinite(info.startTimeSeconds)) {
2142
- file.learn({ startTime: info.startTimeSeconds });
2143
- this.#host.logger.info(
2144
- `transcode: soundtrack file ${file.fileIndex}'s own timeline starts at ` +
2145
- `${info.startTimeSeconds.toFixed(6)}s, read from its header`
2146
- );
2147
- }
2148
- })
2149
- .catch((error) => {
2150
- this.#host.logger.info(
2151
- `transcode: the start of soundtrack file ${file.fileIndex}'s timeline could not be read ` +
2152
- `(${error instanceof Error ? error.message : String(error)}) — the two timelines are ` +
2153
- "taken to agree until it can be"
2154
- );
2155
- })
2156
- .finally(() => {
2157
- this.#host.fileStartTimeReads.delete(file.key);
2158
- });
2159
- }
2160
-
2161
- /**
2162
- * The audio tracks of this session's file, as renditions for the master.
2163
- *
2164
- * Taken from the inventory the playback plan already probed — the same list
2165
- * the browser's audio menu is built from — so nothing is probed again here.
2166
- * The track the session was created with is the default one: it is what the
2167
- * viewer chose (or the file's first track), and a master that defaulted to
2168
- * something else would change the language on its own.
2169
- *
2170
- * @param {HlsSession} session
2171
- * @param {number} [chosenTrack] - The track to mark as the default one.
2172
- * Defaults to the session's own, which is what a caller that is only
2173
- * counting the renditions wants.
2174
- * @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
2175
- */
2176
- #audioRenditionsOf(session, chosenTrack) {
2177
- const tracks = this.#host.getCachedAudioTracks?.({
2178
- sourceKey: session.file.sourceKey,
2179
- // The PICTURE's file, which is what `file` is on every session of a
2180
- // family — a rendition is created with its base's, and only its
2181
- // `audioFile` points at the file its sound comes from. The inventory is
2182
- // keyed on the picture and spans the soundtracks beside it.
2183
- fileIndex: session.file.fileIndex
2184
- }) ?? [];
2185
- if (!Array.isArray(tracks) || tracks.length === 0) {
2186
- return [];
2187
- }
2188
- const chosen = Number.isInteger(chosenTrack) ? chosenTrack : this.#flatAudioTrackOf(session);
2189
- // One line per entry of the inventory, in its order and without omissions —
2190
- // including a track the container marks unusable. The player addresses a
2191
- // rendition by its POSITION in this list, and the browser addresses it by
2192
- // the number the inventory gave it; leaving anything out would make those two
2193
- // disagree from that point on. A track the file says not to offer is kept out
2194
- // of the VIEWER's menu, which is the browser's own business and does not
2195
- // touch the numbering.
2196
- return tracks.map((entry, order) => {
2197
- const index = Number.isInteger(entry?.index) ? entry.index : order;
2198
- const language = typeof entry?.languageBcp47 === "string" && entry.languageBcp47.length > 0
2199
- ? entry.languageBcp47
2200
- : (typeof entry?.language === "string" ? entry.language : "");
2201
- return {
2202
- trackIndex: index,
2203
- name: this.#host.audioRenditionName(
2204
- { ...entry, index, folders: Array.isArray(entry?.folders) ? entry.folders : [] },
2205
- tracks
2206
- ),
2207
- // Only what the container itself states. What a folder name suggests
2208
- // about a language is derived in the browser, where the language table
2209
- // and the viewer's own locale already are; writing a guess into
2210
- // `LANGUAGE` would put it in a playlist as though the file had said it.
2211
- language,
2212
- isDefault: index === chosen
2213
- };
2214
- });
2215
- }
2216
-
2217
- /**
2218
- * Where a person stands when they come onto a rung, in seconds.
2219
- *
2220
- * A stated position when there is one — the picture's, sent with the switch,
2221
- * or the start of the segment the player asked a new rung for. Otherwise the
2222
- * position they have stated on the rung they are on. Never the rung's READ
2223
- * head, which sits a whole buffer further on.
2224
- *
2225
- * @param {HlsSession} base
2226
- * @param {number} [positionSeconds] - A stated position, when there is one.
2227
- * @param {string} [consumerId]
2228
- * @returns {number}
2229
- */
2230
- #variantStartSeconds(base, positionSeconds, consumerId = "") {
2231
- if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
2232
- return positionSeconds;
2233
- }
2234
- return this.#host.viewerSecondsOn(this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }));
2235
- }
2236
-
2237
- /**
2238
- * The track set this session's output will carry — what the proxy DECLARES,
2239
- * and the one answer both sides must agree on.
2240
- *
2241
- * The source may hold any number of tracks: several dubs, subtitles, even a
2242
- * cover-art video stream. The output does not inherit that list — the command
2243
- * maps at most one video and at most one audio, each optional, and subtitles
2244
- * never enter the HLS output at all (they are served separately as WebVTT).
2245
- * So this is not an inference about the file; it is the proxy stating what it
2246
- * chose to produce.
2247
- *
2248
- * Used in two places, and that is the point: the init segment is checked
2249
- * against it here, and it is sent to the browser so the browser can check
2250
- * what it actually received against the same statement. Without the second
2251
- * check a missing track is only noticed by its absence, minutes later, as a
2252
- * black picture with working sound.
2253
- *
2254
- * @param {HlsSession} session
2255
- * @returns {{ video: boolean, audio: boolean }}
2256
- */
2257
- declaredTracks(session) {
2258
- const probed = this.#host.getCachedMediaInfo?.({
2259
- sourceKey: session.file.sourceKey,
2260
- fileIndex: session.file.fileIndex
2261
- }) ?? null;
2262
- // What the SOURCE has, narrowed to what this session's output carries. A
2263
- // rendition maps only audio and a stream whose audio travels separately
2264
- // maps only video, so answering from the source alone would tell the
2265
- // browser about a track that is not in the stream, and would leave
2266
- // `#initFromFirstSegment` waiting for a second track that no init will ever
2267
- // declare — its warning about a short header would then fire on every one.
2268
- const carriesVideo = session.spec.carries !== "audio-only";
2269
- const carriesAudio = !this.servesAudioSeparately(session);
2270
- // The soundtrack of this session may not be in the file that was probed. A
2271
- // release that ships its dub as a separate file often ships the picture with
2272
- // no sound of its own at all, and then the picture's probe says there is no
2273
- // audio while the output plainly carries some — which would leave the header
2274
- // check expecting one track where two arrive, and tell the browser its sound
2275
- // was lost.
2276
- const audioFromAnotherFile = session.spec.audioFileIndex !== session.file.fileIndex;
2277
- return {
2278
- video: carriesVideo && Boolean(probed?.videoCodec),
2279
- audio: carriesAudio && (audioFromAnotherFile || Boolean(probed?.audioCodec))
2280
- };
2281
- }
2282
- }
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) {
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
+ readWindowBytes: this.#host.encodeRuns.readWindowFor(session),
1674
+ baseUrl: this.#host.localBaseUrl
1675
+ });
1676
+ }
1677
+
1678
+ /**
1679
+ * One file of an audio rendition: its playlist, its init segment or one of
1680
+ * its segments.
1681
+ *
1682
+ * A rendition is an ordinary session underneath — same source, same file,
1683
+ * same cut grid, one audio track and no picture — created on the first
1684
+ * request for it, exactly as a quality variant is. What differs is that the
1685
+ * player fetches it ALONGSIDE a variant rather than instead of one, so both
1686
+ * encoders run: a rung and the audio it is played with.
1687
+ *
1688
+ * @param {string} baseSessionId
1689
+ * @param {number} trackIndex
1690
+ * @param {string} fileName
1691
+ * @param {string} [consumerId] - Who is asking. One picture serves everyone
1692
+ * watching it, and which soundtrack they are listening to is theirs alone.
1693
+ * @returns {Promise<{ sessionId: string | null, error?: string }>}
1694
+ */
1695
+ async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName, consumerId = "") {
1696
+ if (!isOutputName(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
1697
+ return { sessionId: null };
1698
+ }
1699
+ const base = this.#host.outputs.get(baseSessionId);
1700
+ if (!base || !this.servesAudioSeparately(base)) {
1701
+ return { sessionId: null };
1702
+ }
1703
+ const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1704
+ const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
1705
+ const isSegment = base.segmentFormat.isSegmentFileName(fileName);
1706
+ if (!isPlaylist && !isInit && !isSegment) {
1707
+ return { sessionId: null };
1708
+ }
1709
+ if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
1710
+ return { sessionId: null };
1711
+ }
1712
+ // The playlist is answered from the base, for the same reason a variant's
1713
+ // is: every rendition of a file has the same boundaries and the same
1714
+ // duration — they are cut on one grid — and the player fetches the playlist
1715
+ // of tracks it may never select. Starting an encoder for each would put as
1716
+ // many encoders on the host as the file has languages.
1717
+ if (isPlaylist) {
1718
+ return { sessionId: base.id };
1719
+ }
1720
+ let rendition;
1721
+ try {
1722
+ rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
1723
+ } catch (error) {
1724
+ const message = error instanceof Error ? error.message : String(error);
1725
+ this.#host.logger.error(
1726
+ `transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
1727
+ (error instanceof Error && error.stack ? `\n${error.stack}` : "")
1728
+ );
1729
+ return { sessionId: null, error: message };
1730
+ }
1731
+ if (rendition && consumerId) {
1732
+ // Asking for ANY file of this soundtrack is this viewer watching it, and
1733
+ // the init is the file they ask for first. Registered here rather than on
1734
+ // the segment alone, because the segment cannot be asked for until the
1735
+ // init has been served, and the init cannot be made unless somebody is
1736
+ // watching: that circle is what left a soundtrack with no encoder, no
1737
+ // init and a viewer waiting sixty seconds on 2026-09-05.
1738
+ //
1739
+ // WHERE they are on it is where they are on the picture: the two are
1740
+ // played together.
1741
+ this.#host.placeViewerOn(rendition, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
1742
+ }
1743
+ if (isSegment && rendition) {
1744
+ this.#noteAudioTrackActive(base, rendition, consumerId);
1745
+ }
1746
+ return { sessionId: rendition?.id ?? null };
1747
+ }
1748
+
1749
+ /**
1750
+ * A SEGMENT of this track is what says the viewer is listening to it — the
1751
+ * player fetches the playlist and the init of tracks it may never choose.
1752
+ *
1753
+ * Every other track is then stopped. Each one is an ffmpeg process AND a
1754
+ * reader holding pieces of the torrent in memory, and the store can only
1755
+ * spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
1756
+ * track once had three readers on one file — picture, the track they chose
1757
+ * and the track they left — and at a seek all three revived their windows at
1758
+ * once, every resident piece was pinned, a read ended with zero bytes, and
1759
+ * every encoder took that for the end of the file and died. Playback was over
1760
+ * for good; the sessions answered 500 to everything after that.
1761
+ *
1762
+ * Stopped, not disposed: the track keeps its place, its grid and its
1763
+ * position, so switching back does not build it again — the same treatment a
1764
+ * quality rung gets when the viewer moves off it.
1765
+ *
1766
+ * "Every other track" is every track NO LIVE VIEWER is listening to, which
1767
+ * with one viewer is what it always was. It has to be asked that way now that
1768
+ * two viewers share one picture: each of them fetches the sound they chose,
1769
+ * and stopping "the others" per request would have them switch each other's
1770
+ * soundtrack off in turn, once per segment, for the whole film.
1771
+ *
1772
+ * What is recorded is the soundtrack they are SENT — its track and the mode
1773
+ * the output that serves it is produced in — so every later reading of
1774
+ * their load counts the output that actually reaches them.
1775
+ *
1776
+ * @param {HlsSession} base
1777
+ * @param {HlsSession} rendition - The output the segment came from.
1778
+ * @param {string} consumerId - Who is listening. A request that names
1779
+ * nobody changes nobody's track.
1780
+ */
1781
+ #noteAudioTrackActive(base, rendition, consumerId) {
1782
+ if (!consumerId) {
1783
+ return;
1784
+ }
1785
+ const trackIndex = this.#flatAudioTrackOf(rendition);
1786
+ const transcode = rendition.spec.transcodesAudio === true;
1787
+ const previous = this.#audioChoiceOf(base, consumerId);
1788
+ if (previous.trackIndex === trackIndex && previous.transcode === transcode) {
1789
+ return;
1790
+ }
1791
+ this.#host.chooseAudioTrack(base, consumerId, { trackIndex, transcode });
1792
+ // Every soundtrack nobody present is listening to any more stops being
1793
+ // waited on.
1794
+ const wanted = this.#liveAudioRenditionKeys(base);
1795
+ for (const other of this.#host.outputs.renditionsOf(base)) {
1796
+ if (wanted.has(audioRenditionKey(this.#flatAudioTrackOf(other), other.spec.transcodesAudio))) {
1797
+ continue;
1798
+ }
1799
+ // Requests held on it are for segments nobody will produce now, and the
1800
+ // player stopped waiting for them the moment it changed track.
1801
+ if (this.#host.encodeRuns.liveRunsOf(other).length > 0) {
1802
+ this.#host.invalidateWaits(other);
1803
+ }
1804
+ // Nobody is listening to it any more: this viewer stops watching that
1805
+ // output, on both sides of the relation, and the claim their listening
1806
+ // placed on it is released with them. Its encoder follows from that —
1807
+ // an output with nobody on it has a map with nothing in it — and was
1808
+ // additionally stopped from here, which is the same decision taken twice
1809
+ // by two parties with two rules.
1810
+ this.#host.viewerLeaves(other, consumerId);
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * The viewers this family has heard from recently enough to still be watching.
1816
+ *
1817
+ * Asked of the whole family, not of one session: a viewer on a quality step
1818
+ * asks that step for its segments, so the picture they started on has not
1819
+ * heard from them since they switched. Their head expires by the same rule the
1820
+ * encoder's own steering uses.
1821
+ *
1822
+ * It is what decides whether an encoder is still wanted, and it is needed
1823
+ * because nothing releases a session when a channel closes (roadmap item 54)
1824
+ * — without it a viewer whose tab is gone would hold a soundtrack or a rung
1825
+ * for the session's whole life.
1826
+ *
1827
+ * @param {HlsSession} base
1828
+ * @returns {Set<string>}
1829
+ */
1830
+ liveConsumers(base) {
1831
+ const live = new Set();
1832
+ for (const member of this.#host.outputs.familyOf(base)) {
1833
+ for (const consumerId of this.#host.presentOn(member)) {
1834
+ live.add(consumerId);
1835
+ }
1836
+ }
1837
+ return live;
1838
+ }
1839
+
1840
+ /**
1841
+ * What one viewer wants of the sound: which soundtrack, and whether their
1842
+ * browser needs it re-encoded.
1843
+ *
1844
+ * @param {HlsSession} base
1845
+ * @param {string} consumerId
1846
+ * @returns {{ trackIndex: number, transcode: boolean }}
1847
+ */
1848
+ #audioChoiceOf(base, consumerId) {
1849
+ const stated = this.#host.audioChoiceOf(base, consumerId);
1850
+ if (stated) {
1851
+ return stated;
1852
+ }
1853
+ // A viewer this session has not heard from by name. The session's own
1854
+ // parameters are the honest fallback: they are what the request that
1855
+ // created it asked for.
1856
+ return {
1857
+ trackIndex: this.#flatAudioTrackOf(base),
1858
+ transcode: base.spec.transcodesAudio
1859
+ };
1860
+ }
1861
+
1862
+ /**
1863
+ * The renditions live viewers are listening to, as the keys they are filed
1864
+ * under.
1865
+ *
1866
+ * A viewer counts while their head is fresh on the picture — the same
1867
+ * expiry the encoder's own steering uses. Without that test a viewer whose
1868
+ * tab was closed without releasing the session (roadmap item 54) would hold
1869
+ * an encoder for the session's whole life.
1870
+ *
1871
+ * @param {HlsSession} base
1872
+ * @returns {Set<string>}
1873
+ */
1874
+ #liveAudioRenditionKeys(base) {
1875
+ const wanted = new Set();
1876
+ const live = this.liveConsumers(base);
1877
+ for (const consumerId of this.#host.consumersOn(base)) {
1878
+ const choice = this.#audioChoiceOf(base, consumerId);
1879
+ // A viewer counts while some session of the family has heard from them.
1880
+ if (live.size > 0 && !live.has(consumerId)) {
1881
+ continue;
1882
+ }
1883
+ wanted.add(audioRenditionKey(choice.trackIndex, choice.transcode));
1884
+ }
1885
+ return wanted;
1886
+ }
1887
+
1888
+ /**
1889
+ * The session producing one audio track of this file, made on first request.
1890
+ *
1891
+ * Filed under the track AND how it has to be produced, because those are two
1892
+ * different encodes: a browser that can decode this track as it stands is
1893
+ * served a copy, and one that cannot is served AAC — and so is a track with
1894
+ * no stated rate, whose copy the viewer's link could not be asked about. The
1895
+ * base cannot answer for either of them now that it is shared — its own
1896
+ * `transcodeAudio` is whatever the first viewer's browser needed.
1897
+ *
1898
+ * @param {HlsSession} base
1899
+ * @param {number} trackIndex
1900
+ * @param {string} consumerId - Who is asking.
1901
+ * @param {boolean | null} [browserPlays] - What the page stated, or null.
1902
+ * @returns {Promise<HlsSession | null>} Null as well when the track cannot be
1903
+ * served to this viewer at all.
1904
+ */
1905
+ async #resolveAudioRenditionSession(base, trackIndex, consumerId = "", browserPlays = null) {
1906
+ const transcodeAudio = this.#audioModeFor(base, consumerId, trackIndex, browserPlays);
1907
+ if (transcodeAudio === null) {
1908
+ return null;
1909
+ }
1910
+ // Found by what it IS: a soundtrack of this file, this track, produced this
1911
+ // way. That is what a map from a rendition key to a session id said, at the
1912
+ // price of a link between two sessions' lifetimes — one that had to be
1913
+ // cleaned from the other side when either ended.
1914
+ const already = this.#host.outputs.renditionsOf(base).find(
1915
+ (other) =>
1916
+ this.#flatAudioTrackOf(other) === trackIndex &&
1917
+ other.spec.transcodesAudio === transcodeAudio
1918
+ );
1919
+ if (already) {
1920
+ this.#host.outputs.touch(already);
1921
+ return already;
1922
+ }
1923
+ const rendition = await this.#host.createOrGetSession({
1924
+ sourceKey: base.file.sourceKey,
1925
+ fileIndex: base.file.fileIndex,
1926
+ // No picture at all: the video flag says what to do with a video stream
1927
+ // this output does not carry.
1928
+ transcodeVideo: false,
1929
+ transcodeAudio,
1930
+ fileName: base.file.name,
1931
+ // Opened by nobody in particular, as a step is.
1932
+ consumerId: "",
1933
+ audioTrackIndex: trackIndex,
1934
+ audioOnly: true,
1935
+ // Where the viewer is, so the rendition starts with the picture rather
1936
+ // than at the beginning of the file. Read the same way a quality variant
1937
+ // reads it: the base's own field is only written by a seek or by a
1938
+ // segment IT served, so on a resume-from-position open it is still unset
1939
+ // while the player is asking for segment #537 — and the audio would begin
1940
+ // at zero and never catch up, since nothing treats a far request as a
1941
+ // seek. The accessor falls back to the last segment actually requested.
1942
+ // Where the PICTURE is, not where it has been read to.
1943
+ //
1944
+ // The position this class keeps is written by the segments a session
1945
+ // serves, so it is the READ head, and the viewer's picture sits behind it
1946
+ // by everything the player has buffered. Started at the read head, the
1947
+ // audio run begins AHEAD of the viewer, and every request they then make
1948
+ // is behind a run that only moves forward — field 2026-08-15, placed at
1949
+ // #16 while the player asked for #10, and the audio arrived only after
1950
+ // the encoder was dragged back.
1951
+ //
1952
+ // The distance is measured, not assumed: the browser reports how many
1953
+ // seconds it holds ahead of the picture with every link report, so the
1954
+ // playhead is one subtraction away. A stale report is no use — a viewer
1955
+ // who seeked since then is somewhere else entirely — so an old one is
1956
+ // ignored and the whole look-ahead is subtracted instead, which cannot
1957
+ // leave the run ahead of them.
1958
+ startPositionSeconds: this.#host.audioStartSecondsFor({
1959
+ family: this.#host.outputs.familyOf(base),
1960
+ openedAtSeconds: this.#host.encodeRuns.progressOf(base)?.startPositionSeconds,
1961
+ segmentSeconds: this.#host.segmentDurationSec
1962
+ }),
1963
+ segmentFormatId: base.segmentFormat.id,
1964
+ // Cut where the picture is cut. Two streams meant to be played together
1965
+ // have to be divided at the same times, and the grid is the base's — the
1966
+ // table as it stands now, corrections included. A base on the uniform
1967
+ // grid passes nothing: the rendition computes the same even grid from the
1968
+ // same duration.
1969
+ inheritedGrid: base.timeline.cutGrid === "keyframe"
1970
+ ? {
1971
+ boundaries: base.timeline.boundaries,
1972
+ published: base.timeline.published
1973
+ }
1974
+ : null
1975
+ });
1976
+ return rendition ?? null;
1977
+ }
1978
+
1979
+ /**
1980
+ * Whether a session created with these parameters publishes its sound as its
1981
+ * own stream rather than muxing it into the picture.
1982
+ *
1983
+ * Asked before the session exists, because the session's KEY depends on the
1984
+ * answer: an output that carries no sound must not be told apart by which
1985
+ * soundtrack was asked for, and an output that carries it must be.
1986
+ *
1987
+ * Three conditions, all of them facts about the request and the file rather
1988
+ * than about the machine's load, so the answer cannot move afterwards:
1989
+ *
1990
+ * 1. the browser said it understands rendition groups. One that did not must
1991
+ * be sent its sound inside the picture, or it gets silence;
1992
+ * 2. the file has soundtracks to publish;
1993
+ * 3. there is more than one height to move between, because renditions are
1994
+ * published in a master playlist and a stream served as a single media
1995
+ * playlist has nowhere to carry an `#EXT-X-MEDIA` line.
1996
+ *
1997
+ * Condition 3 is the reason a source too small for a ladder mixes its sound
1998
+ * in as it always did. It is asked of the same ladder the master's rung list
1999
+ * comes from; the realtime budget may still encode below the height named
2000
+ * here, and cannot change the count, because what it picks is a rung of that
2001
+ * same ladder.
2002
+ *
2003
+ * @param {{ sourceKey: string, fileIndex: number, audioRenditions: boolean, ownHeight: number }} params
2004
+ * @returns {boolean}
2005
+ */
2006
+ audioTravelsSeparately({ sourceKey, fileIndex, audioRenditions, ownHeight }) {
2007
+ if (audioRenditions !== true) {
2008
+ return false;
2009
+ }
2010
+ const tracks = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
2011
+ if (!Array.isArray(tracks) || tracks.length === 0) {
2012
+ return false;
2013
+ }
2014
+ // The source's own height, from the probe the playback plan already ran.
2015
+ // Absent, this answers "mix it in" — which is what the later computation
2016
+ // answered too, since a session with no source height has an empty ladder.
2017
+ const sourceHeight = Math.round(Number(this.#host.getCachedMediaInfo?.({ sourceKey, fileIndex })?.height) || 0);
2018
+ const heights = new Set(variantHeightsFor(sourceHeight));
2019
+ if (Number.isInteger(ownHeight) && ownHeight > 0) {
2020
+ heights.add(ownHeight);
2021
+ }
2022
+ return heights.size >= 2;
2023
+ }
2024
+
2025
+ /**
2026
+ * Where one numbered soundtrack actually is: which file of the torrent, and
2027
+ * which `0:a:N` inside it.
2028
+ *
2029
+ * The number is flat across the picture's own tracks and every soundtrack
2030
+ * shipped as a file beside it, so that the browser's menu, the
2031
+ * `audioTrackIndex` on a session request and the `a/<n>/` path a rendition is
2032
+ * published at all mean the same thing. This resolves it, once, from the
2033
+ * inventory the playback plan built — the very list the menu was drawn from,
2034
+ * so the two cannot disagree about what a number means.
2035
+ *
2036
+ * A number the inventory does not describe resolves to the picture's own file
2037
+ * at that index, which is exactly what every session did before soundtracks in
2038
+ * their own files existed: a plan cached by an older build carries no
2039
+ * inventory, and a session created against it must keep working.
2040
+ *
2041
+ * @param {string} sourceKey
2042
+ * @param {number} fileIndex - The PICTURE's file.
2043
+ * @param {number} flatIndex
2044
+ * @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string, entry: object | null }}
2045
+ */
2046
+ resolveAudioSource(sourceKey, fileIndex, flatIndex) {
2047
+ const inventory = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
2048
+ const entry = Array.isArray(inventory)
2049
+ ? inventory.find((candidate) => candidate?.index === flatIndex)
2050
+ : null;
2051
+ if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
2052
+ return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "", entry: null };
2053
+ }
2054
+ return {
2055
+ fileIndex: entry.fileIndex,
2056
+ sourceTrackIndex: entry.sourceTrackIndex,
2057
+ isSidecar: entry.fileIndex !== fileIndex,
2058
+ name: typeof entry.fileName === "string" ? entry.fileName : "",
2059
+ // The whole entry, which is what says what the track weighs on a link —
2060
+ // the bound its codec allows, the rate the file states — and decides how
2061
+ // it is produced. Handed on whole, so no conversion between here and the
2062
+ // link's question can drop a field of it.
2063
+ entry
2064
+ };
2065
+ }
2066
+
2067
+ /**
2068
+ * The browser's flat soundtrack number for the audio carried by this output.
2069
+ *
2070
+ * `OutputSpec` keeps the stable source address: file plus `0:a:N`. The flat
2071
+ * number belongs to the browser menu and is reconstructed from that menu's
2072
+ * inventory when a route needs it. It is therefore not kept as a duplicate
2073
+ * field on the output.
2074
+ *
2075
+ * @param {HlsSession} session
2076
+ * @returns {number}
2077
+ */
2078
+ #flatAudioTrackOf(session) {
2079
+ const audio = session.spec.audio;
2080
+ if (!audio) {
2081
+ return 0;
2082
+ }
2083
+ const tracks = this.#host.getCachedAudioTracks?.({
2084
+ sourceKey: session.file.sourceKey,
2085
+ fileIndex: session.file.fileIndex
2086
+ }) ?? [];
2087
+ const matching = Array.isArray(tracks)
2088
+ ? tracks.find((entry) => entry?.fileIndex === audio.fileIndex && entry?.sourceTrackIndex === audio.trackIndex)
2089
+ : null;
2090
+ return Number.isInteger(matching?.index) ? matching.index : audio.trackIndex;
2091
+ }
2092
+
2093
+ /**
2094
+ * Start reading where a soundtrack file's own timeline begins, if nobody has.
2095
+ *
2096
+ * Read by the container layer from the file's own header — the same 64 KB,
2097
+ * the same reader and the same per-file cache the audio menu's track list
2098
+ * comes from. A container states this, so it is read from the container and
2099
+ * not measured from the media.
2100
+ *
2101
+ * The answer goes onto the FILE, which is where it belongs and which is what
2102
+ * removed the pair of maps this used to keep beside it: a start time held per
2103
+ * `sourceKey:fileIndex` is a fact of that file, and a second store of facts
2104
+ * about files is a second thing that can disagree. Every session of the
2105
+ * soundtrack shares the one object, so a reading that lands after a session
2106
+ * has started is seen by that session too — which is what the spawn path
2107
+ * needed and used to re-read a map for.
2108
+ *
2109
+ * Until 2.73.0 the session spawned an ffmpeg against the proxy's own
2110
+ * `/stream` for it and waited up to eight seconds for the banner. Field
2111
+ * 2026-09-03: that read cost 8121 ms of a cold start, three times out of
2112
+ * three, while the container layer had read the same header of the same file
2113
+ * in 8 ms in the same second. The eight seconds were not even spent on the
2114
+ * answer — the early exit was gated on a DURATION, and a partly downloaded
2115
+ * file prints `Duration: N/A` with the start time on that very line.
2116
+ *
2117
+ * Runs behind whoever asked, so no viewer waits for it. Once per file per
2118
+ * process: a container's start time is a property of the file and cannot
2119
+ * change. A reading that comes back without an answer is NOT remembered — the
2120
+ * file may simply not have been downloaded far enough yet, and the next
2121
+ * session asks again.
2122
+ *
2123
+ * @param {SourceFile} file - The soundtrack's own file.
2124
+ * @returns {void}
2125
+ */
2126
+ warmFileStartTime(file) {
2127
+ if (typeof this.#host.getContainerMediaInfo !== "function") {
2128
+ return;
2129
+ }
2130
+ if (!(this.#host.fileStartTimeReads instanceof Set)) {
2131
+ this.#host.fileStartTimeReads = new Set();
2132
+ }
2133
+ if (file.media?.startTime !== undefined || this.#host.fileStartTimeReads.has(file.key)) {
2134
+ return;
2135
+ }
2136
+ this.#host.fileStartTimeReads.add(file.key);
2137
+ void Promise.resolve(
2138
+ this.#host.getContainerMediaInfo({ sourceKey: file.sourceKey, fileIndex: file.fileIndex })
2139
+ )
2140
+ .then((info) => {
2141
+ if (info && Number.isFinite(info.startTimeSeconds)) {
2142
+ file.learn({ startTime: info.startTimeSeconds });
2143
+ this.#host.logger.info(
2144
+ `transcode: soundtrack file ${file.fileIndex}'s own timeline starts at ` +
2145
+ `${info.startTimeSeconds.toFixed(6)}s, read from its header`
2146
+ );
2147
+ }
2148
+ })
2149
+ .catch((error) => {
2150
+ this.#host.logger.info(
2151
+ `transcode: the start of soundtrack file ${file.fileIndex}'s timeline could not be read ` +
2152
+ `(${error instanceof Error ? error.message : String(error)}) — the two timelines are ` +
2153
+ "taken to agree until it can be"
2154
+ );
2155
+ })
2156
+ .finally(() => {
2157
+ this.#host.fileStartTimeReads.delete(file.key);
2158
+ });
2159
+ }
2160
+
2161
+ /**
2162
+ * The audio tracks of this session's file, as renditions for the master.
2163
+ *
2164
+ * Taken from the inventory the playback plan already probed — the same list
2165
+ * the browser's audio menu is built from — so nothing is probed again here.
2166
+ * The track the session was created with is the default one: it is what the
2167
+ * viewer chose (or the file's first track), and a master that defaulted to
2168
+ * something else would change the language on its own.
2169
+ *
2170
+ * @param {HlsSession} session
2171
+ * @param {number} [chosenTrack] - The track to mark as the default one.
2172
+ * Defaults to the session's own, which is what a caller that is only
2173
+ * counting the renditions wants.
2174
+ * @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
2175
+ */
2176
+ #audioRenditionsOf(session, chosenTrack) {
2177
+ const tracks = this.#host.getCachedAudioTracks?.({
2178
+ sourceKey: session.file.sourceKey,
2179
+ // The PICTURE's file, which is what `file` is on every session of a
2180
+ // family — a rendition is created with its base's, and only its
2181
+ // `audioFile` points at the file its sound comes from. The inventory is
2182
+ // keyed on the picture and spans the soundtracks beside it.
2183
+ fileIndex: session.file.fileIndex
2184
+ }) ?? [];
2185
+ if (!Array.isArray(tracks) || tracks.length === 0) {
2186
+ return [];
2187
+ }
2188
+ const chosen = Number.isInteger(chosenTrack) ? chosenTrack : this.#flatAudioTrackOf(session);
2189
+ // One line per entry of the inventory, in its order and without omissions —
2190
+ // including a track the container marks unusable. The player addresses a
2191
+ // rendition by its POSITION in this list, and the browser addresses it by
2192
+ // the number the inventory gave it; leaving anything out would make those two
2193
+ // disagree from that point on. A track the file says not to offer is kept out
2194
+ // of the VIEWER's menu, which is the browser's own business and does not
2195
+ // touch the numbering.
2196
+ return tracks.map((entry, order) => {
2197
+ const index = Number.isInteger(entry?.index) ? entry.index : order;
2198
+ const language = typeof entry?.languageBcp47 === "string" && entry.languageBcp47.length > 0
2199
+ ? entry.languageBcp47
2200
+ : (typeof entry?.language === "string" ? entry.language : "");
2201
+ return {
2202
+ trackIndex: index,
2203
+ name: this.#host.audioRenditionName(
2204
+ { ...entry, index, folders: Array.isArray(entry?.folders) ? entry.folders : [] },
2205
+ tracks
2206
+ ),
2207
+ // Only what the container itself states. What a folder name suggests
2208
+ // about a language is derived in the browser, where the language table
2209
+ // and the viewer's own locale already are; writing a guess into
2210
+ // `LANGUAGE` would put it in a playlist as though the file had said it.
2211
+ language,
2212
+ isDefault: index === chosen
2213
+ };
2214
+ });
2215
+ }
2216
+
2217
+ /**
2218
+ * Where a person stands when they come onto a rung, in seconds.
2219
+ *
2220
+ * A stated position when there is one — the picture's, sent with the switch,
2221
+ * or the start of the segment the player asked a new rung for. Otherwise the
2222
+ * position they have stated on the rung they are on. Never the rung's READ
2223
+ * head, which sits a whole buffer further on.
2224
+ *
2225
+ * @param {HlsSession} base
2226
+ * @param {number} [positionSeconds] - A stated position, when there is one.
2227
+ * @param {string} [consumerId]
2228
+ * @returns {number}
2229
+ */
2230
+ #variantStartSeconds(base, positionSeconds, consumerId = "") {
2231
+ if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
2232
+ return positionSeconds;
2233
+ }
2234
+ return this.#host.viewerSecondsOn(this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }));
2235
+ }
2236
+
2237
+ /**
2238
+ * The track set this session's output will carry — what the proxy DECLARES,
2239
+ * and the one answer both sides must agree on.
2240
+ *
2241
+ * The source may hold any number of tracks: several dubs, subtitles, even a
2242
+ * cover-art video stream. The output does not inherit that list — the command
2243
+ * maps at most one video and at most one audio, each optional, and subtitles
2244
+ * never enter the HLS output at all (they are served separately as WebVTT).
2245
+ * So this is not an inference about the file; it is the proxy stating what it
2246
+ * chose to produce.
2247
+ *
2248
+ * Used in two places, and that is the point: the init segment is checked
2249
+ * against it here, and it is sent to the browser so the browser can check
2250
+ * what it actually received against the same statement. Without the second
2251
+ * check a missing track is only noticed by its absence, minutes later, as a
2252
+ * black picture with working sound.
2253
+ *
2254
+ * @param {HlsSession} session
2255
+ * @returns {{ video: boolean, audio: boolean }}
2256
+ */
2257
+ declaredTracks(session) {
2258
+ const probed = this.#host.getCachedMediaInfo?.({
2259
+ sourceKey: session.file.sourceKey,
2260
+ fileIndex: session.file.fileIndex
2261
+ }) ?? null;
2262
+ // What the SOURCE has, narrowed to what this session's output carries. A
2263
+ // rendition maps only audio and a stream whose audio travels separately
2264
+ // maps only video, so answering from the source alone would tell the
2265
+ // browser about a track that is not in the stream, and would leave
2266
+ // `#initFromFirstSegment` waiting for a second track that no init will ever
2267
+ // declare — its warning about a short header would then fire on every one.
2268
+ const carriesVideo = session.spec.carries !== "audio-only";
2269
+ const carriesAudio = !this.servesAudioSeparately(session);
2270
+ // The soundtrack of this session may not be in the file that was probed. A
2271
+ // release that ships its dub as a separate file often ships the picture with
2272
+ // no sound of its own at all, and then the picture's probe says there is no
2273
+ // audio while the output plainly carries some — which would leave the header
2274
+ // check expecting one track where two arrive, and tell the browser its sound
2275
+ // was lost.
2276
+ const audioFromAnotherFile = session.spec.audioFileIndex !== session.file.fileIndex;
2277
+ return {
2278
+ video: carriesVideo && Boolean(probed?.videoCodec),
2279
+ audio: carriesAudio && (audioFromAnotherFile || Boolean(probed?.audioCodec))
2280
+ };
2281
+ }
2282
+ }