@torrent-tv/proxy 2.88.4 → 2.88.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (489) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2266 -2259
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -0
  19. package/assets/calibration/audio/aac.m4a +0 -0
  20. package/assets/calibration/audio/ac3.ac3 +0 -0
  21. package/assets/calibration/audio/alac.m4a +0 -0
  22. package/assets/calibration/audio/dts.dts +0 -0
  23. package/assets/calibration/audio/eac3.eac3 +0 -0
  24. package/assets/calibration/audio/flac.flac +0 -0
  25. package/assets/calibration/audio/manifest.json +98 -0
  26. package/assets/calibration/audio/mp2.mp2 +0 -0
  27. package/assets/calibration/audio/mp3.mp3 +0 -0
  28. package/assets/calibration/audio/opus.opus +0 -0
  29. package/assets/calibration/audio/pcm.wav +0 -0
  30. package/assets/calibration/audio/truehd.thd +0 -0
  31. package/assets/calibration/audio/vorbis.ogg +0 -0
  32. package/assets/diagnostics/sctpstate.gdb +31 -31
  33. package/bin/cli.js +684 -684
  34. package/bin/thread-pool.js +30 -30
  35. package/biome.json +412 -411
  36. package/docs/browser-proxy-contract.md +155 -152
  37. package/docs/container-architecture.md +218 -218
  38. package/docs/download-architecture.md +261 -261
  39. package/docs/encode-architecture.md +729 -729
  40. package/docs/encode-run-state.md +96 -96
  41. package/docs/logs.md +97 -97
  42. package/docs/playback-start-forecast.md +56 -0
  43. package/docs/socks5-egress-research.md +114 -114
  44. package/docs/storage-architecture.md +267 -267
  45. package/knip.json +14 -14
  46. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  47. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  48. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  49. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  50. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  51. package/openspec/changes/binary-distribution/proposal.md +53 -53
  52. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  53. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  54. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  55. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  56. package/openspec/changes/cold-start/design.md +98 -98
  57. package/openspec/changes/cold-start/proposal.md +62 -62
  58. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  59. package/openspec/changes/cold-start/tasks.md +48 -48
  60. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  61. package/openspec/changes/disk-cap/proposal.md +37 -37
  62. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  63. package/openspec/changes/disk-cap/tasks.md +19 -19
  64. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  65. package/openspec/changes/proxy-observability/design.md +38 -38
  66. package/openspec/changes/proxy-observability/proposal.md +49 -49
  67. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  68. package/openspec/changes/proxy-observability/tasks.md +21 -21
  69. package/openspec/changes/seek-debounce/design.md +118 -118
  70. package/openspec/changes/seek-debounce/proposal.md +59 -59
  71. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  72. package/openspec/changes/seek-debounce/tasks.md +33 -33
  73. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  74. package/openspec/changes/subtitle-language/proposal.md +50 -50
  75. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  76. package/openspec/changes/subtitle-language/tasks.md +26 -26
  77. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  78. package/openspec/changes/track-selection/design.md +47 -47
  79. package/openspec/changes/track-selection/proposal.md +45 -45
  80. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  81. package/openspec/changes/track-selection/tasks.md +20 -20
  82. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  83. package/openspec/changes/transcode-quality/proposal.md +51 -51
  84. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  85. package/openspec/changes/transcode-quality/tasks.md +63 -63
  86. package/openspec/config.yaml +38 -38
  87. package/package.json +1 -1
  88. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  89. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  90. package/research/double-spawn-2026-09-10.md +171 -171
  91. package/research/handover-2026-09-13.md +209 -209
  92. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  93. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  94. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  95. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  96. package/routes/api/client-logs/post.js +86 -86
  97. package/routes/api/delivery-sink/get.js +72 -72
  98. package/routes/api/link-probe/get.js +51 -51
  99. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  100. package/routes/api/playback-plan/post.js +53 -53
  101. package/routes/api/sources/post.js +36 -36
  102. package/routes/api/sources/stats/get.js +132 -132
  103. package/routes/api/sources/warm/post.js +217 -217
  104. package/routes/api/subtitles/get.js +230 -230
  105. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  106. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  107. package/routes/api/transcode-sessions/post.js +178 -178
  108. package/routes/api/transcode-sessions/progress/get.js +27 -27
  109. package/routes/api/transcode-sessions/release/post.js +41 -41
  110. package/routes/api/transcode-sessions/seek/post.js +63 -63
  111. package/routes/health/get.js +13 -13
  112. package/routes/healthz/get.js +13 -13
  113. package/routes/stream/get.js +380 -380
  114. package/routes/transcode/audio-file/get.js +64 -64
  115. package/routes/transcode/audio-warm/get.js +95 -95
  116. package/routes/transcode/session-file/get.js +52 -52
  117. package/routes/transcode/variant-file/get.js +95 -95
  118. package/routes/transcode/variant-warm/get.js +84 -84
  119. package/scripts/build-audio-samples.mjs +31 -0
  120. package/scripts/render-run-graph.js +173 -173
  121. package/server.js +730 -725
  122. package/services/encode/CoverageMap.js +428 -428
  123. package/services/encode/CushionReport.js +345 -345
  124. package/services/encode/EncodeAdmission.js +376 -376
  125. package/services/encode/EncodeOrchestrator.js +1217 -1217
  126. package/services/encode/EncodePlan.js +1366 -1366
  127. package/services/encode/EncodeRun.js +813 -813
  128. package/services/encode/EncodeRuns.js +1237 -1235
  129. package/services/encode/Encoder.js +142 -142
  130. package/services/encode/EncoderSelection.js +38 -38
  131. package/services/encode/LocalObservations.js +359 -359
  132. package/services/encode/NvencEncoder.js +50 -50
  133. package/services/encode/OutputOpening.js +783 -783
  134. package/services/encode/OutputTimes.js +579 -579
  135. package/services/encode/QsvEncoder.js +58 -58
  136. package/services/encode/Renditions.js +2282 -2282
  137. package/services/encode/RunProgress.js +84 -84
  138. package/services/encode/SegmentDemand.js +95 -95
  139. package/services/encode/SoftwareEncoder.js +121 -121
  140. package/services/encode/V4l2m2mEncoder.js +53 -53
  141. package/services/encode/VaapiEncoder.js +66 -66
  142. package/services/encode/args.js +418 -418
  143. package/services/encode/audio-calibration.js +67 -0
  144. package/services/encode/audio-work-rate.js +15 -0
  145. package/services/encode/available-share.js +89 -89
  146. package/services/encode/calibration.js +361 -361
  147. package/services/encode/contention.js +128 -128
  148. package/services/encode/decode-cost-fit.js +363 -363
  149. package/services/encode/encode-exit.js +114 -114
  150. package/services/encode/encode-run-state.js +551 -551
  151. package/services/encode/encoder-readings.js +45 -45
  152. package/services/encode/fingerprint.js +322 -322
  153. package/services/encode/host-load.js +174 -174
  154. package/services/encode/hwaccel.js +1894 -1894
  155. package/services/encode/index.js +9 -9
  156. package/services/encode/learned-median.js +90 -90
  157. package/services/encode/output/EncodedOutput.js +57 -57
  158. package/services/encode/output/Output.js +131 -131
  159. package/services/encode/output/OutputCatalog.js +414 -414
  160. package/services/encode/output/OutputSpec.js +390 -390
  161. package/services/encode/output/Timeline.js +308 -308
  162. package/services/encode/output/cut-grid.js +107 -107
  163. package/services/encode/output/index.js +20 -20
  164. package/services/encode/output/ladder.js +32 -32
  165. package/services/encode/output/playlists.js +284 -284
  166. package/services/encode/output/rates.js +125 -125
  167. package/services/encode/output-key-format.js +21 -21
  168. package/services/encode/quality/EncodeCost.js +1167 -1150
  169. package/services/encode/quality/HostLoad.js +525 -525
  170. package/services/encode/quality/HostTimings.js +249 -249
  171. package/services/encode/quality/OutputQualityState.js +28 -28
  172. package/services/encode/quality/QualityController.js +935 -935
  173. package/services/encode/quality/QualityOffer.js +416 -416
  174. package/services/encode/quality/drain-threat.js +47 -47
  175. package/services/encode/quality/link-budget.js +349 -349
  176. package/services/encode/quality/output-format.js +319 -319
  177. package/services/encode/quality/serving-output.js +216 -216
  178. package/services/encode/quality/visible-rung.js +57 -57
  179. package/services/encode/run-budget.js +97 -97
  180. package/services/encode/run-command.js +754 -754
  181. package/services/encode/run-costs.js +155 -155
  182. package/services/encode/run-inputs.js +42 -42
  183. package/services/encode/segment-formats/fmp4.js +458 -456
  184. package/services/encode/segment-formats/index.js +85 -85
  185. package/services/encode/segment-formats/init-compat.js +324 -324
  186. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  187. package/services/encode/segment-formats/mpegts.js +134 -134
  188. package/services/encode/start-stop-cost.js +178 -178
  189. package/services/encode/throughput.js +97 -97
  190. package/services/encode/torrent-cost.js +88 -88
  191. package/services/identity/installation-id.js +38 -38
  192. package/services/media/ContainerOrchestrator.js +149 -149
  193. package/services/media/KeyframeTables.js +316 -316
  194. package/services/media/SourceFile.js +298 -298
  195. package/services/media/SubtitleCues.js +520 -520
  196. package/services/media/SubtitleOrchestrator.js +167 -167
  197. package/services/media/audio-inventory.js +299 -298
  198. package/services/media/container/AviContainer.js +266 -266
  199. package/services/media/container/Container.js +609 -609
  200. package/services/media/container/ContainerFactory.js +56 -56
  201. package/services/media/container/KeyframeTable.js +272 -272
  202. package/services/media/container/MatroskaContainer.js +1494 -1494
  203. package/services/media/container/Mp4Container.js +1344 -1344
  204. package/services/media/container/ebml-reader.js +159 -159
  205. package/services/media/ffmpeg-banner.js +260 -260
  206. package/services/media/keyframe-probe.js +157 -157
  207. package/services/media/media-info-probe.js +93 -93
  208. package/services/media/playback-planner.js +931 -931
  209. package/services/media/tracks/AudioTrack.js +143 -143
  210. package/services/media/tracks/ContainerTrack.js +75 -75
  211. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  212. package/services/media/tracks/SubtitleTrack.js +38 -38
  213. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  214. package/services/media/tracks/VideoTrack.js +33 -33
  215. package/services/media/tracks/audio-bound.js +233 -233
  216. package/services/media/tracks/index.js +15 -15
  217. package/services/media/tracks/language-detect.js +174 -174
  218. package/services/server/OutputLifecycle.js +418 -418
  219. package/services/server/SegmentServing.js +1150 -1150
  220. package/services/server/ViewerRequests.js +785 -760
  221. package/services/server/WaitLedger.js +142 -142
  222. package/services/server/controllers/PlaybackController.js +40 -40
  223. package/services/server/controllers/SubtitleController.js +109 -109
  224. package/services/server/http-responses.js +60 -60
  225. package/services/server/playback-readiness.js +516 -708
  226. package/services/server/transcode-session-files.js +325 -325
  227. package/services/server/wire-outputs.js +1020 -995
  228. package/services/storage/Diagnostics.js +202 -202
  229. package/services/storage/MachineBudget.js +235 -235
  230. package/services/storage/allowance.js +107 -107
  231. package/services/storage/core-dumps.js +106 -106
  232. package/services/storage/files/CompletedFiles.js +391 -391
  233. package/services/storage/files/piece-from-whole-file.js +118 -118
  234. package/services/storage/free.js +71 -71
  235. package/services/storage/keep.js +48 -48
  236. package/services/storage/machine-memory.js +56 -56
  237. package/services/storage/memory-report.js +575 -575
  238. package/services/storage/piece-store/piece-disk-store.js +539 -539
  239. package/services/storage/piece-store/piece-lru.js +495 -495
  240. package/services/storage/piece-store/shared-piece-store.js +1937 -1931
  241. package/services/storage/returns.js +103 -103
  242. package/services/storage/segment-store/SegmentStore.js +1255 -1240
  243. package/services/storage/wire.js +175 -175
  244. package/services/torrent/demand/DemandRegister.js +202 -202
  245. package/services/torrent/demand/Urgency.js +137 -137
  246. package/services/torrent/demand/Window.js +118 -118
  247. package/services/torrent/demand/index.js +8 -8
  248. package/services/torrent/demand/pieces.js +140 -140
  249. package/services/torrent/download/SwarmSelection.js +386 -386
  250. package/services/torrent/download/registry.js +102 -102
  251. package/services/torrent/download/withdraw-claim.js +80 -80
  252. package/services/torrent/files.js +455 -455
  253. package/services/torrent/naming.js +619 -619
  254. package/services/torrent/piece-store-of.js +29 -29
  255. package/services/torrent/supply-margin.js +262 -262
  256. package/services/torrent/torrent-pool.js +2688 -2675
  257. package/services/torrent/worker/background-fill.js +213 -213
  258. package/services/torrent/worker/channel.js +271 -271
  259. package/services/torrent/worker/client.js +765 -765
  260. package/services/torrent/worker/fastest-wires.js +342 -342
  261. package/services/torrent/worker/handle-state.js +23 -23
  262. package/services/torrent/worker/held-bytes.js +155 -155
  263. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  264. package/services/torrent/worker/piece-reader.js +1078 -1078
  265. package/services/torrent/worker/pool-adapter.js +371 -371
  266. package/services/torrent/worker/protocol.js +184 -184
  267. package/services/torrent/worker/resume-warm.js +107 -107
  268. package/services/torrent/worker/webrtc-shim.js +219 -219
  269. package/services/torrent/worker/worker.js +1129 -1129
  270. package/services/transport/data-channel-handler.js +1369 -1369
  271. package/services/transport/delivery-probe.js +842 -842
  272. package/services/transport/health-collector.js +53 -53
  273. package/services/transport/nat-classifier.js +231 -231
  274. package/services/transport/packet-witness.js +808 -808
  275. package/services/transport/port-mapper.js +235 -235
  276. package/services/transport/registry-api.js +81 -81
  277. package/services/transport/tunnel-client.js +447 -447
  278. package/services/transport/usrsctp-state.js +175 -175
  279. package/services/transport/webrtc-manager.js +484 -484
  280. package/services/viewer/Assignments.js +340 -340
  281. package/services/viewer/PriorityMap.js +390 -390
  282. package/services/viewer/PriorityOrchestrator.js +325 -325
  283. package/services/viewer/Viewer.js +691 -691
  284. package/services/viewer/Viewers.js +368 -368
  285. package/services/viewer/active-output.js +37 -37
  286. package/services/viewer/audio-start.js +46 -46
  287. package/services/viewer/buffer-trend.js +81 -81
  288. package/services/viewer/choices.js +647 -647
  289. package/services/viewer/positions.js +126 -126
  290. package/services/viewer/report-intake.js +46 -46
  291. package/store/source-registry.js +71 -71
  292. package/test/asset-paths.test.js +28 -28
  293. package/test/audio-calibration.test.js +23 -0
  294. package/test/audio-inventory.test.js +212 -212
  295. package/test/audio-track-bitrate.test.js +40 -40
  296. package/test/auto-quality-step.test.js +707 -707
  297. package/test/available-share.test.js +76 -76
  298. package/test/background-fill.test.js +190 -190
  299. package/test/client-log-file.test.js +110 -110
  300. package/test/completed-files.test.js +115 -115
  301. package/test/concurrent-cost.test.js +148 -148
  302. package/test/container-media-info.test.js +228 -228
  303. package/test/contention.test.js +76 -76
  304. package/test/core-dumps.test.js +57 -57
  305. package/test/coverage-follows-the-disk.test.js +228 -228
  306. package/test/coverage-map.test.js +195 -195
  307. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  308. package/test/cut-times-timeline.test.js +65 -65
  309. package/test/cuts-follow-published-grid.test.js +109 -109
  310. package/test/data-channel-frame.test.js +63 -63
  311. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  312. package/test/dead-torrent-handle.test.js +52 -52
  313. package/test/declared-rates.test.js +116 -116
  314. package/test/declared-tracks.test.js +43 -43
  315. package/test/decode-cost-fit.test.js +202 -202
  316. package/test/decode-cost.test.js +462 -462
  317. package/test/delivery-probe.test.js +393 -393
  318. package/test/demand-register.test.js +206 -206
  319. package/test/dht-bootstrap.test.js +72 -72
  320. package/test/drain-threat.test.js +65 -65
  321. package/test/duplicate-add.test.js +48 -48
  322. package/test/encode-admission.test.js +146 -146
  323. package/test/encode-cost.test.js +283 -283
  324. package/test/encode-exit.test.js +111 -111
  325. package/test/encode-orchestrator.test.js +419 -419
  326. package/test/encode-plan-viewers.test.js +718 -718
  327. package/test/encode-plan.test.js +545 -545
  328. package/test/encode-run-state.test.js +314 -314
  329. package/test/encode-run.test.js +162 -162
  330. package/test/encode-slope.test.js +46 -46
  331. package/test/encoded-output.test.js +39 -39
  332. package/test/encoder-kinds.test.js +122 -122
  333. package/test/encoder-readings.test.js +46 -46
  334. package/test/every-claimant-is-told-its-share.test.js +152 -152
  335. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  336. package/test/failed-start-breaker.test.js +202 -202
  337. package/test/fastest-wires.test.js +143 -143
  338. package/test/ffmpeg-stream-counts.test.js +78 -78
  339. package/test/file-edges.test.js +191 -191
  340. package/test/fmp4-presentation-clock.test.js +162 -147
  341. package/test/fmp4-self-contained.test.js +80 -80
  342. package/test/fmp4-timestamps.test.js +140 -140
  343. package/test/fmp4-track-completeness.test.js +41 -41
  344. package/test/format-keyed-outputs.test.js +78 -78
  345. package/test/health-metrics.test.js +38 -38
  346. package/test/held-request-width.test.js +157 -157
  347. package/test/helpers/encode-run.js +136 -136
  348. package/test/helpers/held-file.js +29 -29
  349. package/test/helpers/manager.js +57 -57
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +102 -102
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +112 -112
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +292 -292
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/memory-budget.test.js +390 -390
  372. package/test/move-cost.test.js +216 -216
  373. package/test/mp4-subtitles.test.js +173 -173
  374. package/test/one-authority.test.js +285 -285
  375. package/test/one-budget.test.js +149 -149
  376. package/test/one-output-one-session.test.js +273 -270
  377. package/test/one-resource-divided.test.js +185 -185
  378. package/test/one-serving-output-per-height.test.js +458 -458
  379. package/test/orchestrator-wired.test.js +206 -206
  380. package/test/output-catalog.test.js +39 -39
  381. package/test/output-shape.test.js +21 -21
  382. package/test/output-spec.test.js +251 -251
  383. package/test/output-speed.test.js +88 -88
  384. package/test/outputs-being-prepared.test.js +40 -40
  385. package/test/packet-witness-ring.test.js +236 -236
  386. package/test/packet-witness.test.js +148 -148
  387. package/test/piece-disk-store.test.js +401 -401
  388. package/test/piece-from-whole-file.test.js +129 -129
  389. package/test/piece-lru.test.js +319 -319
  390. package/test/piece-reader.test.js +162 -162
  391. package/test/piece-selection-offset.test.js +85 -85
  392. package/test/piece-store-eviction.test.js +595 -595
  393. package/test/piece-store-never-refuses.test.js +153 -153
  394. package/test/piece-store-reservations.test.js +403 -403
  395. package/test/piece-store-slow-disk.test.js +159 -159
  396. package/test/piece-tail.test.js +97 -97
  397. package/test/plan-host-timings.test.js +68 -68
  398. package/test/plan-is-steady.test.js +225 -225
  399. package/test/playback-readiness.test.js +483 -453
  400. package/test/playback-start-regressions.test.js +99 -99
  401. package/test/priority-map-download.test.js +174 -174
  402. package/test/priority-map-emptied.test.js +204 -204
  403. package/test/priority-map-per-output.test.js +215 -215
  404. package/test/priority-map.test.js +163 -163
  405. package/test/probe-wedge-certainty.test.js +60 -60
  406. package/test/produced-copy-choice.test.js +213 -213
  407. package/test/published-timeline.test.js +83 -83
  408. package/test/quality-offer.test.js +162 -162
  409. package/test/quality-variants.test.js +1257 -1257
  410. package/test/rate-control-in-the-output.test.js +180 -180
  411. package/test/read-survives-withdrawal.test.js +164 -164
  412. package/test/read-window.test.js +365 -365
  413. package/test/resume-warm.test.js +39 -39
  414. package/test/run-graph-drift.test.js +37 -37
  415. package/test/run-inputs.test.js +80 -80
  416. package/test/run-intervals.test.js +100 -100
  417. package/test/run-position-follows-published-grid.test.js +89 -89
  418. package/test/run-progress.test.js +65 -65
  419. package/test/same-height-switch.test.js +664 -664
  420. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  421. package/test/seek-landing.test.js +109 -109
  422. package/test/seek-target-not-superseded.test.js +195 -195
  423. package/test/segment-closed-on-both-branches.test.js +96 -96
  424. package/test/segment-cut-times.test.js +63 -63
  425. package/test/segment-demand.test.js +80 -80
  426. package/test/segment-hold-supersede.test.js +91 -91
  427. package/test/segment-judged-by-its-own-header.test.js +221 -221
  428. package/test/segment-serve-wiring.test.js +475 -475
  429. package/test/segment-store-eviction.test.js +247 -247
  430. package/test/segment-store.test.js +338 -323
  431. package/test/segment-true-start.test.js +86 -86
  432. package/test/segments-are-shared.test.js +176 -176
  433. package/test/send-chunk-bytes.test.js +86 -86
  434. package/test/serving-existing-output.test.js +105 -105
  435. package/test/serving-output.test.js +102 -102
  436. package/test/shared-piece-store.test.js +310 -310
  437. package/test/sidecar-naming.test.js +142 -142
  438. package/test/soundtrack-rate-bound.test.js +289 -289
  439. package/test/source-file.test.js +133 -133
  440. package/test/source-registry.test.js +18 -18
  441. package/test/split-a-stretch.test.js +139 -139
  442. package/test/stale-request-after-seek.test.js +158 -158
  443. package/test/startup-readings.test.js +124 -124
  444. package/test/stream-route.test.js +209 -209
  445. package/test/subtitle-cue-framing.test.js +200 -200
  446. package/test/subtitle-cue-source.test.js +102 -102
  447. package/test/subtitle-cue-walk.test.js +370 -370
  448. package/test/subtitle-cursor.test.js +63 -63
  449. package/test/subtitle-defaults.test.js +118 -118
  450. package/test/subtitle-language.test.js +252 -252
  451. package/test/subtitle-track-numbering.test.js +386 -386
  452. package/test/supply-margin.test.js +177 -177
  453. package/test/swarm-follows-readers.test.js +174 -174
  454. package/test/swarm-reach.test.js +130 -130
  455. package/test/swarm-selection.test.js +220 -220
  456. package/test/tail-duplication.test.js +167 -167
  457. package/test/the-channel-has-both-ends.test.js +91 -91
  458. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  459. package/test/thread-pool.test.js +53 -53
  460. package/test/timeline.test.js +84 -84
  461. package/test/torrent-contents.test.js +235 -235
  462. package/test/torrent-cost.test.js +71 -71
  463. package/test/torrent-files.test.js +221 -221
  464. package/test/torrent-naming.test.js +255 -255
  465. package/test/tracks-begin-together.test.js +209 -209
  466. package/test/tunnel-renewal.test.js +167 -167
  467. package/test/two-viewers-one-picture.test.js +504 -504
  468. package/test/upload-hurry.test.js +186 -186
  469. package/test/usrsctp-state.test.js +131 -131
  470. package/test/video-facts.test.js +102 -102
  471. package/test/viewer-assignments.test.js +143 -143
  472. package/test/viewer-outputs.test.js +285 -285
  473. package/test/viewer-presence.test.js +116 -116
  474. package/test/viewer-requests-admission.test.js +89 -89
  475. package/test/viewer-states.test.js +189 -189
  476. package/test/viewer-subtitle-subscription.test.js +96 -96
  477. package/test/viewer.test.js +139 -139
  478. package/test/viewing-generations.test.js +390 -390
  479. package/test/visible-rung.test.js +37 -37
  480. package/test/wait-ledger.test.js +100 -100
  481. package/test/webrtc-shim.test.js +149 -149
  482. package/test/wedge-certainty.test.js +131 -131
  483. package/test/withdraw-piece-claim.test.js +212 -212
  484. package/test/worker-channel.test.js +158 -158
  485. package/utils/client-log-file.js +151 -151
  486. package/utils/logger.js +312 -312
  487. package/utils/parse-range.js +25 -25
  488. package/utils/perf.js +59 -59
  489. package/utils/torrent-source-key.js +38 -38
@@ -1,1235 +1,1237 @@
1
- /**
2
- * The encoders of this proxy, from the plan's decision to the end of the process.
3
- *
4
- * The plan (`EncodePlan`, through `EncodeOrchestrator`) decides where a run goes;
5
- * this builds it with the arguments its output needs, follows what it produces,
6
- * says how far it got, and accounts for how it ended. Nothing here decides where
7
- * an encoder should be.
8
- */
9
-
10
- import path from "node:path";
11
- import { spawn } from "node:child_process";
12
- import { contentionPenalty } from "./contention.js";
13
- import { ENCODE_RUN_STATE, liveRunsOf, runStateOf, wireState } from "./encode-run-state.js";
14
- import { ENCODE_EXIT } from "./encode-exit.js";
15
- import { EncodeRun } from "./EncodeRun.js";
16
- import { computeOutputDimensions } from "./args.js";
17
- import { buildRunCommand, trueStartOf } from "./run-command.js";
18
-
19
- /**
20
- * @typedef {Object} SegmentFiles
21
- * @property {(address: string) => string} pathFor
22
- * @property {(address: string) => Buffer | null} initOf
23
- * @property {(address: string) => string} directoryFor
24
- * @property {(address: string, makingName: string, format: object) => string | null} publish
25
- */
26
- // How far the accounting of a backward restart looks for work about to be done
27
- // twice. It runs on the restart path and a session an hour in has thousands of
28
- // segments; the figure is for a comparison, not an inventory.
29
- const BACKWARD_RESTART_SCAN_SEGMENTS = 300;
30
- const PROGRESS_LOG_INTERVAL_MS = 5_000;
31
- /**
32
- * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
33
- *
34
- * @param {number} seconds
35
- * @returns {string}
36
- */
37
- export function formatSeconds(seconds) {
38
- if (!Number.isFinite(seconds) || seconds < 0) {
39
- return "n/a";
40
- }
41
- const total = Math.floor(seconds);
42
- const hours = Math.floor(total / 3600);
43
- const minutes = Math.floor((total % 3600) / 60);
44
- const rest = total % 60;
45
- return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
46
- }
47
- /**
48
- * Where this session's encoding begins: the earliest number any live run of it
49
- * was given.
50
- *
51
- * @param {object[] | Set<object>} runs
52
- * @returns {number | null}
53
- */
54
- export function earliestRunStart(runs) {
55
- const live = liveRunsOf(runs);
56
- return live.length > 0 ? live[0].from : null;
57
- }
58
- /**
59
- * The live run of this session that was given this number, if any.
60
- *
61
- * A run with an explicit stretch owns the whole of it. One WITHOUT an end owns
62
- * only as far as it has actually got: claiming the rest of the film would make
63
- * it the owner of every number in front of it, including ones another run was
64
- * expressly given.
65
- *
66
- * @param {object[] | Set<object>} runs
67
- * @param {number} index
68
- * @returns {object | null}
69
- */
70
- function ownRunMaking(runs, index) {
71
- let answer = null;
72
- for (const run of liveRunsOf(runs)) {
73
- if (run.from > index) {
74
- break; // Ordered by start, so nothing further can hold this number.
75
- }
76
- if (Number.isInteger(run.to) && run.to >= run.from && index > run.to) {
77
- continue; // Its stretch ends before this number.
78
- }
79
- answer = run;
80
- }
81
- return answer;
82
- }
83
-
84
- /**
85
- * Whether an encoder run died because its INPUT went away, rather than because
86
- * of anything about the encode itself.
87
- *
88
- * These are the messages the read path and ffmpeg's HTTP client produce when
89
- * the torrent is gone, being re-added, or has no data for the range yet — all
90
- * of them temporary by nature: the source can be added again and the pieces
91
- * fetched again.
92
- *
93
- * @param {string} message
94
- * @returns {boolean}
95
- */
96
- export function isInputUnavailable(message) {
97
- const text = typeof message === "string" ? message : "";
98
- return (
99
- /Error reading HTTP response/i.test(text) ||
100
- /not found in (?:magnet|torrent):/i.test(text) ||
101
- /Unknown source/i.test(text) ||
102
- /is gone and cannot be re-added/i.test(text) ||
103
- /Read error at pos/i.test(text) ||
104
- /Server returned 5\d\d/i.test(text) ||
105
- /Input\/output error/i.test(text) ||
106
- /Connection reset by peer/i.test(text) ||
107
- /End of file/i.test(text)
108
- );
109
- }
110
-
111
- /**
112
- * The ffmpeg command as one readable line.
113
- *
114
- * Everything is shown as passed except the list of cut times, which is one
115
- * value per segment — 830 of them on a two-hour film, about 7 KB of log for a
116
- * single run, repeated on every restart. The count and the two ends say
117
- * everything the list is ever consulted for: whether cutting was explicit at
118
- * all, how far it reaches, and where it starts.
119
- *
120
- * @param {string[]} args
121
- * @returns {string}
122
- */
123
- export function describeFfmpegArgs(args) {
124
- const parts = [];
125
- for (let index = 0; index < args.length; index += 1) {
126
- const value = args[index];
127
- // Both take the same list, and a keyframe-grid variant passes it twice.
128
- // `-force_key_frames` also takes an expression, which is short and is left
129
- // alone — only a list is folded.
130
- if (
131
- (value === "-segment_times" || value === "-force_key_frames") &&
132
- typeof args[index + 1] === "string" &&
133
- args[index + 1].includes(",")
134
- ) {
135
- const times = args[index + 1].split(",");
136
- parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
137
- index += 1;
138
- continue;
139
- }
140
- parts.push(value);
141
- }
142
- return parts.join(" ");
143
- }
144
-
145
- export class EncodeRuns {
146
- #inputState = new WeakMap();
147
- /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
148
- #host;
149
-
150
- /**
151
- * Whether a re-decision of what encoders should exist is already queued for
152
- * the end of this turn. See `planEncodersSoon`.
153
- * @type {boolean}
154
- */
155
- #planScheduled = false;
156
-
157
- /**
158
- * @param {object} host - `logger`, `viewerSecondsOn`, `noteRunStarting`, `inputOf`, `producedNumbers`, `servesAudioSeparately`, `disposeSession`, `contentionPenalties`, `encodeCost`, `encodeOrchestrator`, `encoders`, `ffmpegBin`, `outputTimes`, `outputs`, `priority`, `segmentDurationSec`, `segmentFiles`, `videoEncoder`
159
- * @param {SegmentFiles} host.segmentFiles - The storage operations used to
160
- * write and inspect output files.
161
- */
162
- constructor(host) {
163
- this.#host = host;
164
- }
165
-
166
- #inputStateFor(output) {
167
- let state = this.#inputState.get(output);
168
- if (!state) {
169
- state = {
170
- readWindowBytes: 0,
171
- inputRetryCount: 0,
172
- backwardRestarts: { count: 0, segmentsBack: 0, worstBack: 0, remade: 0 },
173
- firstWantedAt: new Map(),
174
- /** Bytes the swarm has delivered to this output's own input reads. */
175
- inputBytes: 0,
176
- /** The mismatch between the held init and the run's size last said, so it is said once. */
177
- initSizeSaid: ""
178
- };
179
- this.#inputState.set(output, state);
180
- }
181
- return state;
182
- }
183
-
184
- setReadWindow(output, bytes) {
185
- this.#inputStateFor(output).readWindowBytes = Number.isFinite(bytes) ? bytes : 0;
186
- }
187
-
188
- readWindowFor(output) {
189
- return this.#inputStateFor(output).readWindowBytes;
190
- }
191
-
192
- noteWanted(output, index, at = Date.now()) {
193
- const wanted = this.#inputStateFor(output).firstWantedAt;
194
- if (!wanted.has(index)) wanted.set(index, at);
195
- }
196
-
197
- resetInputRetry(output) {
198
- this.#inputStateFor(output).inputRetryCount = 0;
199
- }
200
-
201
- /**
202
- * Count bytes the swarm has delivered to one output's own input read.
203
- *
204
- * Called by the `/stream` route for every fragment it writes to an encoder.
205
- * Cheap on purpose — one addition, no clock, no log — because it runs per
206
- * fragment on the path that feeds ffmpeg.
207
- *
208
- * @param {string} outputId
209
- * @param {number} bytes
210
- * @returns {void}
211
- */
212
- noteInputBytes(outputId, bytes) {
213
- if (!outputId || !(bytes > 0)) {
214
- return;
215
- }
216
- const output = this.#host.outputs.get(outputId);
217
- if (output) {
218
- this.#inputStateFor(output).inputBytes += bytes;
219
- }
220
- }
221
-
222
- /**
223
- * @param {object} output
224
- * @returns {number}
225
- */
226
- inputBytesOf(output) {
227
- return output ? this.#inputStateFor(output).inputBytes : 0;
228
- }
229
-
230
- runsOf(output) {
231
- return output ? this.#host.encodeOrchestrator.runsOn(output.outputKey) : [];
232
- }
233
-
234
- /**
235
- * Where the earliest live encoder of this output began, or null with none.
236
- *
237
- * @param {object} output
238
- * @returns {number | null}
239
- */
240
- earliestStartOf(output) {
241
- return earliestRunStart(this.runsOf(output));
242
- }
243
-
244
- isLive(output) {
245
- return Boolean(output && this.#host.outputs.get(output.id) === output);
246
- }
247
-
248
- liveRunsOf(output) {
249
- return liveRunsOf(this.runsOf(output));
250
- }
251
-
252
- runStateOf(output) {
253
- return output ? this.#host.encodeOrchestrator.stateOf(output.outputKey) : runStateOf([]);
254
- }
255
-
256
- lastErrorOf(output) {
257
- return output ? this.#host.encodeOrchestrator.errorOf(output.outputKey) : "";
258
- }
259
-
260
- /**
261
- * Whether this output's encoding has failed for good.
262
- *
263
- * @param {object} output
264
- * @returns {boolean}
265
- */
266
- hasFailed(output) {
267
- return this.runStateOf(output) === ENCODE_RUN_STATE.ENDED_FAILED;
268
- }
269
-
270
- /**
271
- * Whether this output's input went away and is being fetched again.
272
- *
273
- * @param {object} output
274
- * @returns {boolean}
275
- */
276
- isWaitingForInput(output) {
277
- return this.runStateOf(output) === ENCODE_RUN_STATE.RETRY_WAIT;
278
- }
279
-
280
- /**
281
- * The run state in the words the browser is told.
282
- *
283
- * @param {object} output
284
- * @returns {string}
285
- */
286
- wireStateOf(output) {
287
- return wireState(this.runStateOf(output));
288
- }
289
-
290
- /**
291
- * What went wrong, when this output's encoding has failed; empty otherwise.
292
- *
293
- * @param {object} output
294
- * @returns {string}
295
- */
296
- failureOf(output) {
297
- return this.hasFailed(output) ? this.lastErrorOf(output) : "";
298
- }
299
-
300
- progressOf(output, index = null) {
301
- if (!output) {
302
- return null;
303
- }
304
- const at = Number.isInteger(index)
305
- ? index
306
- : this.#host.outputTimes.segmentIndexForTime(output, this.#host.viewerSecondsOn(output));
307
- const progress = this.#host.encodeOrchestrator.progressOf(output.outputKey, at);
308
- if (progress) {
309
- return progress;
310
- }
311
- const start = this.#host.viewerSecondsOn(output);
312
- const total = Number(output.file?.durationSeconds);
313
- return {
314
- processedSeconds: start,
315
- startPositionSeconds: start,
316
- totalSeconds: Number.isFinite(total) && total > 0 ? total : null,
317
- percent: 0,
318
- remainingSeconds: Number.isFinite(total) && total > 0 ? Math.max(0, total - start) : null,
319
- speed: "",
320
- updatedAt: this.#host.outputs.startedAt(output) ?? Date.now()
321
- };
322
- }
323
-
324
- planEncodersNow() {
325
- const byOutput = this.#outputsWithSessions();
326
- for (const [address, sessions] of byOutput) {
327
- // From the TIMELINE, which is where how a file is cut has lived since
328
- // 2.76.0. Read off the session it left, this was `undefined` on every
329
- // session ever made: the map then held no length, and the walk that
330
- // gives a run its end ran to MAX_SAFE_INTEGER — the main thread spun at
331
- // 100% and the proxy answered nothing, measured on the addon host
332
- // 2026-09-05 with the stack read out of the live process.
333
- const segmentCount = Number(sessions[0].timeline?.segmentCount) || 0;
334
- if (segmentCount > 0) {
335
- this.#host.encodeOrchestrator.setSegmentCount(address, segmentCount);
336
- }
337
- }
338
- // THE MAP IS BUILT ONCE, IN ITS OWN LAYER, AND BOTH SIDES READ THAT ONE.
339
- //
340
- // It used to be built twice: once here, per viewer, converted and stated to
341
- // the encoding as a window each, and once again inside `publishFor` for the
342
- // downloading. Two answers to one question, and the encoding's copy carried
343
- // the viewer's NAME as the key of a claim — against the rule that the
344
- // encoding and the viewer are not connected at all.
345
- this.#host.priority.publishFor({
346
- sessionGroups: byOutput.values(),
347
- });
348
- // FROM THE VIEWERS OF THAT OUTPUT, not from the viewers of the film.
349
- //
350
- // Both scopes are right for what asks them: the swarm is asked for bytes of
351
- // a FILE, which every output of it reads, and encoders are placed per
352
- // OUTPUT, which a person watching 480p wants nothing of at 1080p. Handed the
353
- // film's map, the plan wanted an encoder on every output of it, and what
354
- // stopped the unwatched ones was this class killing them by its own
355
- // judgement — while the viewer's own move announced itself and had the plan
356
- // start them again.
357
- //
358
- // Converted into each output's own numbering, because two outputs of one
359
- // film are cut independently and the same second is a different number in
360
- // each: 454 pieces against 401 on the field file.
361
- for (const [address, sessions] of byOutput) {
362
- const timeline = sessions[0].timeline;
363
- this.#host.encodeOrchestrator.notePriorityMap(
364
- address,
365
- timeline?.inSegments?.(
366
- this.#host.priority.mapForOutput(address),
367
- Number(timeline?.segmentCount) || 0
368
- ) ?? []
369
- );
370
- }
371
- this.#host.encodeOrchestrator.reconcile();
372
- }
373
-
374
- /**
375
- * Re-decide what encoders should exist, once, after the change that is being
376
- * made now.
377
- *
378
- * COALESCED, NOT DELAYED. One turn of the event loop may carry several
379
- * changes — a viewer arrives and is placed and states their soundtrack — and
380
- * each of them is a reason to re-decide, while re-deciding three times in a
381
- * row would give the same answer three times and could act on a half-built
382
- * state. So the decision is taken once, after the current turn, and no
383
- * interval is involved: there is nothing to choose and nothing to tune.
384
- *
385
- * @returns {void}
386
- */
387
- planEncodersSoon() {
388
- if (this.#planScheduled) {
389
- return;
390
- }
391
- this.#planScheduled = true;
392
- queueMicrotask(() => {
393
- this.#planScheduled = false;
394
- try {
395
- this.planEncodersNow();
396
- } catch (error) {
397
- const message = error instanceof Error ? error.message : String(error);
398
- this.#host.logger.warn(`transcode could not re-plan encoders: ${message}`);
399
- }
400
- });
401
- }
402
-
403
- /**
404
- * The live sessions grouped by the output they produce.
405
- *
406
- * @returns {Map<string, HlsSession[]>}
407
- */
408
- #outputsWithSessions() {
409
- /** @type {Map<string, HlsSession[]>} */
410
- const byOutput = new Map();
411
- for (const session of this.#host.outputs.values()) {
412
- const address = session.outputKey ?? "";
413
- if (!address) {
414
- continue;
415
- }
416
- byOutput.set(address, [...(byOutput.get(address) ?? []), session]);
417
- }
418
- return byOutput;
419
- }
420
-
421
- forgetEncodingOfGone(output) {
422
- if (this.#host.outputs.outputsOn(output.outputKey).length === 0) {
423
- this.#host.encodeOrchestrator.forgetOutput(output.outputKey);
424
- }
425
- }
426
-
427
- /**
428
- * Make an encoder for this output, beginning at this segment.
429
- *
430
- * Another run of the SAME session, not another session. A run at another
431
- * position is not a different output — the material, the tracks, the grid and
432
- * the box are all the same, and only where it begins differs, which changes
433
- * no byte of what is produced. Both write into the output's one directory and
434
- * either viewer is served whatever either has made.
435
- *
436
- * @param {string} address
437
- * @param {number} from
438
- * @param {number} to
439
- * @param {string} because - The plan's own words for why it placed this one.
440
- * @returns {object | null}
441
- */
442
- makeRunAt(address, from, to, because) {
443
- let base = null;
444
- for (const session of this.#host.outputs.values()) {
445
- if (session.outputKey === address) {
446
- base = session;
447
- break;
448
- }
449
- }
450
- if (!base) {
451
- return null;
452
- }
453
- // A position that has already failed to start, this many times running,
454
- // will fail again: nothing about it has changed between the attempts, which
455
- // is exactly why the attempts keep taking the same fraction of a second and
456
- // ending the same way.
457
- //
458
- // The plan is arithmetic over coverage and demand, and neither of them
459
- // knows that a process refused to start — so without asking here, the plan
460
- // commands the same start, the run ends, the ended stretch goes back to the
461
- // map, and the plan commands it again. Measured 2026-09-05 with ffmpeg
462
- // absent: fifty passes of the plan in the time a probe took to notice, as
463
- // fast as spawning could fail. The five-second timer this decision used to
464
- // sit on hid that, at the price of hiding it in the field too — the loop
465
- // seen there ran for sixteen minutes and read as "a restart every five
466
- // seconds".
467
- //
468
- // A DIFFERENT position is unaffected and gets its own budget, and the count
469
- // resets the moment a run at this one does real work.
470
- if (!this.#host.encodeOrchestrator.mayStartAt(address, from)) {
471
- return null;
472
- }
473
- // The encoder is built and handed back in this same call. Nothing here
474
- // waits: the one thing this path used to wait for was the death of the run
475
- // it was replacing, and that killing is gone. Answering with nothing while
476
- // the encoder was built behind the answer is what let the same stretch be
477
- // started over and over — 684 starts in 482 seconds of field 2026-09-05.
478
- try {
479
- return this.#startEncodeRun(base, from, because, { to });
480
- } catch (error) {
481
- const message = error instanceof Error ? error.message : String(error);
482
- this.#host.logger.warn(`transcode could not start a run at #${from} of ${address}: ${message}`);
483
- return null;
484
- }
485
- }
486
-
487
- /**
488
- * How many encoders this machine can afford on one output at once.
489
- *
490
- * Measured rather than chosen, and it is the same arithmetic that decides
491
- * which quality steps are offered: what a second job costs here is not
492
- * assumed to be nothing. Field 2026-09-03 on the addon host, `testsrc2`
493
- * through libx264 `ultrafast` — at 854x480 one run makes 7.12x and two make
494
- * 4.20x and 4.16x, so both stay far above realtime and a second is
495
- * affordable; at 1920x1080 one makes 1.96x and two make 0.99x and 0.98x, so
496
- * the machine is full at one.
497
- *
498
- * @param {string} address
499
- * @returns {number}
500
- */
501
- maxRunsForOutput(address) {
502
- const alone = this.#host.encodeCost.speedForOutput(address);
503
- if (!(alone > 0)) {
504
- // The host measured nothing at all, which is a broken startup rather than
505
- // a state to plan around. One encoder is what it keeps.
506
- return 1;
507
- }
508
- let affordable = 1;
509
- for (;;) {
510
- const { penalty, measured, from } = contentionPenalty(affordable, this.#host.contentionPenalties);
511
- // An UNMEASURED penalty is 1, and that is the honest answer to "what does
512
- // a second job cost" only in the sense that nothing has been measured —
513
- // it is not a statement that a second job is free. Measured on the addon
514
- // host it is 1.70x at 854x480 and 1.98x at 1920x1080, so taking 1 would
515
- // let a machine that cannot hold two runs start two. Where nothing has
516
- // been measured, one is what it has.
517
- //
518
- // `from` is the concurrency the reading came from, and past the measured
519
- // range `contentionPenalty` HOLDS the largest reading rather than continue
520
- // a curve two points cannot describe. Held is the right answer to "what
521
- // does this cost", and the wrong one to "may I start another": it would
522
- // price a fifth encoder at what a second was measured to cost, and a fast
523
- // host would keep dividing until some chosen ceiling stopped it. So the
524
- // ladder stops where the measurements stop — the bound on how many
525
- // encoders may run is measured, like the budget itself, and there is no
526
- // constant here to overrule it.
527
- if (!measured || from !== affordable || !(alone / penalty >= 1)) {
528
- break;
529
- }
530
- affordable += 1;
531
- }
532
- return affordable;
533
- }
534
-
535
- /**
536
- * A segment this run made has just been served.
537
- *
538
- * The one event whose raising is guarded by the state, and the guard is a
539
- * READ of the state rather than a second copy of it: "something has been
540
- * produced" is a level, not an edge, so without the guard every served
541
- * segment would raise it and the log would fill with refusals.
542
- *
543
- * Whose file it is decides the rest, and the directory answers that outright:
544
- * runs write into one each, and serving searches them newest-first, so a
545
- * segment left by an EARLIER run is served routinely. Comparing indices
546
- * instead would have been fooled by the ordinary case of a backward seek —
547
- * the new run starts at #10, the old one left #50 on disk, and #50 is above
548
- * the new run's start index while saying nothing about it.
549
- *
550
- * @param {HlsSession} session
551
- * @param {string} filePath - Where the served segment was actually found.
552
- * @returns {void}
553
- */
554
- noteRunProducedSegment(session, filePath) {
555
- if (typeof filePath !== "string" || path.dirname(filePath) !== this.#host.segmentFiles.pathFor(session.outputKey ?? "")) {
556
- return;
557
- }
558
- const index = session.segmentFormat.segmentIndexFromName(path.basename(filePath));
559
- if (index === null) {
560
- return;
561
- }
562
- // Whichever run was given this number. An output has as many runs as the
563
- // machine affords, and a segment moves the one it belongs to out of
564
- // starting — not whichever happens to be listed first.
565
- ownRunMaking(this.runsOf(session), index)?.noteProduced(index);
566
- }
567
-
568
- /**
569
- * Say so when a run is about to encode a picture the init segment already in
570
- * the player's hands does not describe.
571
- *
572
- * This is the whole class of fault named in one line, and it exists because
573
- * the fault is otherwise SILENT: no layer reports an error, the encoder is
574
- * healthy, segments are served in milliseconds, and what the viewer gets is
575
- * either a band of macroblock garbage or a picture that never appears. The
576
- * shape is the one 2.48.0 uses for the TIME a run begins at — a run states
577
- * where it really landed, and a disagreement with what was published is
578
- * named rather than left to be inferred from a browser's own reading.
579
- *
580
- * The size the init describes is read from the init's own bytes, not taken
581
- * from our record of what the encoder was told, because those two disagreeing
582
- * IS the fault. The size the run will produce is computed by the same
583
- * arithmetic the scale filter performs, so a source smaller than the target
584
- * box is not reported as a disagreement.
585
- *
586
- * Said once per distinct pair of sizes: a run repeated at the same wrong size
587
- * has nothing further to say.
588
- *
589
- * @param {HlsSession} session
590
- * @returns {void}
591
- */
592
- #warnIfRunLeavesTheInitBehind(session) {
593
- const initBytes = this.#host.segmentFiles.initOf(session.outputKey ?? "");
594
- if (!session.spec.transcodesVideo || !initBytes || initBytes.length === 0) {
595
- return; // nothing served yet, or nothing being encoded
596
- }
597
- const format = session.segmentFormat;
598
- if (typeof format?.initVideoSize !== "function") {
599
- return; // a self-describing container (MPEG-TS) cannot have this fault
600
- }
601
- const described = format.initVideoSize(initBytes);
602
- if (!described) {
603
- return;
604
- }
605
- if (!(session.output.encodeWidth > 0) || !(session.output.encodeHeight > 0)) {
606
- return; // the run keeps the encoder's own default box; nothing was told
607
- }
608
- const producing = computeOutputDimensions(
609
- session.output.encodeWidth,
610
- session.output.encodeHeight,
611
- session.file.width,
612
- session.file.height
613
- );
614
- if (!producing) {
615
- return; // the source size is unknown, so nothing can be predicted
616
- }
617
- if (described.width === producing.w && described.height === producing.h) {
618
- return;
619
- }
620
- const said = `${described.width}x${described.height}->${producing.w}x${producing.h}`;
621
- const inputState = this.#inputStateFor(session);
622
- if (inputState.initSizeSaid === said) {
623
- return;
624
- }
625
- inputState.initSizeSaid = said;
626
- this.#host.logger.warn(
627
- `transcode ${session.id} is about to encode ${producing.w}x${producing.h} while the init segment ` +
628
- `the player holds describes ${described.width}x${described.height} "${session.file.name}" — ` +
629
- `every fragment of this run will be decoded against parameter sets for a picture that is not ` +
630
- `being made. A change of size is a change of variant; nothing here should have moved it.`
631
- );
632
- }
633
-
634
- /**
635
- * How far a run starting here may work before it meets somebody else's
636
- * material — read off the ONE coverage map, never worked out again here.
637
- *
638
- * This replaced `planRunInterval`, which was a second authority over the same
639
- * question and answered it by different rules: it walked the whole track for
640
- * the first free number, MOVED the start there itself, and counted every live
641
- * run as claiming up to `head + look-ahead`. Measured in the field
642
- * 2026-09-05: the plan commanded a start at #46, this moved it to #78 — the
643
- * "run moved forward from #46 to #78" line — and the plan, seeing a run
644
- * outside the window it had asked for, killed it as unwanted and commanded
645
- * the same start again, 350-700ms per cycle, dozens of times, the viewer's
646
- * picture stopped for 125 seconds.
647
- *
648
- * Where a run STARTS is the plan's decision and arrives here as an argument.
649
- * All this answers is where it must stop, and the answer is a fact of the
650
- * map: the free stretch from that number on. `-1` means the end of the track,
651
- * which is what "no end" is written as everywhere here.
652
- *
653
- * @param {object} session
654
- * @param {number} startIndex
655
- * @param {object | null} exceptRun - The run being replaced, whose own claim
656
- * is not somebody else's material.
657
- * @returns {number} The last number to work through, or `-1` for the end.
658
- */
659
- #runEndFrom(session, startIndex, exceptRun = null) {
660
- return this.#host.encodeOrchestrator.freeStretchEnd({
661
- address: session.outputKey ?? "",
662
- from: startIndex,
663
- exceptRun,
664
- segmentCount: Number(session.timeline?.segmentCount) || 0
665
- });
666
- }
667
-
668
- // Returns the encoder it built, or nothing when there was nothing to build.
669
- // It waits for nothing: the one thing it used to wait for was the death of
670
- // the run it was replacing, and that killing is gone.
671
- #startEncodeRun(session, startIndex, because, ordered = null) {
672
- if (!this.isLive(session)) {
673
- return null;
674
- }
675
- // A new run starts the quality budget's reckoning afresh: a pair of
676
- // readings spanning the restart would count the gap between two runs as
677
- // slow encoding. Told, not written: those readings are the budget's.
678
- this.#host.noteRunStarting(session);
679
- // Where a restart's seconds go. A seek costs 5-8 s in the field and the
680
- // recorded reason — waiting for the previous ffmpeg to exit, measured at
681
- // 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
682
- // in the proxy on a guess, make each stage state its own cost.
683
- const restartEnteredAt = Date.now();
684
- // Reads where the old run began BEFORE the new one takes its place, and
685
- // does not await: everything below is the restart path, which is measured
686
- // in milliseconds and has been worked on twice to keep it that way.
687
- this.#accountBackwardRestart(session, startIndex);
688
- // STARTING AN ENCODER STOPS NOTHING. It used to stop any live run whose own
689
- // start was not below this one's — a rule left over from when a session
690
- // held exactly one run and "the previous one" meant "the only one". Once a
691
- // an output could hold several, that rule began killing runs the plan had
692
- // decided to keep: 294 stops for this reason in eight minutes of field
693
- // 2026-09-05, against four starts asked for by a viewer. Who is stopped is
694
- // decided in one place, and it is not this one.
695
- //
696
- // HOW FAR IT MAY WORK is decided by the same one place and arrives here as
697
- // an argument. Reading it off the coverage map a second time was the last
698
- // remaining second answer to that question: the plan computed `to`, passed
699
- // it, and the parameter list did not name it.
700
- const runEnd = Number.isInteger(ordered?.to)
701
- ? ordered.to
702
- : this.#runEndFrom(session, startIndex, null);
703
- // The restart backs off a segment or two from what was asked for, so the
704
- // request that prompted it is recorded under a HIGHER index than the run
705
- // starts at. Looking it up by the start index alone found nothing and the
706
- // line never printed once.
707
- let wantedAt = null;
708
- const inputState = this.#inputStateFor(session);
709
- for (const [index, at] of inputState.firstWantedAt) {
710
- if (index >= startIndex && (wantedAt === null || at < wantedAt)) {
711
- wantedAt = at;
712
- }
713
- }
714
- if (typeof wantedAt === "number") {
715
- this.#host.logger.info(
716
- `transcode ${session.id} restart for #${startIndex} decided ` +
717
- `${restartEnteredAt - wantedAt}ms after it was first asked for`
718
- );
719
- }
720
- // Cleared with the run that could not answer them. These are "how long has
721
- // this segment gone unanswered", and the question only means anything about
722
- // the run in force: a timestamp kept from an abandoned scan probe minutes
723
- // ago says a fresh request has already waited long enough, which is how the
724
- // behind-head repair came to fire on the very first poll instead of waiting
725
- // for the seek that should move the encoder. It also stops the map growing
726
- // for the life of a session.
727
- inputState.firstWantedAt.clear();
728
- // WHERE THIS NUMBER REALLY BEGINS, when a produced piece has said so and
729
- // the table the player holds still says otherwise.
730
- //
731
- // Only a soundtrack, and only because a soundtrack has no keyframes: it
732
- // begins exactly where it is asked to, to within one audio frame, while the
733
- // picture can begin nowhere but a real keyframe and so moves forward to the
734
- // next one — up to three seconds, measured 2026-08-17. Left at the published
735
- // time, the sound of one film starts up to that far from its picture after
736
- // every restart, each correctly labelled with where it really is, and the
737
- // viewer gets sound with no new picture for the difference.
738
- //
739
- // DERIVED HERE rather than handed in. It used to arrive as an argument from
740
- // the one caller that knew it — the correction that had just measured it —
741
- // which meant the instant was only ever right for a run that caller
742
- // started. Any run the plan placed at a corrected number got the published
743
- // time and landed apart again. It is a fact of the FILE's cutting, held in
744
- // the live table every session of the file shares, so it is read from
745
- // there.
746
- const positionSecondsOverride = session.spec.carries === "audio-only"
747
- ? trueStartOf(session.timeline, startIndex)
748
- : undefined;
749
-
750
- // What is still read off the session for a run. The list is the measure of
751
- // how far a session still is from being the three things a run is built
752
- // from — the material, what is produced of it, and the stretch — and it
753
- // shrinks in place as those are taken off. There is deliberately no method
754
- // wrapping it: a named adapter with one caller is a thing to remember to
755
- // delete, and this is a thing that disappears by being emptied.
756
- const inputs = this.#host.inputOf(session);
757
- const { args, safeIndex, startSeconds, cutTimes } = buildRunCommand({
758
- keyframes: session.keyframes,
759
- inputFile: inputs.inputFile,
760
- audioFile: inputs.audioFile,
761
- inputUrl: inputs.inputUrl,
762
- audioInputUrl: inputs.audioInputUrl,
763
- timeline: session.timeline,
764
- output: session.output,
765
- segmentFormat: session.segmentFormat,
766
- transcodeVideo: session.spec.transcodesVideo,
767
- transcodeAudio: session.spec.transcodesAudio,
768
- audioOnly: session.spec.carries === "audio-only",
769
- audioSeparate: this.#host.servesAudioSeparately(session),
770
- audioSourceTrackIndex: session.spec.audioSourceTrackIndex,
771
- rateControl: session.spec.video?.encode?.rateControl ?? null,
772
- startIndex,
773
- endIndex: runEnd,
774
- positionSecondsOverride,
775
- videoEncoder: this.#host.videoEncoder,
776
- segmentDurationSec: this.#host.segmentDurationSec
777
- });
778
-
779
- // The exact command, every run. An encode failure is otherwise reported
780
- // with ffmpeg's message and nothing about what it was asked to do, and the
781
- // two are not always deducible from each other: 2026-08-04 a run died with
782
- // "Cannot write moov atom before AC3 packets" although both muxing paths
783
- // were verified to handle a copied AC-3 track on this very host, so the
784
- // arguments that run actually received are the missing evidence. One line
785
- // per run, and a run happens at most every few seconds.
786
- // Numbered, because a burst of seeks starts several runs in one second and
787
- // every line about them carries the SESSION id, which is the same for all.
788
- // Without a run number the command that failed cannot be told from the two
789
- // that succeeded around it — which is exactly the state the unexplained
790
- // `Cannot write moov atom before AC3 packets` was found in.
791
-
792
- // One directory for the output, and every run writes straight into it.
793
- //
794
- // Runs used to be kept apart by a directory each, because two of them
795
- // writing one segment name at the same time produce a file belonging to
796
- // neither — which was also the only reason a restart ever had to wait for
797
- // its predecessor to die. Intervals remove that by construction: a run is
798
- // given a stretch nobody else holds and stops at the end of it, so no two
799
- // runs ever want the same number. What is left of the old reason — a run
800
- // killed mid-piece leaving a partial file — is not a correctness problem
801
- // either, because the store serves a segment only once its closure is
802
- // proven, and it is cleared up when the run ends.
803
- const run = new EncodeRun({
804
- address: session.outputKey ?? session.id,
805
- encoder: this.#host.videoEncoder,
806
- from: safeIndex,
807
- to: runEnd,
808
- buildArgs: () => args,
809
- argsDescribed: describeFfmpegArgs(args),
810
- // Whether this run cuts at times we gave it. Decides how a segment is
811
- // judged finished — see getFileStream.
812
- usesExplicitCuts: Boolean(cutTimes && cutTimes.length > 0),
813
- startSeconds,
814
- totalSeconds: Number(session.file.durationSeconds) || null,
815
- spawn: (spawnArgs) =>
816
- spawn(this.#host.ffmpegBin, spawnArgs, {
817
- cwd: this.#host.segmentFiles.directoryFor(session.outputKey ?? ""),
818
- // A fourth channel: the encoder names every piece it has CLOSED on it,
819
- // which is the only proof a piece is whole.
820
- stdio: ["ignore", "pipe", "pipe", "pipe"]
821
- }),
822
- logger: this.#host.logger,
823
- // The film's last segment number, which is what tells "it reached the
824
- // end" from "its input dried up": ffmpeg exits zero for both and over a
825
- // torrent cannot tell them apart.
826
- lastSegmentIndex: () =>
827
- session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
828
- inputUnavailable: (message) => isInputUnavailable(message),
829
- onProgress: (report) => this.#noteRunProgress(session, run, report),
830
- indexOfName: (name) => session.segmentFormat.segmentIndexFromName(
831
- session.segmentFormat.servedNameOf?.(name) ?? name),
832
- // Why this encoder exists, recorded with its argument list. It used to be
833
- // handed to a separate `start` call; there is no separate call now.
834
- because,
835
- onClosed: (name) => {
836
- const index = session.segmentFormat.segmentIndexFromName(
837
- session.segmentFormat.servedNameOf?.(name) ?? name);
838
- if (Number.isInteger(index) && index < safeIndex) return null;
839
- return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat);
840
- },
841
- onEnded: (ended) => this.noteRunEnded(session, run, ended)
842
- });
843
- // THE ONE FAULT THAT IS OTHERWISE SILENT, asked before this run produces a
844
- // frame. It lost its caller in a refactor on 2026-09-04 and had none until
845
- // 2026-09-15 — not by a decision, which is why it is back rather than gone.
846
- this.#warnIfRunLeavesTheInitBehind(session);
847
-
848
- this.#host.logger.info(
849
- `transcode ${session.id} encode-run #${safeIndex}..#${runEnd} from segment #${safeIndex} ` +
850
- `(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
851
- `(${formatSeconds(startSeconds)}) "${session.file.name}"`
852
- );
853
- // The four numbers a run is positioned by, said once, because their
854
- // disagreement is invisible everywhere else. The two tables are printed
855
- // side by side: while they differ, every cut of this run is off by the
856
- // difference, and nothing downstream can tell that from a bad index.
857
- const liveStart = this.#host.outputTimes.segmentStartTime(session, safeIndex);
858
- this.#host.logger.info(
859
- `transcode ${session.id} run #${safeIndex}..#${runEnd} positioned at ${startSeconds.toFixed(3)}s ` +
860
- `for boundary #${safeIndex} (published ${startSeconds.toFixed(3)}s, ` +
861
- `live ${liveStart.toFixed(3)}s, apart ${(liveStart - startSeconds).toFixed(3)}s), ` +
862
- `numbering from #${safeIndex}`
863
- );
864
- return run;
865
- }
866
-
867
- /**
868
- * Log a report from a run that still belongs to this output. The run owns the
869
- * clock and has already placed its relative ffmpeg time on the source
870
- * timeline.
871
- *
872
- * @param {HlsSession} session
873
- * @param {import("./EncodeRun.js").EncodeRun} run
874
- * @param {object} progress
875
- * @returns {void}
876
- */
877
- #noteRunProgress(session, run, progress) {
878
- if (!this.isLive(session) || !this.runsOf(session).includes(run)) {
879
- return;
880
- }
881
- if (run.progress.shouldLog(PROGRESS_LOG_INTERVAL_MS)) {
882
- this.#host.logger.info(
883
- `transcode ${session.id} "${session.file.name}" ${progress.percent.toFixed(1)}% ` +
884
- `(${formatSeconds(progress.processedSeconds)} / ${formatSeconds(progress.totalSeconds)})` +
885
- ` speed=${progress.speed || "n/a"}`
886
- );
887
- }
888
- }
889
-
890
- /**
891
- * What to do about a run that has ended.
892
- *
893
- * The run says how it ended and why; this decides what follows, which is a
894
- * question about the SESSION and the machine rather than about the process:
895
- * whether to condemn a hardware encoder, whether to wait for the input to
896
- * come back, whether a position has failed often enough to stop retrying it.
897
- *
898
- * An ending from a run the session has already replaced is not ignored — it
899
- * is simply not about this session's current run, so nothing that describes
900
- * the current run is written from it. That is what the identity check used to
901
- * be for, and it is one condition now instead of one at the head of every
902
- * handler.
903
- *
904
- * @param {HlsSession} session
905
- * @param {import("./EncodeRun.js").EncodeRun} run
906
- * @param {import("./EncodeRun.js").RunEnded} ended
907
- * @returns {void}
908
- */
909
- noteRunEnded(session, run, ended) {
910
- // First, and for every run whatever else follows: the stretch it held goes
911
- // back to the map. A run whose claim is never released tells the plan that
912
- // numbers nobody is making are being made, and nothing is ever started
913
- // there again.
914
- // WAS this run still the session's when it ended? Asked before it is
915
- // removed, because after the removal the question always answers "no" —
916
- // and it was asked after, so every line below this point was unreachable.
917
- //
918
- // Measured 2026-09-05 over both of the field host's log files: zero
919
- // occurrences of this handler's own `encode-run #… failed:` line and zero
920
- // of `fast failure at segment`, across every session this proxy has ever
921
- // run. So the fallback from a failed hardware encoder to software, the
922
- // retry when the torrent data goes away, the limit on retrying a position
923
- // that keeps failing, and the error line naming the ffmpeg command have all
924
- // been dead code — which is also why nothing ever stopped the restart loop
925
- // recorded in the field the same day.
926
- const wasCurrent = this.runsOf(session).includes(run);
927
- this.#host.encodeOrchestrator.noteEnded(ended);
928
- // What this admitted encode was seen to do, kept for the next time this
929
- // host prices the same mode (roadmap item 97, step 14). Whatever the
930
- // ending: a run that failed still ran at the speed it ran at, and the
931
- // segments it closed still carried what they carried.
932
- this.#host.observeEncodeEnded?.(session);
933
- // A stretch went back to the map, so what should be running has changed.
934
- // Said here rather than waited for: this is the moment it became true.
935
- this.planEncodersSoon();
936
- // ANYBODY ALREADY WAITING IS TOLD, and it is asked HERE because every
937
- // ending passes this line while the handlers below return one by one. It
938
- // used to be the last statement of this method, which the `SHORT` branch —
939
- // ffmpeg exiting 0 having produced less than the playlist promises —
940
- // returns before reaching. That ending is terminal (`ENDED_FAILED`), so a
941
- // request arriving after it is answered with the failure at once while a
942
- // wait that BEGAN before it sat out its whole deadline, or, where the page
943
- // states none, until the viewer disconnected.
944
- //
945
- // THE CONDITION IS THE OUTPUT'S ACTUAL STATE, not this run's ending.
946
- // `stateOf` answers from the LIVE runs and falls back to the last ending
947
- // only when none remain, so one run of several ending in failure wakes
948
- // nobody — something is still making it — and the last one does. Asked
949
- // after `noteEnded` has recorded the ending, so the woken request reads the
950
- // final state; and it re-reads the disk first, because a piece already made
951
- // is served whatever became of the encoder.
952
- if (this.hasFailed(session)) {
953
- this.#host.invalidateWaits(session);
954
- // A move to this output being prepared for a viewer waits for a segment
955
- // that nothing will now make, and no publication will ever end that wait.
956
- // This is the event that does (roadmap item 97, step 12).
957
- this.#host.productionFailed(session);
958
- }
959
- if (!this.isLive(session)) {
960
- this.forgetEncodingOfGone(session);
961
- return;
962
- }
963
- if (!wasCurrent) {
964
- // A run the session had already replaced. It has logged its own ending;
965
- // nothing about the session follows from it.
966
- return;
967
- }
968
- const lastError = this.lastErrorOf(session) || ended.because;
969
- if (ended.ending === ENCODE_EXIT.STOPPED || ended.ending === ENCODE_EXIT.GONE) {
970
- return;
971
- }
972
- if (ended.ending === ENCODE_EXIT.COMPLETE) {
973
- this.#host.logger.info(`transcode ${session.id} encode-run complete "${session.file.name}"`);
974
- return;
975
- }
976
- if (ended.ending === ENCODE_EXIT.SHORT) {
977
- // ffmpeg exits 0 both when it reaches the end of the file and when its
978
- // input simply stops producing bytes — over HTTP the two look identical
979
- // to it. Field 2026-08-05: the torrent's download died, the read ended,
980
- // and a run that had made 188 segments of 624 reported itself complete;
981
- // the player then consumed what was on disk and froze on the first
982
- // segment nobody was making. So the claim is checked against the playlist
983
- // we published, and a run that stopped short is a FAILURE that can be
984
- // restarted, not a finished file.
985
- this.#host.logger.error(
986
- `transcode ${session.id} encode-run #${ended.from}..#${ended.to} ended early: ` +
987
- `${ended.because} "${session.file.name}"`
988
- );
989
- return;
990
- }
991
- // Runtime safety net: if a hardware encode fails, downgrade this proxy to
992
- // software encoding for all sessions and restart this one, so playback is
993
- // never permanently broken by a hardware/driver issue.
994
- //
995
- // Asked only of a genuine encoder failure. It used to be asked of every
996
- // non-zero exit, so a run whose TORRENT DATA went away — which says nothing
997
- // whatever about the encoder — condemned a working NVENC or QuickSync to
998
- // software for the life of the process, and started an extra run at the old
999
- // index while it was at it.
1000
- if (ended.ending === ENCODE_EXIT.FAILED && session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software") {
1001
- const failed = this.#host.encoders.useSoftware();
1002
- const failedEncoder = failed?.name ?? this.#host.videoEncoder.name;
1003
- this.#host.logger.warn(
1004
- `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
1005
- `(${lastError}); falling back to software libx264, and every output named by ${failedEncoder} is closed`
1006
- );
1007
- // An output IS its format, and the encoder is part of it: pieces from
1008
- // another encoder cannot be decoded with the header this output's viewers
1009
- // already hold. So the outputs made by the failed encoder end here, and
1010
- // the next request for that picture names a format built on the new one.
1011
- //
1012
- // THE ONE DISPOSAL THAT DOES NOT ASK WHETHER ASSIGNMENTS STILL HOLD IT, and
1013
- // deliberately. Every other one asks, because a repeat of a request must be
1014
- // answered by what answered it first. Here what answered it first has no
1015
- // encoder that can go on making it: holding the output keeps only a name
1016
- // whose next segment nobody will produce. A response already being sent is
1017
- // not cut short by this — it reads from a file already opened.
1018
- for (const other of [...this.#host.outputs.values()]) {
1019
- if (other.spec.video?.encode?.encoder === failedEncoder) {
1020
- void this.#host.disposeSession(other.id);
1021
- }
1022
- }
1023
- this.planEncodersSoon();
1024
- return;
1025
- }
1026
- // Losing the INPUT is not the session failing — it is the data not being
1027
- // there YET. The torrent can be re-added and re-downloaded, so the honest
1028
- // answer to the viewer is "still working", not an error screen. Field
1029
- // 2026-08-06: a torrent evicted mid-seek took the film with it, the run
1030
- // died on `File 0 not found`, the session went terminal and answered 500 to
1031
- // every request from then on — although the swarm was there and the data
1032
- // would have come back in seconds. The circuit breaker below stays for what
1033
- // it was built for, a target that genuinely cannot be encoded; it must not
1034
- // condemn a session whose data merely went away.
1035
- if (ended.ending === ENCODE_EXIT.INPUT_LOST) {
1036
- const inputState = this.#inputStateFor(session);
1037
- inputState.inputRetryCount += 1;
1038
- this.#host.logger.warn(
1039
- `transcode ${session.id} encode-run #${ended.from}..#${ended.to} lost its input ` +
1040
- `(${lastError}) (attempt ${inputState.inputRetryCount})`
1041
- );
1042
- // HOW LONG TO WAIT IS THE PLAN'S, and this says only what happened. The
1043
- // delay used to be timed here, against the dead run, which the plan never
1044
- // consults — so it placed a fresh run at the same spot as fast as ffmpeg
1045
- // could fail there: 2432 starts in 23 minutes in the field 2026-09-12,
1046
- // against a delay that had reached its 15 s ceiling long before. The
1047
- // orchestrator holds it now, beside the decision it governs.
1048
- if (run.state === ENCODE_RUN_STATE.RETRY_WAIT) {
1049
- run.retryDue();
1050
- }
1051
- return;
1052
- }
1053
- // A run that exits THIS fast never did real work: it failed at the start
1054
- // itself — opening the input, spawning the process — rather than
1055
- // mid-stream. Consecutive fast failures at the SAME position are counted so
1056
- // that whoever commands a start can stop commanding one that keeps failing.
1057
- //
1058
- // COUNTED AT EVERY POSITION, #0 included. It used to be counted only past
1059
- // #0, because it was written for seek restarts and a seek is never to the
1060
- // beginning. That left the one position the plan commands FIRST with no
1061
- // count at all, and the plan re-commanding a start that cannot succeed is
1062
- // an unbounded loop: measured 2026-09-05, ffmpeg failing to spawn produced
1063
- // fifty passes of the plan before a probe stopped it, as fast as the
1064
- // failures arrived.
1065
- const failure = this.#host.encodeOrchestrator.noteStartFailure(
1066
- session.outputKey,
1067
- ended.from,
1068
- ended.livedMs
1069
- );
1070
- if (failure.count > 0) {
1071
- this.#host.logger.warn(
1072
- `transcode ${session.id} fast failure at segment #${ended.from} ` +
1073
- `(${ended.livedMs}ms) — ${failure.count}/${failure.limit} consecutive`
1074
- );
1075
- if (failure.blocked) {
1076
- this.#host.logger.error(
1077
- `transcode ${session.id} will not be started at #${ended.from} again: ` +
1078
- `${failure.count} starts there failed within ${failure.fastMs}ms each ` +
1079
- `(${lastError}) "${session.file.name}"`
1080
- );
1081
- }
1082
- }
1083
- this.#host.logger.error(
1084
- `transcode ${session.id} encode-run #${ended.from}..#${ended.to} failed: ${lastError}` +
1085
- ` — ${this.#describeTrackSelection(session)}` +
1086
- `\n ffmpeg ${run.argsDescribed || "(command not recorded)"}`
1087
- );
1088
- }
1089
-
1090
- /**
1091
- * What this run asked the source for, against what the source said it has.
1092
- *
1093
- * Written for one failure in particular: every `-map` this proxy builds ends
1094
- * in `?`, so ffmpeg drops a mapping for an absent stream without complaint,
1095
- * and a run whose mappings ALL drop produces a file with nothing in it —
1096
- * reported as `Output file does not contain any stream`, exit 255. Read from
1097
- * the exit code alone that is indistinguishable from any other refusal. Read
1098
- * beside the tracks the file actually holds it is unmistakable, and it names
1099
- * which side is wrong: an audio index past the end of the list is ours, no
1100
- * streams at all is the source's.
1101
- *
1102
- * @param {HlsSession} session
1103
- * @returns {string}
1104
- */
1105
- #describeTrackSelection(session) {
1106
- const wanted = [];
1107
- // Named exactly as the command line names them, second input included: a
1108
- // refusal whose message describes a different mapping than the one that was
1109
- // refused is the reading that cost a wrong diagnosis before.
1110
- const inputs = this.#host.inputOf(session);
1111
- const audioInput = inputs.audioInputUrl.length > 0 ? 1 : 0;
1112
- const audioTrack = session.spec.audioSourceTrackIndex;
1113
- if (session.spec.carries === "audio-only") {
1114
- wanted.push(`audio 0:a:${audioTrack}`);
1115
- } else if (this.#host.servesAudioSeparately(session)) {
1116
- wanted.push("video 0:v:0");
1117
- } else {
1118
- wanted.push("video 0:v:0", `audio ${audioInput}:a:${audioTrack}`);
1119
- }
1120
- const counts = session.file.streamCounts;
1121
- const held = counts
1122
- ? `the source holds ${counts.video} video, ${counts.audio} audio, ` +
1123
- `${counts.subtitle} subtitle` +
1124
- (counts.other > 0 ? `, ${counts.other} other` : "")
1125
- : "what the source holds was not recorded";
1126
- return `this run asked for ${wanted.join(" + ")}, and ${held}`;
1127
- }
1128
-
1129
- /**
1130
- * Stop this session's encoder without replacing it.
1131
- *
1132
- * A variant nobody is watching must not go on encoding: the host has one
1133
- * encoder's worth of capacity, and the whole point of switching quality
1134
- * seamlessly is that the new rung gets it. The session itself stays — its
1135
- * produced segments remain servable, and switching back restarts it from
1136
- * where the viewer then is.
1137
- *
1138
- * @param {HlsSession} session
1139
- * @param {string} reason - Named in the log; a stopped encoder is otherwise
1140
- * indistinguishable from one that died.
1141
- * @returns {void}
1142
- */
1143
- stopEncodeRun(session, reason) {
1144
- const running = this.liveRunsOf(session);
1145
- if (running.length === 0) {
1146
- return;
1147
- }
1148
- // Every run this session has going. The callers all mean the session's
1149
- // encoding as a whole: a rung nobody is watching any more, a session being
1150
- // disposed. A run that had already finished or failed is not among them,
1151
- // which is what keeps a stop from erasing how it actually ended.
1152
- for (const run of running) {
1153
- // The run resumes itself if it was suspended — a stopped process does not
1154
- // act on SIGTERM until it is continued — records the cause, and answers
1155
- // its own exit. Nothing here has to null a field so that the exit is read
1156
- // correctly, because there is no shared field left to misread.
1157
- run.stop(reason);
1158
- // Nothing is cleared up from here. What this run left open is under its
1159
- // own working name, and the encoding layer removes it when the run's
1160
- // ending reaches it — one place, and it needs neither the stretch nor the
1161
- // init bytes this method used to fetch to judge a file by its contents.
1162
- }
1163
- this.#host.logger.info(`transcode ${session.id} ${running.length} encoder(s) stopped: ${reason}`);
1164
- }
1165
-
1166
- /**
1167
- * What moving the encoder BACKWARDS costs, said out loud when it happens.
1168
- *
1169
- * Nothing already written is lost — every run keeps its own directory and
1170
- * {@link SegmentStore#pathOfName} serves the union of all of them
1171
- * — so the price of a restart is not the files. It is two other things, and
1172
- * neither was ever counted:
1173
- *
1174
- * - **work done twice.** The new run begins at the target and encodes
1175
- * forward through segments the old run had already finished. ffmpeg cannot
1176
- * know they exist, so it makes them again.
1177
- * - **the viewer in front.** While the run walks back up to where it already
1178
- * was, nothing new is being made ahead of them, and their cushion drains.
1179
- *
1180
- * Both are what decides whether a session should be allowed a SECOND
1181
- * concurrent run instead — roadmap item 64. That question cannot be answered
1182
- * from taste, and this is the reading it needs: how often it happens at all,
1183
- * how far back, and how much of the walk is a repeat.
1184
- *
1185
- * Nothing here is awaited by the caller. Everything below the call site is
1186
- * the restart path, which is measured in milliseconds and has been worked on
1187
- * twice to keep it that way; a reading that delays the thing it is reading
1188
- * about is not a reading. The figures that MUST be taken before the new run
1189
- * exists are taken synchronously, and only the file counting is left to run
1190
- * on its own — against the directories that existed at this instant, so what
1191
- * the new run is about to write cannot be counted as already there.
1192
- *
1193
- * @param {HlsSession} session
1194
- * @param {number} startIndex - Where the new run will begin.
1195
- * @returns {void}
1196
- */
1197
- #accountBackwardRestart(session, startIndex) {
1198
- const previousStart = earliestRunStart(this.runsOf(session));
1199
- if (!Number.isInteger(previousStart) || !Number.isInteger(startIndex) || startIndex >= previousStart) {
1200
- // A first run, or one moving forward. Neither costs anything here: a
1201
- // forward restart skips material it never made.
1202
- return;
1203
- }
1204
- const processed = Number(this.progressOf(session)?.processedSeconds);
1205
- const head = Number.isFinite(processed)
1206
- ? Math.max(previousStart, this.#host.outputTimes.segmentIndexForTime(session, processed))
1207
- : previousStart;
1208
- // Bounded: a session an hour in has thousands of segments, and the count is
1209
- // for a comparison, not an inventory.
1210
- const last = Math.min(head, startIndex + BACKWARD_RESTART_SCAN_SEGMENTS);
1211
- const accounting = this.#inputStateFor(session).backwardRestarts;
1212
- accounting.count += 1;
1213
- accounting.segmentsBack += previousStart - startIndex;
1214
- accounting.worstBack = Math.max(accounting.worstBack, previousStart - startIndex);
1215
-
1216
- void (async () => {
1217
- let alreadyOnDisk = 0;
1218
- for (const index of this.#host.producedNumbers(session)) {
1219
- if (index >= startIndex && index <= last) {
1220
- alreadyOnDisk += 1;
1221
- }
1222
- }
1223
- accounting.remade += alreadyOnDisk;
1224
- this.#host.logger.info(
1225
- `transcode ${session.id} moving the encoder BACK from #${previousStart} to #${startIndex} ` +
1226
- `(head was #${head}): ${alreadyOnDisk} of the ${last - startIndex + 1} segment(s) it will walk through ` +
1227
- `are already on disk and will be made again, and nothing is produced ahead of #${head} until it gets ` +
1228
- `back there — ${accounting.count} backward restart(s) this session, worst ${accounting.worstBack} ` +
1229
- `segment(s) back, ${accounting.remade} segment(s) remade in total (roadmap 64)`
1230
- );
1231
- })().catch(() => {
1232
- // silent-ok: a reading that fails is not worth ending a restart over.
1233
- });
1234
- }
1235
- }
1
+ /**
2
+ * The encoders of this proxy, from the plan's decision to the end of the process.
3
+ *
4
+ * The plan (`EncodePlan`, through `EncodeOrchestrator`) decides where a run goes;
5
+ * this builds it with the arguments its output needs, follows what it produces,
6
+ * says how far it got, and accounts for how it ended. Nothing here decides where
7
+ * an encoder should be.
8
+ */
9
+
10
+ import path from "node:path";
11
+ import { spawn } from "node:child_process";
12
+ import { contentionPenalty } from "./contention.js";
13
+ import { ENCODE_RUN_STATE, liveRunsOf, runStateOf, wireState } from "./encode-run-state.js";
14
+ import { ENCODE_EXIT } from "./encode-exit.js";
15
+ import { EncodeRun } from "./EncodeRun.js";
16
+ import { computeOutputDimensions } from "./args.js";
17
+ import { buildRunCommand, trueStartOf } from "./run-command.js";
18
+
19
+ /**
20
+ * @typedef {Object} SegmentFiles
21
+ * @property {(address: string) => string} pathFor
22
+ * @property {(address: string) => Buffer | null} initOf
23
+ * @property {(address: string) => string} directoryFor
24
+ * @property {(address: string, makingName: string, format: object) => string | null} publish
25
+ */
26
+ // How far the accounting of a backward restart looks for work about to be done
27
+ // twice. It runs on the restart path and a session an hour in has thousands of
28
+ // segments; the figure is for a comparison, not an inventory.
29
+ const BACKWARD_RESTART_SCAN_SEGMENTS = 300;
30
+ const PROGRESS_LOG_INTERVAL_MS = 5_000;
31
+ /**
32
+ * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
33
+ *
34
+ * @param {number} seconds
35
+ * @returns {string}
36
+ */
37
+ export function formatSeconds(seconds) {
38
+ if (!Number.isFinite(seconds) || seconds < 0) {
39
+ return "n/a";
40
+ }
41
+ const total = Math.floor(seconds);
42
+ const hours = Math.floor(total / 3600);
43
+ const minutes = Math.floor((total % 3600) / 60);
44
+ const rest = total % 60;
45
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
46
+ }
47
+ /**
48
+ * Where this session's encoding begins: the earliest number any live run of it
49
+ * was given.
50
+ *
51
+ * @param {object[] | Set<object>} runs
52
+ * @returns {number | null}
53
+ */
54
+ export function earliestRunStart(runs) {
55
+ const live = liveRunsOf(runs);
56
+ return live.length > 0 ? live[0].from : null;
57
+ }
58
+ /**
59
+ * The live run of this session that was given this number, if any.
60
+ *
61
+ * A run with an explicit stretch owns the whole of it. One WITHOUT an end owns
62
+ * only as far as it has actually got: claiming the rest of the film would make
63
+ * it the owner of every number in front of it, including ones another run was
64
+ * expressly given.
65
+ *
66
+ * @param {object[] | Set<object>} runs
67
+ * @param {number} index
68
+ * @returns {object | null}
69
+ */
70
+ function ownRunMaking(runs, index) {
71
+ let answer = null;
72
+ for (const run of liveRunsOf(runs)) {
73
+ if (run.from > index) {
74
+ break; // Ordered by start, so nothing further can hold this number.
75
+ }
76
+ if (Number.isInteger(run.to) && run.to >= run.from && index > run.to) {
77
+ continue; // Its stretch ends before this number.
78
+ }
79
+ answer = run;
80
+ }
81
+ return answer;
82
+ }
83
+
84
+ /**
85
+ * Whether an encoder run died because its INPUT went away, rather than because
86
+ * of anything about the encode itself.
87
+ *
88
+ * These are the messages the read path and ffmpeg's HTTP client produce when
89
+ * the torrent is gone, being re-added, or has no data for the range yet — all
90
+ * of them temporary by nature: the source can be added again and the pieces
91
+ * fetched again.
92
+ *
93
+ * @param {string} message
94
+ * @returns {boolean}
95
+ */
96
+ export function isInputUnavailable(message) {
97
+ const text = typeof message === "string" ? message : "";
98
+ return (
99
+ /Error reading HTTP response/i.test(text) ||
100
+ /not found in (?:magnet|torrent):/i.test(text) ||
101
+ /Unknown source/i.test(text) ||
102
+ /is gone and cannot be re-added/i.test(text) ||
103
+ /Read error at pos/i.test(text) ||
104
+ /Server returned 5\d\d/i.test(text) ||
105
+ /Input\/output error/i.test(text) ||
106
+ /Connection reset by peer/i.test(text) ||
107
+ /End of file/i.test(text)
108
+ );
109
+ }
110
+
111
+ /**
112
+ * The ffmpeg command as one readable line.
113
+ *
114
+ * Everything is shown as passed except the list of cut times, which is one
115
+ * value per segment — 830 of them on a two-hour film, about 7 KB of log for a
116
+ * single run, repeated on every restart. The count and the two ends say
117
+ * everything the list is ever consulted for: whether cutting was explicit at
118
+ * all, how far it reaches, and where it starts.
119
+ *
120
+ * @param {string[]} args
121
+ * @returns {string}
122
+ */
123
+ export function describeFfmpegArgs(args) {
124
+ const parts = [];
125
+ for (let index = 0; index < args.length; index += 1) {
126
+ const value = args[index];
127
+ // Both take the same list, and a keyframe-grid variant passes it twice.
128
+ // `-force_key_frames` also takes an expression, which is short and is left
129
+ // alone — only a list is folded.
130
+ if (
131
+ (value === "-segment_times" || value === "-force_key_frames") &&
132
+ typeof args[index + 1] === "string" &&
133
+ args[index + 1].includes(",")
134
+ ) {
135
+ const times = args[index + 1].split(",");
136
+ parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
137
+ index += 1;
138
+ continue;
139
+ }
140
+ parts.push(value);
141
+ }
142
+ return parts.join(" ");
143
+ }
144
+
145
+ export class EncodeRuns {
146
+ #inputState = new WeakMap();
147
+ /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
148
+ #host;
149
+
150
+ /**
151
+ * Whether a re-decision of what encoders should exist is already queued for
152
+ * the end of this turn. See `planEncodersSoon`.
153
+ * @type {boolean}
154
+ */
155
+ #planScheduled = false;
156
+
157
+ /**
158
+ * @param {object} host - `logger`, `viewerSecondsOn`, `noteRunStarting`, `inputOf`, `producedNumbers`, `servesAudioSeparately`, `disposeSession`, `contentionPenalties`, `encodeCost`, `encodeOrchestrator`, `encoders`, `ffmpegBin`, `outputTimes`, `outputs`, `priority`, `segmentDurationSec`, `segmentFiles`, `videoEncoder`
159
+ * @param {SegmentFiles} host.segmentFiles - The storage operations used to
160
+ * write and inspect output files.
161
+ */
162
+ constructor(host) {
163
+ this.#host = host;
164
+ }
165
+
166
+ #inputStateFor(output) {
167
+ let state = this.#inputState.get(output);
168
+ if (!state) {
169
+ state = {
170
+ readWindowBytes: 0,
171
+ inputRetryCount: 0,
172
+ backwardRestarts: { count: 0, segmentsBack: 0, worstBack: 0, remade: 0 },
173
+ firstWantedAt: new Map(),
174
+ /** Bytes the swarm has delivered to this output's own input reads. */
175
+ inputBytes: 0,
176
+ /** The mismatch between the held init and the run's size last said, so it is said once. */
177
+ initSizeSaid: ""
178
+ };
179
+ this.#inputState.set(output, state);
180
+ }
181
+ return state;
182
+ }
183
+
184
+ setReadWindow(output, bytes) {
185
+ this.#inputStateFor(output).readWindowBytes = Number.isFinite(bytes) ? bytes : 0;
186
+ }
187
+
188
+ readWindowFor(output) {
189
+ return this.#inputStateFor(output).readWindowBytes;
190
+ }
191
+
192
+ noteWanted(output, index, at = Date.now()) {
193
+ const wanted = this.#inputStateFor(output).firstWantedAt;
194
+ if (!wanted.has(index)) wanted.set(index, at);
195
+ }
196
+
197
+ resetInputRetry(output) {
198
+ this.#inputStateFor(output).inputRetryCount = 0;
199
+ }
200
+
201
+ /**
202
+ * Count bytes the swarm has delivered to one output's own input read.
203
+ *
204
+ * Called by the `/stream` route for every fragment it writes to an encoder.
205
+ * Cheap on purpose — one addition, no clock, no log — because it runs per
206
+ * fragment on the path that feeds ffmpeg.
207
+ *
208
+ * @param {string} outputId
209
+ * @param {number} bytes
210
+ * @returns {void}
211
+ */
212
+ noteInputBytes(outputId, bytes) {
213
+ if (!outputId || !(bytes > 0)) {
214
+ return;
215
+ }
216
+ const output = this.#host.outputs.get(outputId);
217
+ if (output) {
218
+ this.#inputStateFor(output).inputBytes += bytes;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * @param {object} output
224
+ * @returns {number}
225
+ */
226
+ inputBytesOf(output) {
227
+ return output ? this.#inputStateFor(output).inputBytes : 0;
228
+ }
229
+
230
+ runsOf(output) {
231
+ return output ? this.#host.encodeOrchestrator.runsOn(output.outputKey) : [];
232
+ }
233
+
234
+ /**
235
+ * Where the earliest live encoder of this output began, or null with none.
236
+ *
237
+ * @param {object} output
238
+ * @returns {number | null}
239
+ */
240
+ earliestStartOf(output) {
241
+ return earliestRunStart(this.runsOf(output));
242
+ }
243
+
244
+ isLive(output) {
245
+ return Boolean(output && this.#host.outputs.get(output.id) === output);
246
+ }
247
+
248
+ liveRunsOf(output) {
249
+ return liveRunsOf(this.runsOf(output));
250
+ }
251
+
252
+ runStateOf(output) {
253
+ return output ? this.#host.encodeOrchestrator.stateOf(output.outputKey) : runStateOf([]);
254
+ }
255
+
256
+ lastErrorOf(output) {
257
+ return output ? this.#host.encodeOrchestrator.errorOf(output.outputKey) : "";
258
+ }
259
+
260
+ /**
261
+ * Whether this output's encoding has failed for good.
262
+ *
263
+ * @param {object} output
264
+ * @returns {boolean}
265
+ */
266
+ hasFailed(output) {
267
+ return this.runStateOf(output) === ENCODE_RUN_STATE.ENDED_FAILED;
268
+ }
269
+
270
+ /**
271
+ * Whether this output's input went away and is being fetched again.
272
+ *
273
+ * @param {object} output
274
+ * @returns {boolean}
275
+ */
276
+ isWaitingForInput(output) {
277
+ return this.runStateOf(output) === ENCODE_RUN_STATE.RETRY_WAIT;
278
+ }
279
+
280
+ /**
281
+ * The run state in the words the browser is told.
282
+ *
283
+ * @param {object} output
284
+ * @returns {string}
285
+ */
286
+ wireStateOf(output) {
287
+ return wireState(this.runStateOf(output));
288
+ }
289
+
290
+ /**
291
+ * What went wrong, when this output's encoding has failed; empty otherwise.
292
+ *
293
+ * @param {object} output
294
+ * @returns {string}
295
+ */
296
+ failureOf(output) {
297
+ return this.hasFailed(output) ? this.lastErrorOf(output) : "";
298
+ }
299
+
300
+ progressOf(output, index = null) {
301
+ if (!output) {
302
+ return null;
303
+ }
304
+ const at = Number.isInteger(index)
305
+ ? index
306
+ : this.#host.outputTimes.segmentIndexForTime(output, this.#host.viewerSecondsOn(output));
307
+ const progress = this.#host.encodeOrchestrator.progressOf(output.outputKey, at);
308
+ if (progress) {
309
+ return progress;
310
+ }
311
+ const start = this.#host.viewerSecondsOn(output);
312
+ const total = Number(output.file?.durationSeconds);
313
+ return {
314
+ processedSeconds: start,
315
+ startPositionSeconds: start,
316
+ totalSeconds: Number.isFinite(total) && total > 0 ? total : null,
317
+ percent: 0,
318
+ remainingSeconds: Number.isFinite(total) && total > 0 ? Math.max(0, total - start) : null,
319
+ speed: "",
320
+ updatedAt: this.#host.outputs.startedAt(output) ?? Date.now()
321
+ };
322
+ }
323
+
324
+ planEncodersNow() {
325
+ const byOutput = this.#outputsWithSessions();
326
+ for (const [address, sessions] of byOutput) {
327
+ // From the TIMELINE, which is where how a file is cut has lived since
328
+ // 2.76.0. Read off the session it left, this was `undefined` on every
329
+ // session ever made: the map then held no length, and the walk that
330
+ // gives a run its end ran to MAX_SAFE_INTEGER — the main thread spun at
331
+ // 100% and the proxy answered nothing, measured on the addon host
332
+ // 2026-09-05 with the stack read out of the live process.
333
+ const segmentCount = Number(sessions[0].timeline?.segmentCount) || 0;
334
+ if (segmentCount > 0) {
335
+ this.#host.encodeOrchestrator.setSegmentCount(address, segmentCount);
336
+ }
337
+ }
338
+ // THE MAP IS BUILT ONCE, IN ITS OWN LAYER, AND BOTH SIDES READ THAT ONE.
339
+ //
340
+ // It used to be built twice: once here, per viewer, converted and stated to
341
+ // the encoding as a window each, and once again inside `publishFor` for the
342
+ // downloading. Two answers to one question, and the encoding's copy carried
343
+ // the viewer's NAME as the key of a claim — against the rule that the
344
+ // encoding and the viewer are not connected at all.
345
+ this.#host.priority.publishFor({
346
+ sessionGroups: byOutput.values(),
347
+ });
348
+ // FROM THE VIEWERS OF THAT OUTPUT, not from the viewers of the film.
349
+ //
350
+ // Both scopes are right for what asks them: the swarm is asked for bytes of
351
+ // a FILE, which every output of it reads, and encoders are placed per
352
+ // OUTPUT, which a person watching 480p wants nothing of at 1080p. Handed the
353
+ // film's map, the plan wanted an encoder on every output of it, and what
354
+ // stopped the unwatched ones was this class killing them by its own
355
+ // judgement — while the viewer's own move announced itself and had the plan
356
+ // start them again.
357
+ //
358
+ // Converted into each output's own numbering, because two outputs of one
359
+ // film are cut independently and the same second is a different number in
360
+ // each: 454 pieces against 401 on the field file.
361
+ for (const [address, sessions] of byOutput) {
362
+ const timeline = sessions[0].timeline;
363
+ this.#host.encodeOrchestrator.notePriorityMap(
364
+ address,
365
+ timeline?.inSegments?.(
366
+ this.#host.priority.mapForOutput(address),
367
+ Number(timeline?.segmentCount) || 0
368
+ ) ?? []
369
+ );
370
+ }
371
+ this.#host.encodeOrchestrator.reconcile();
372
+ }
373
+
374
+ /**
375
+ * Re-decide what encoders should exist, once, after the change that is being
376
+ * made now.
377
+ *
378
+ * COALESCED, NOT DELAYED. One turn of the event loop may carry several
379
+ * changes — a viewer arrives and is placed and states their soundtrack — and
380
+ * each of them is a reason to re-decide, while re-deciding three times in a
381
+ * row would give the same answer three times and could act on a half-built
382
+ * state. So the decision is taken once, after the current turn, and no
383
+ * interval is involved: there is nothing to choose and nothing to tune.
384
+ *
385
+ * @returns {void}
386
+ */
387
+ planEncodersSoon() {
388
+ if (this.#planScheduled) {
389
+ return;
390
+ }
391
+ this.#planScheduled = true;
392
+ queueMicrotask(() => {
393
+ this.#planScheduled = false;
394
+ try {
395
+ this.planEncodersNow();
396
+ } catch (error) {
397
+ const message = error instanceof Error ? error.message : String(error);
398
+ this.#host.logger.warn(`transcode could not re-plan encoders: ${message}`);
399
+ }
400
+ });
401
+ }
402
+
403
+ /**
404
+ * The live sessions grouped by the output they produce.
405
+ *
406
+ * @returns {Map<string, HlsSession[]>}
407
+ */
408
+ #outputsWithSessions() {
409
+ /** @type {Map<string, HlsSession[]>} */
410
+ const byOutput = new Map();
411
+ for (const session of this.#host.outputs.values()) {
412
+ const address = session.outputKey ?? "";
413
+ if (!address) {
414
+ continue;
415
+ }
416
+ byOutput.set(address, [...(byOutput.get(address) ?? []), session]);
417
+ }
418
+ return byOutput;
419
+ }
420
+
421
+ forgetEncodingOfGone(output) {
422
+ if (this.#host.outputs.outputsOn(output.outputKey).length === 0) {
423
+ this.#host.encodeOrchestrator.forgetOutput(output.outputKey);
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Make an encoder for this output, beginning at this segment.
429
+ *
430
+ * Another run of the SAME session, not another session. A run at another
431
+ * position is not a different output — the material, the tracks, the grid and
432
+ * the box are all the same, and only where it begins differs, which changes
433
+ * no byte of what is produced. Both write into the output's one directory and
434
+ * either viewer is served whatever either has made.
435
+ *
436
+ * @param {string} address
437
+ * @param {number} from
438
+ * @param {number} to
439
+ * @param {string} because - The plan's own words for why it placed this one.
440
+ * @returns {object | null}
441
+ */
442
+ makeRunAt(address, from, to, because) {
443
+ let base = null;
444
+ for (const session of this.#host.outputs.values()) {
445
+ if (session.outputKey === address) {
446
+ base = session;
447
+ break;
448
+ }
449
+ }
450
+ if (!base) {
451
+ return null;
452
+ }
453
+ // A position that has already failed to start, this many times running,
454
+ // will fail again: nothing about it has changed between the attempts, which
455
+ // is exactly why the attempts keep taking the same fraction of a second and
456
+ // ending the same way.
457
+ //
458
+ // The plan is arithmetic over coverage and demand, and neither of them
459
+ // knows that a process refused to start — so without asking here, the plan
460
+ // commands the same start, the run ends, the ended stretch goes back to the
461
+ // map, and the plan commands it again. Measured 2026-09-05 with ffmpeg
462
+ // absent: fifty passes of the plan in the time a probe took to notice, as
463
+ // fast as spawning could fail. The five-second timer this decision used to
464
+ // sit on hid that, at the price of hiding it in the field too — the loop
465
+ // seen there ran for sixteen minutes and read as "a restart every five
466
+ // seconds".
467
+ //
468
+ // A DIFFERENT position is unaffected and gets its own budget, and the count
469
+ // resets the moment a run at this one does real work.
470
+ if (!this.#host.encodeOrchestrator.mayStartAt(address, from)) {
471
+ return null;
472
+ }
473
+ // The encoder is built and handed back in this same call. Nothing here
474
+ // waits: the one thing this path used to wait for was the death of the run
475
+ // it was replacing, and that killing is gone. Answering with nothing while
476
+ // the encoder was built behind the answer is what let the same stretch be
477
+ // started over and over — 684 starts in 482 seconds of field 2026-09-05.
478
+ try {
479
+ return this.#startEncodeRun(base, from, because, { to });
480
+ } catch (error) {
481
+ const message = error instanceof Error ? error.message : String(error);
482
+ this.#host.logger.warn(`transcode could not start a run at #${from} of ${address}: ${message}`);
483
+ return null;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * How many encoders this machine can afford on one output at once.
489
+ *
490
+ * Measured rather than chosen, and it is the same arithmetic that decides
491
+ * which quality steps are offered: what a second job costs here is not
492
+ * assumed to be nothing. Field 2026-09-03 on the addon host, `testsrc2`
493
+ * through libx264 `ultrafast` — at 854x480 one run makes 7.12x and two make
494
+ * 4.20x and 4.16x, so both stay far above realtime and a second is
495
+ * affordable; at 1920x1080 one makes 1.96x and two make 0.99x and 0.98x, so
496
+ * the machine is full at one.
497
+ *
498
+ * @param {string} address
499
+ * @returns {number}
500
+ */
501
+ maxRunsForOutput(address) {
502
+ const alone = this.#host.encodeCost.speedForOutput(address);
503
+ if (!(alone > 0)) {
504
+ // The host measured nothing at all, which is a broken startup rather than
505
+ // a state to plan around. One encoder is what it keeps.
506
+ return 1;
507
+ }
508
+ let affordable = 1;
509
+ for (;;) {
510
+ const { penalty, measured, from } = contentionPenalty(affordable, this.#host.contentionPenalties);
511
+ // An UNMEASURED penalty is 1, and that is the honest answer to "what does
512
+ // a second job cost" only in the sense that nothing has been measured —
513
+ // it is not a statement that a second job is free. Measured on the addon
514
+ // host it is 1.70x at 854x480 and 1.98x at 1920x1080, so taking 1 would
515
+ // let a machine that cannot hold two runs start two. Where nothing has
516
+ // been measured, one is what it has.
517
+ //
518
+ // `from` is the concurrency the reading came from, and past the measured
519
+ // range `contentionPenalty` HOLDS the largest reading rather than continue
520
+ // a curve two points cannot describe. Held is the right answer to "what
521
+ // does this cost", and the wrong one to "may I start another": it would
522
+ // price a fifth encoder at what a second was measured to cost, and a fast
523
+ // host would keep dividing until some chosen ceiling stopped it. So the
524
+ // ladder stops where the measurements stop — the bound on how many
525
+ // encoders may run is measured, like the budget itself, and there is no
526
+ // constant here to overrule it.
527
+ if (!measured || from !== affordable || !(alone / penalty >= 1)) {
528
+ break;
529
+ }
530
+ affordable += 1;
531
+ }
532
+ return affordable;
533
+ }
534
+
535
+ /**
536
+ * A segment this run made has just been served.
537
+ *
538
+ * The one event whose raising is guarded by the state, and the guard is a
539
+ * READ of the state rather than a second copy of it: "something has been
540
+ * produced" is a level, not an edge, so without the guard every served
541
+ * segment would raise it and the log would fill with refusals.
542
+ *
543
+ * Whose file it is decides the rest, and the directory answers that outright:
544
+ * runs write into one each, and serving searches them newest-first, so a
545
+ * segment left by an EARLIER run is served routinely. Comparing indices
546
+ * instead would have been fooled by the ordinary case of a backward seek —
547
+ * the new run starts at #10, the old one left #50 on disk, and #50 is above
548
+ * the new run's start index while saying nothing about it.
549
+ *
550
+ * @param {HlsSession} session
551
+ * @param {string} filePath - Where the served segment was actually found.
552
+ * @returns {void}
553
+ */
554
+ noteRunProducedSegment(session, filePath) {
555
+ if (typeof filePath !== "string" || path.dirname(filePath) !== this.#host.segmentFiles.pathFor(session.outputKey ?? "")) {
556
+ return;
557
+ }
558
+ const index = session.segmentFormat.segmentIndexFromName(path.basename(filePath));
559
+ if (index === null) {
560
+ return;
561
+ }
562
+ // Whichever run was given this number. An output has as many runs as the
563
+ // machine affords, and a segment moves the one it belongs to out of
564
+ // starting — not whichever happens to be listed first.
565
+ ownRunMaking(this.runsOf(session), index)?.noteProduced(index);
566
+ }
567
+
568
+ /**
569
+ * Say so when a run is about to encode a picture the init segment already in
570
+ * the player's hands does not describe.
571
+ *
572
+ * This is the whole class of fault named in one line, and it exists because
573
+ * the fault is otherwise SILENT: no layer reports an error, the encoder is
574
+ * healthy, segments are served in milliseconds, and what the viewer gets is
575
+ * either a band of macroblock garbage or a picture that never appears. The
576
+ * shape is the one 2.48.0 uses for the TIME a run begins at — a run states
577
+ * where it really landed, and a disagreement with what was published is
578
+ * named rather than left to be inferred from a browser's own reading.
579
+ *
580
+ * The size the init describes is read from the init's own bytes, not taken
581
+ * from our record of what the encoder was told, because those two disagreeing
582
+ * IS the fault. The size the run will produce is computed by the same
583
+ * arithmetic the scale filter performs, so a source smaller than the target
584
+ * box is not reported as a disagreement.
585
+ *
586
+ * Said once per distinct pair of sizes: a run repeated at the same wrong size
587
+ * has nothing further to say.
588
+ *
589
+ * @param {HlsSession} session
590
+ * @returns {void}
591
+ */
592
+ #warnIfRunLeavesTheInitBehind(session) {
593
+ const initBytes = this.#host.segmentFiles.initOf(session.outputKey ?? "");
594
+ if (!session.spec.transcodesVideo || !initBytes || initBytes.length === 0) {
595
+ return; // nothing served yet, or nothing being encoded
596
+ }
597
+ const format = session.segmentFormat;
598
+ if (typeof format?.initVideoSize !== "function") {
599
+ return; // a self-describing container (MPEG-TS) cannot have this fault
600
+ }
601
+ const described = format.initVideoSize(initBytes);
602
+ if (!described) {
603
+ return;
604
+ }
605
+ if (!(session.output.encodeWidth > 0) || !(session.output.encodeHeight > 0)) {
606
+ return; // the run keeps the encoder's own default box; nothing was told
607
+ }
608
+ const producing = computeOutputDimensions(
609
+ session.output.encodeWidth,
610
+ session.output.encodeHeight,
611
+ session.file.width,
612
+ session.file.height
613
+ );
614
+ if (!producing) {
615
+ return; // the source size is unknown, so nothing can be predicted
616
+ }
617
+ if (described.width === producing.w && described.height === producing.h) {
618
+ return;
619
+ }
620
+ const said = `${described.width}x${described.height}->${producing.w}x${producing.h}`;
621
+ const inputState = this.#inputStateFor(session);
622
+ if (inputState.initSizeSaid === said) {
623
+ return;
624
+ }
625
+ inputState.initSizeSaid = said;
626
+ this.#host.logger.warn(
627
+ `transcode ${session.id} is about to encode ${producing.w}x${producing.h} while the init segment ` +
628
+ `the player holds describes ${described.width}x${described.height} "${session.file.name}" — ` +
629
+ `every fragment of this run will be decoded against parameter sets for a picture that is not ` +
630
+ `being made. A change of size is a change of variant; nothing here should have moved it.`
631
+ );
632
+ }
633
+
634
+ /**
635
+ * How far a run starting here may work before it meets somebody else's
636
+ * material — read off the ONE coverage map, never worked out again here.
637
+ *
638
+ * This replaced `planRunInterval`, which was a second authority over the same
639
+ * question and answered it by different rules: it walked the whole track for
640
+ * the first free number, MOVED the start there itself, and counted every live
641
+ * run as claiming up to `head + look-ahead`. Measured in the field
642
+ * 2026-09-05: the plan commanded a start at #46, this moved it to #78 — the
643
+ * "run moved forward from #46 to #78" line — and the plan, seeing a run
644
+ * outside the window it had asked for, killed it as unwanted and commanded
645
+ * the same start again, 350-700ms per cycle, dozens of times, the viewer's
646
+ * picture stopped for 125 seconds.
647
+ *
648
+ * Where a run STARTS is the plan's decision and arrives here as an argument.
649
+ * All this answers is where it must stop, and the answer is a fact of the
650
+ * map: the free stretch from that number on. `-1` means the end of the track,
651
+ * which is what "no end" is written as everywhere here.
652
+ *
653
+ * @param {object} session
654
+ * @param {number} startIndex
655
+ * @param {object | null} exceptRun - The run being replaced, whose own claim
656
+ * is not somebody else's material.
657
+ * @returns {number} The last number to work through, or `-1` for the end.
658
+ */
659
+ #runEndFrom(session, startIndex, exceptRun = null) {
660
+ return this.#host.encodeOrchestrator.freeStretchEnd({
661
+ address: session.outputKey ?? "",
662
+ from: startIndex,
663
+ exceptRun,
664
+ segmentCount: Number(session.timeline?.segmentCount) || 0
665
+ });
666
+ }
667
+
668
+ // Returns the encoder it built, or nothing when there was nothing to build.
669
+ // It waits for nothing: the one thing it used to wait for was the death of
670
+ // the run it was replacing, and that killing is gone.
671
+ #startEncodeRun(session, startIndex, because, ordered = null) {
672
+ if (!this.isLive(session)) {
673
+ return null;
674
+ }
675
+ // A new run starts the quality budget's reckoning afresh: a pair of
676
+ // readings spanning the restart would count the gap between two runs as
677
+ // slow encoding. Told, not written: those readings are the budget's.
678
+ this.#host.noteRunStarting(session);
679
+ // Where a restart's seconds go. A seek costs 5-8 s in the field and the
680
+ // recorded reason — waiting for the previous ffmpeg to exit, measured at
681
+ // 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
682
+ // in the proxy on a guess, make each stage state its own cost.
683
+ const restartEnteredAt = Date.now();
684
+ // Reads where the old run began BEFORE the new one takes its place, and
685
+ // does not await: everything below is the restart path, which is measured
686
+ // in milliseconds and has been worked on twice to keep it that way.
687
+ this.#accountBackwardRestart(session, startIndex);
688
+ // STARTING AN ENCODER STOPS NOTHING. It used to stop any live run whose own
689
+ // start was not below this one's — a rule left over from when a session
690
+ // held exactly one run and "the previous one" meant "the only one". Once a
691
+ // an output could hold several, that rule began killing runs the plan had
692
+ // decided to keep: 294 stops for this reason in eight minutes of field
693
+ // 2026-09-05, against four starts asked for by a viewer. Who is stopped is
694
+ // decided in one place, and it is not this one.
695
+ //
696
+ // HOW FAR IT MAY WORK is decided by the same one place and arrives here as
697
+ // an argument. Reading it off the coverage map a second time was the last
698
+ // remaining second answer to that question: the plan computed `to`, passed
699
+ // it, and the parameter list did not name it.
700
+ const runEnd = Number.isInteger(ordered?.to)
701
+ ? ordered.to
702
+ : this.#runEndFrom(session, startIndex, null);
703
+ // The restart backs off a segment or two from what was asked for, so the
704
+ // request that prompted it is recorded under a HIGHER index than the run
705
+ // starts at. Looking it up by the start index alone found nothing and the
706
+ // line never printed once.
707
+ let wantedAt = null;
708
+ const inputState = this.#inputStateFor(session);
709
+ for (const [index, at] of inputState.firstWantedAt) {
710
+ if (index >= startIndex && (wantedAt === null || at < wantedAt)) {
711
+ wantedAt = at;
712
+ }
713
+ }
714
+ if (typeof wantedAt === "number") {
715
+ this.#host.logger.info(
716
+ `transcode ${session.id} restart for #${startIndex} decided ` +
717
+ `${restartEnteredAt - wantedAt}ms after it was first asked for`
718
+ );
719
+ }
720
+ // Cleared with the run that could not answer them. These are "how long has
721
+ // this segment gone unanswered", and the question only means anything about
722
+ // the run in force: a timestamp kept from an abandoned scan probe minutes
723
+ // ago says a fresh request has already waited long enough, which is how the
724
+ // behind-head repair came to fire on the very first poll instead of waiting
725
+ // for the seek that should move the encoder. It also stops the map growing
726
+ // for the life of a session.
727
+ inputState.firstWantedAt.clear();
728
+ // WHERE THIS NUMBER REALLY BEGINS, when a produced piece has said so and
729
+ // the table the player holds still says otherwise.
730
+ //
731
+ // Only a soundtrack, and only because a soundtrack has no keyframes: it
732
+ // begins exactly where it is asked to, to within one audio frame, while the
733
+ // picture can begin nowhere but a real keyframe and so moves forward to the
734
+ // next one — up to three seconds, measured 2026-08-17. Left at the published
735
+ // time, the sound of one film starts up to that far from its picture after
736
+ // every restart, each correctly labelled with where it really is, and the
737
+ // viewer gets sound with no new picture for the difference.
738
+ //
739
+ // DERIVED HERE rather than handed in. It used to arrive as an argument from
740
+ // the one caller that knew it — the correction that had just measured it —
741
+ // which meant the instant was only ever right for a run that caller
742
+ // started. Any run the plan placed at a corrected number got the published
743
+ // time and landed apart again. It is a fact of the FILE's cutting, held in
744
+ // the live table every session of the file shares, so it is read from
745
+ // there.
746
+ const positionSecondsOverride = session.spec.carries === "audio-only"
747
+ ? trueStartOf(session.timeline, startIndex)
748
+ : undefined;
749
+
750
+ // What is still read off the session for a run. The list is the measure of
751
+ // how far a session still is from being the three things a run is built
752
+ // from — the material, what is produced of it, and the stretch — and it
753
+ // shrinks in place as those are taken off. There is deliberately no method
754
+ // wrapping it: a named adapter with one caller is a thing to remember to
755
+ // delete, and this is a thing that disappears by being emptied.
756
+ const inputs = this.#host.inputOf(session);
757
+ const { args, safeIndex, startSeconds, cutTimes } = buildRunCommand({
758
+ keyframes: session.keyframes,
759
+ inputFile: inputs.inputFile,
760
+ audioFile: inputs.audioFile,
761
+ inputUrl: inputs.inputUrl,
762
+ audioInputUrl: inputs.audioInputUrl,
763
+ timeline: session.timeline,
764
+ output: session.output,
765
+ segmentFormat: session.segmentFormat,
766
+ transcodeVideo: session.spec.transcodesVideo,
767
+ transcodeAudio: session.spec.transcodesAudio,
768
+ audioOnly: session.spec.carries === "audio-only",
769
+ audioSeparate: this.#host.servesAudioSeparately(session),
770
+ audioSourceTrackIndex: session.spec.audioSourceTrackIndex,
771
+ rateControl: session.spec.video?.encode?.rateControl ?? null,
772
+ startIndex,
773
+ endIndex: runEnd,
774
+ positionSecondsOverride,
775
+ videoEncoder: this.#host.videoEncoder,
776
+ segmentDurationSec: this.#host.segmentDurationSec
777
+ });
778
+
779
+ // The exact command, every run. An encode failure is otherwise reported
780
+ // with ffmpeg's message and nothing about what it was asked to do, and the
781
+ // two are not always deducible from each other: 2026-08-04 a run died with
782
+ // "Cannot write moov atom before AC3 packets" although both muxing paths
783
+ // were verified to handle a copied AC-3 track on this very host, so the
784
+ // arguments that run actually received are the missing evidence. One line
785
+ // per run, and a run happens at most every few seconds.
786
+ // Numbered, because a burst of seeks starts several runs in one second and
787
+ // every line about them carries the SESSION id, which is the same for all.
788
+ // Without a run number the command that failed cannot be told from the two
789
+ // that succeeded around it — which is exactly the state the unexplained
790
+ // `Cannot write moov atom before AC3 packets` was found in.
791
+
792
+ // One directory for the output, and every run writes straight into it.
793
+ //
794
+ // Runs used to be kept apart by a directory each, because two of them
795
+ // writing one segment name at the same time produce a file belonging to
796
+ // neither — which was also the only reason a restart ever had to wait for
797
+ // its predecessor to die. Intervals remove that by construction: a run is
798
+ // given a stretch nobody else holds and stops at the end of it, so no two
799
+ // runs ever want the same number. What is left of the old reason — a run
800
+ // killed mid-piece leaving a partial file — is not a correctness problem
801
+ // either, because the store serves a segment only once its closure is
802
+ // proven, and it is cleared up when the run ends.
803
+ const run = new EncodeRun({
804
+ address: session.outputKey ?? session.id,
805
+ encoder: this.#host.videoEncoder,
806
+ from: safeIndex,
807
+ to: runEnd,
808
+ buildArgs: () => args,
809
+ argsDescribed: describeFfmpegArgs(args),
810
+ // Whether this run cuts at times we gave it. Decides how a segment is
811
+ // judged finished — see getFileStream.
812
+ usesExplicitCuts: Boolean(cutTimes && cutTimes.length > 0),
813
+ startSeconds,
814
+ totalSeconds: Number(session.file.durationSeconds) || null,
815
+ spawn: (spawnArgs) =>
816
+ spawn(this.#host.ffmpegBin, spawnArgs, {
817
+ cwd: this.#host.segmentFiles.directoryFor(session.outputKey ?? ""),
818
+ // A fourth channel: the encoder names every piece it has CLOSED on it,
819
+ // which is the only proof a piece is whole.
820
+ stdio: ["ignore", "pipe", "pipe", "pipe"]
821
+ }),
822
+ logger: this.#host.logger,
823
+ // The film's last segment number, which is what tells "it reached the
824
+ // end" from "its input dried up": ffmpeg exits zero for both and over a
825
+ // torrent cannot tell them apart.
826
+ lastSegmentIndex: () =>
827
+ session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
828
+ inputUnavailable: (message) => isInputUnavailable(message),
829
+ onProgress: (report) => this.#noteRunProgress(session, run, report),
830
+ indexOfName: (name) => session.segmentFormat.segmentIndexFromName(
831
+ session.segmentFormat.servedNameOf?.(name) ?? name),
832
+ // Why this encoder exists, recorded with its argument list. It used to be
833
+ // handed to a separate `start` call; there is no separate call now.
834
+ because,
835
+ onClosed: (name) => {
836
+ const index = session.segmentFormat.segmentIndexFromName(
837
+ session.segmentFormat.servedNameOf?.(name) ?? name);
838
+ if (Number.isInteger(index) && index < safeIndex) return null;
839
+ return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, {
840
+ endSeconds: index < session.timeline.segmentCount - 1 ? session.timeline.publishedStartOf(index + 1) : undefined
841
+ });
842
+ },
843
+ onEnded: (ended) => this.noteRunEnded(session, run, ended)
844
+ });
845
+ // THE ONE FAULT THAT IS OTHERWISE SILENT, asked before this run produces a
846
+ // frame. It lost its caller in a refactor on 2026-09-04 and had none until
847
+ // 2026-09-15 — not by a decision, which is why it is back rather than gone.
848
+ this.#warnIfRunLeavesTheInitBehind(session);
849
+
850
+ this.#host.logger.info(
851
+ `transcode ${session.id} encode-run #${safeIndex}..#${runEnd} from segment #${safeIndex} ` +
852
+ `(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
853
+ `(${formatSeconds(startSeconds)}) "${session.file.name}"`
854
+ );
855
+ // The four numbers a run is positioned by, said once, because their
856
+ // disagreement is invisible everywhere else. The two tables are printed
857
+ // side by side: while they differ, every cut of this run is off by the
858
+ // difference, and nothing downstream can tell that from a bad index.
859
+ const liveStart = this.#host.outputTimes.segmentStartTime(session, safeIndex);
860
+ this.#host.logger.info(
861
+ `transcode ${session.id} run #${safeIndex}..#${runEnd} positioned at ${startSeconds.toFixed(3)}s ` +
862
+ `for boundary #${safeIndex} (published ${startSeconds.toFixed(3)}s, ` +
863
+ `live ${liveStart.toFixed(3)}s, apart ${(liveStart - startSeconds).toFixed(3)}s), ` +
864
+ `numbering from #${safeIndex}`
865
+ );
866
+ return run;
867
+ }
868
+
869
+ /**
870
+ * Log a report from a run that still belongs to this output. The run owns the
871
+ * clock and has already placed its relative ffmpeg time on the source
872
+ * timeline.
873
+ *
874
+ * @param {HlsSession} session
875
+ * @param {import("./EncodeRun.js").EncodeRun} run
876
+ * @param {object} progress
877
+ * @returns {void}
878
+ */
879
+ #noteRunProgress(session, run, progress) {
880
+ if (!this.isLive(session) || !this.runsOf(session).includes(run)) {
881
+ return;
882
+ }
883
+ if (run.progress.shouldLog(PROGRESS_LOG_INTERVAL_MS)) {
884
+ this.#host.logger.info(
885
+ `transcode ${session.id} "${session.file.name}" ${progress.percent.toFixed(1)}% ` +
886
+ `(${formatSeconds(progress.processedSeconds)} / ${formatSeconds(progress.totalSeconds)})` +
887
+ ` speed=${progress.speed || "n/a"}`
888
+ );
889
+ }
890
+ }
891
+
892
+ /**
893
+ * What to do about a run that has ended.
894
+ *
895
+ * The run says how it ended and why; this decides what follows, which is a
896
+ * question about the SESSION and the machine rather than about the process:
897
+ * whether to condemn a hardware encoder, whether to wait for the input to
898
+ * come back, whether a position has failed often enough to stop retrying it.
899
+ *
900
+ * An ending from a run the session has already replaced is not ignored — it
901
+ * is simply not about this session's current run, so nothing that describes
902
+ * the current run is written from it. That is what the identity check used to
903
+ * be for, and it is one condition now instead of one at the head of every
904
+ * handler.
905
+ *
906
+ * @param {HlsSession} session
907
+ * @param {import("./EncodeRun.js").EncodeRun} run
908
+ * @param {import("./EncodeRun.js").RunEnded} ended
909
+ * @returns {void}
910
+ */
911
+ noteRunEnded(session, run, ended) {
912
+ // First, and for every run whatever else follows: the stretch it held goes
913
+ // back to the map. A run whose claim is never released tells the plan that
914
+ // numbers nobody is making are being made, and nothing is ever started
915
+ // there again.
916
+ // WAS this run still the session's when it ended? Asked before it is
917
+ // removed, because after the removal the question always answers "no" —
918
+ // and it was asked after, so every line below this point was unreachable.
919
+ //
920
+ // Measured 2026-09-05 over both of the field host's log files: zero
921
+ // occurrences of this handler's own `encode-run #… failed:` line and zero
922
+ // of `fast failure at segment`, across every session this proxy has ever
923
+ // run. So the fallback from a failed hardware encoder to software, the
924
+ // retry when the torrent data goes away, the limit on retrying a position
925
+ // that keeps failing, and the error line naming the ffmpeg command have all
926
+ // been dead code — which is also why nothing ever stopped the restart loop
927
+ // recorded in the field the same day.
928
+ const wasCurrent = this.runsOf(session).includes(run);
929
+ this.#host.encodeOrchestrator.noteEnded(ended);
930
+ // What this admitted encode was seen to do, kept for the next time this
931
+ // host prices the same mode (roadmap item 97, step 14). Whatever the
932
+ // ending: a run that failed still ran at the speed it ran at, and the
933
+ // segments it closed still carried what they carried.
934
+ this.#host.observeEncodeEnded?.(session);
935
+ // A stretch went back to the map, so what should be running has changed.
936
+ // Said here rather than waited for: this is the moment it became true.
937
+ this.planEncodersSoon();
938
+ // ANYBODY ALREADY WAITING IS TOLD, and it is asked HERE because every
939
+ // ending passes this line while the handlers below return one by one. It
940
+ // used to be the last statement of this method, which the `SHORT` branch —
941
+ // ffmpeg exiting 0 having produced less than the playlist promises —
942
+ // returns before reaching. That ending is terminal (`ENDED_FAILED`), so a
943
+ // request arriving after it is answered with the failure at once while a
944
+ // wait that BEGAN before it sat out its whole deadline, or, where the page
945
+ // states none, until the viewer disconnected.
946
+ //
947
+ // THE CONDITION IS THE OUTPUT'S ACTUAL STATE, not this run's ending.
948
+ // `stateOf` answers from the LIVE runs and falls back to the last ending
949
+ // only when none remain, so one run of several ending in failure wakes
950
+ // nobody — something is still making it — and the last one does. Asked
951
+ // after `noteEnded` has recorded the ending, so the woken request reads the
952
+ // final state; and it re-reads the disk first, because a piece already made
953
+ // is served whatever became of the encoder.
954
+ if (this.hasFailed(session)) {
955
+ this.#host.invalidateWaits(session);
956
+ // A move to this output being prepared for a viewer waits for a segment
957
+ // that nothing will now make, and no publication will ever end that wait.
958
+ // This is the event that does (roadmap item 97, step 12).
959
+ this.#host.productionFailed(session);
960
+ }
961
+ if (!this.isLive(session)) {
962
+ this.forgetEncodingOfGone(session);
963
+ return;
964
+ }
965
+ if (!wasCurrent) {
966
+ // A run the session had already replaced. It has logged its own ending;
967
+ // nothing about the session follows from it.
968
+ return;
969
+ }
970
+ const lastError = this.lastErrorOf(session) || ended.because;
971
+ if (ended.ending === ENCODE_EXIT.STOPPED || ended.ending === ENCODE_EXIT.GONE) {
972
+ return;
973
+ }
974
+ if (ended.ending === ENCODE_EXIT.COMPLETE) {
975
+ this.#host.logger.info(`transcode ${session.id} encode-run complete "${session.file.name}"`);
976
+ return;
977
+ }
978
+ if (ended.ending === ENCODE_EXIT.SHORT) {
979
+ // ffmpeg exits 0 both when it reaches the end of the file and when its
980
+ // input simply stops producing bytes — over HTTP the two look identical
981
+ // to it. Field 2026-08-05: the torrent's download died, the read ended,
982
+ // and a run that had made 188 segments of 624 reported itself complete;
983
+ // the player then consumed what was on disk and froze on the first
984
+ // segment nobody was making. So the claim is checked against the playlist
985
+ // we published, and a run that stopped short is a FAILURE that can be
986
+ // restarted, not a finished file.
987
+ this.#host.logger.error(
988
+ `transcode ${session.id} encode-run #${ended.from}..#${ended.to} ended early: ` +
989
+ `${ended.because} "${session.file.name}"`
990
+ );
991
+ return;
992
+ }
993
+ // Runtime safety net: if a hardware encode fails, downgrade this proxy to
994
+ // software encoding for all sessions and restart this one, so playback is
995
+ // never permanently broken by a hardware/driver issue.
996
+ //
997
+ // Asked only of a genuine encoder failure. It used to be asked of every
998
+ // non-zero exit, so a run whose TORRENT DATA went away — which says nothing
999
+ // whatever about the encoder — condemned a working NVENC or QuickSync to
1000
+ // software for the life of the process, and started an extra run at the old
1001
+ // index while it was at it.
1002
+ if (ended.ending === ENCODE_EXIT.FAILED && session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software") {
1003
+ const failed = this.#host.encoders.useSoftware();
1004
+ const failedEncoder = failed?.name ?? this.#host.videoEncoder.name;
1005
+ this.#host.logger.warn(
1006
+ `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
1007
+ `(${lastError}); falling back to software libx264, and every output named by ${failedEncoder} is closed`
1008
+ );
1009
+ // An output IS its format, and the encoder is part of it: pieces from
1010
+ // another encoder cannot be decoded with the header this output's viewers
1011
+ // already hold. So the outputs made by the failed encoder end here, and
1012
+ // the next request for that picture names a format built on the new one.
1013
+ //
1014
+ // THE ONE DISPOSAL THAT DOES NOT ASK WHETHER ASSIGNMENTS STILL HOLD IT, and
1015
+ // deliberately. Every other one asks, because a repeat of a request must be
1016
+ // answered by what answered it first. Here what answered it first has no
1017
+ // encoder that can go on making it: holding the output keeps only a name
1018
+ // whose next segment nobody will produce. A response already being sent is
1019
+ // not cut short by this — it reads from a file already opened.
1020
+ for (const other of [...this.#host.outputs.values()]) {
1021
+ if (other.spec.video?.encode?.encoder === failedEncoder) {
1022
+ void this.#host.disposeSession(other.id);
1023
+ }
1024
+ }
1025
+ this.planEncodersSoon();
1026
+ return;
1027
+ }
1028
+ // Losing the INPUT is not the session failing — it is the data not being
1029
+ // there YET. The torrent can be re-added and re-downloaded, so the honest
1030
+ // answer to the viewer is "still working", not an error screen. Field
1031
+ // 2026-08-06: a torrent evicted mid-seek took the film with it, the run
1032
+ // died on `File 0 not found`, the session went terminal and answered 500 to
1033
+ // every request from then on — although the swarm was there and the data
1034
+ // would have come back in seconds. The circuit breaker below stays for what
1035
+ // it was built for, a target that genuinely cannot be encoded; it must not
1036
+ // condemn a session whose data merely went away.
1037
+ if (ended.ending === ENCODE_EXIT.INPUT_LOST) {
1038
+ const inputState = this.#inputStateFor(session);
1039
+ inputState.inputRetryCount += 1;
1040
+ this.#host.logger.warn(
1041
+ `transcode ${session.id} encode-run #${ended.from}..#${ended.to} lost its input ` +
1042
+ `(${lastError}) (attempt ${inputState.inputRetryCount})`
1043
+ );
1044
+ // HOW LONG TO WAIT IS THE PLAN'S, and this says only what happened. The
1045
+ // delay used to be timed here, against the dead run, which the plan never
1046
+ // consults — so it placed a fresh run at the same spot as fast as ffmpeg
1047
+ // could fail there: 2432 starts in 23 minutes in the field 2026-09-12,
1048
+ // against a delay that had reached its 15 s ceiling long before. The
1049
+ // orchestrator holds it now, beside the decision it governs.
1050
+ if (run.state === ENCODE_RUN_STATE.RETRY_WAIT) {
1051
+ run.retryDue();
1052
+ }
1053
+ return;
1054
+ }
1055
+ // A run that exits THIS fast never did real work: it failed at the start
1056
+ // itself — opening the input, spawning the process — rather than
1057
+ // mid-stream. Consecutive fast failures at the SAME position are counted so
1058
+ // that whoever commands a start can stop commanding one that keeps failing.
1059
+ //
1060
+ // COUNTED AT EVERY POSITION, #0 included. It used to be counted only past
1061
+ // #0, because it was written for seek restarts and a seek is never to the
1062
+ // beginning. That left the one position the plan commands FIRST with no
1063
+ // count at all, and the plan re-commanding a start that cannot succeed is
1064
+ // an unbounded loop: measured 2026-09-05, ffmpeg failing to spawn produced
1065
+ // fifty passes of the plan before a probe stopped it, as fast as the
1066
+ // failures arrived.
1067
+ const failure = this.#host.encodeOrchestrator.noteStartFailure(
1068
+ session.outputKey,
1069
+ ended.from,
1070
+ ended.livedMs
1071
+ );
1072
+ if (failure.count > 0) {
1073
+ this.#host.logger.warn(
1074
+ `transcode ${session.id} fast failure at segment #${ended.from} ` +
1075
+ `(${ended.livedMs}ms) — ${failure.count}/${failure.limit} consecutive`
1076
+ );
1077
+ if (failure.blocked) {
1078
+ this.#host.logger.error(
1079
+ `transcode ${session.id} will not be started at #${ended.from} again: ` +
1080
+ `${failure.count} starts there failed within ${failure.fastMs}ms each ` +
1081
+ `(${lastError}) "${session.file.name}"`
1082
+ );
1083
+ }
1084
+ }
1085
+ this.#host.logger.error(
1086
+ `transcode ${session.id} encode-run #${ended.from}..#${ended.to} failed: ${lastError}` +
1087
+ ` — ${this.#describeTrackSelection(session)}` +
1088
+ `\n ffmpeg ${run.argsDescribed || "(command not recorded)"}`
1089
+ );
1090
+ }
1091
+
1092
+ /**
1093
+ * What this run asked the source for, against what the source said it has.
1094
+ *
1095
+ * Written for one failure in particular: every `-map` this proxy builds ends
1096
+ * in `?`, so ffmpeg drops a mapping for an absent stream without complaint,
1097
+ * and a run whose mappings ALL drop produces a file with nothing in it —
1098
+ * reported as `Output file does not contain any stream`, exit 255. Read from
1099
+ * the exit code alone that is indistinguishable from any other refusal. Read
1100
+ * beside the tracks the file actually holds it is unmistakable, and it names
1101
+ * which side is wrong: an audio index past the end of the list is ours, no
1102
+ * streams at all is the source's.
1103
+ *
1104
+ * @param {HlsSession} session
1105
+ * @returns {string}
1106
+ */
1107
+ #describeTrackSelection(session) {
1108
+ const wanted = [];
1109
+ // Named exactly as the command line names them, second input included: a
1110
+ // refusal whose message describes a different mapping than the one that was
1111
+ // refused is the reading that cost a wrong diagnosis before.
1112
+ const inputs = this.#host.inputOf(session);
1113
+ const audioInput = inputs.audioInputUrl.length > 0 ? 1 : 0;
1114
+ const audioTrack = session.spec.audioSourceTrackIndex;
1115
+ if (session.spec.carries === "audio-only") {
1116
+ wanted.push(`audio 0:a:${audioTrack}`);
1117
+ } else if (this.#host.servesAudioSeparately(session)) {
1118
+ wanted.push("video 0:v:0");
1119
+ } else {
1120
+ wanted.push("video 0:v:0", `audio ${audioInput}:a:${audioTrack}`);
1121
+ }
1122
+ const counts = session.file.streamCounts;
1123
+ const held = counts
1124
+ ? `the source holds ${counts.video} video, ${counts.audio} audio, ` +
1125
+ `${counts.subtitle} subtitle` +
1126
+ (counts.other > 0 ? `, ${counts.other} other` : "")
1127
+ : "what the source holds was not recorded";
1128
+ return `this run asked for ${wanted.join(" + ")}, and ${held}`;
1129
+ }
1130
+
1131
+ /**
1132
+ * Stop this session's encoder without replacing it.
1133
+ *
1134
+ * A variant nobody is watching must not go on encoding: the host has one
1135
+ * encoder's worth of capacity, and the whole point of switching quality
1136
+ * seamlessly is that the new rung gets it. The session itself stays — its
1137
+ * produced segments remain servable, and switching back restarts it from
1138
+ * where the viewer then is.
1139
+ *
1140
+ * @param {HlsSession} session
1141
+ * @param {string} reason - Named in the log; a stopped encoder is otherwise
1142
+ * indistinguishable from one that died.
1143
+ * @returns {void}
1144
+ */
1145
+ stopEncodeRun(session, reason) {
1146
+ const running = this.liveRunsOf(session);
1147
+ if (running.length === 0) {
1148
+ return;
1149
+ }
1150
+ // Every run this session has going. The callers all mean the session's
1151
+ // encoding as a whole: a rung nobody is watching any more, a session being
1152
+ // disposed. A run that had already finished or failed is not among them,
1153
+ // which is what keeps a stop from erasing how it actually ended.
1154
+ for (const run of running) {
1155
+ // The run resumes itself if it was suspended — a stopped process does not
1156
+ // act on SIGTERM until it is continued — records the cause, and answers
1157
+ // its own exit. Nothing here has to null a field so that the exit is read
1158
+ // correctly, because there is no shared field left to misread.
1159
+ run.stop(reason);
1160
+ // Nothing is cleared up from here. What this run left open is under its
1161
+ // own working name, and the encoding layer removes it when the run's
1162
+ // ending reaches it — one place, and it needs neither the stretch nor the
1163
+ // init bytes this method used to fetch to judge a file by its contents.
1164
+ }
1165
+ this.#host.logger.info(`transcode ${session.id} ${running.length} encoder(s) stopped: ${reason}`);
1166
+ }
1167
+
1168
+ /**
1169
+ * What moving the encoder BACKWARDS costs, said out loud when it happens.
1170
+ *
1171
+ * Nothing already written is lost — every run keeps its own directory and
1172
+ * {@link SegmentStore#pathOfName} serves the union of all of them
1173
+ * — so the price of a restart is not the files. It is two other things, and
1174
+ * neither was ever counted:
1175
+ *
1176
+ * - **work done twice.** The new run begins at the target and encodes
1177
+ * forward through segments the old run had already finished. ffmpeg cannot
1178
+ * know they exist, so it makes them again.
1179
+ * - **the viewer in front.** While the run walks back up to where it already
1180
+ * was, nothing new is being made ahead of them, and their cushion drains.
1181
+ *
1182
+ * Both are what decides whether a session should be allowed a SECOND
1183
+ * concurrent run instead — roadmap item 64. That question cannot be answered
1184
+ * from taste, and this is the reading it needs: how often it happens at all,
1185
+ * how far back, and how much of the walk is a repeat.
1186
+ *
1187
+ * Nothing here is awaited by the caller. Everything below the call site is
1188
+ * the restart path, which is measured in milliseconds and has been worked on
1189
+ * twice to keep it that way; a reading that delays the thing it is reading
1190
+ * about is not a reading. The figures that MUST be taken before the new run
1191
+ * exists are taken synchronously, and only the file counting is left to run
1192
+ * on its own — against the directories that existed at this instant, so what
1193
+ * the new run is about to write cannot be counted as already there.
1194
+ *
1195
+ * @param {HlsSession} session
1196
+ * @param {number} startIndex - Where the new run will begin.
1197
+ * @returns {void}
1198
+ */
1199
+ #accountBackwardRestart(session, startIndex) {
1200
+ const previousStart = earliestRunStart(this.runsOf(session));
1201
+ if (!Number.isInteger(previousStart) || !Number.isInteger(startIndex) || startIndex >= previousStart) {
1202
+ // A first run, or one moving forward. Neither costs anything here: a
1203
+ // forward restart skips material it never made.
1204
+ return;
1205
+ }
1206
+ const processed = Number(this.progressOf(session)?.processedSeconds);
1207
+ const head = Number.isFinite(processed)
1208
+ ? Math.max(previousStart, this.#host.outputTimes.segmentIndexForTime(session, processed))
1209
+ : previousStart;
1210
+ // Bounded: a session an hour in has thousands of segments, and the count is
1211
+ // for a comparison, not an inventory.
1212
+ const last = Math.min(head, startIndex + BACKWARD_RESTART_SCAN_SEGMENTS);
1213
+ const accounting = this.#inputStateFor(session).backwardRestarts;
1214
+ accounting.count += 1;
1215
+ accounting.segmentsBack += previousStart - startIndex;
1216
+ accounting.worstBack = Math.max(accounting.worstBack, previousStart - startIndex);
1217
+
1218
+ void (async () => {
1219
+ let alreadyOnDisk = 0;
1220
+ for (const index of this.#host.producedNumbers(session)) {
1221
+ if (index >= startIndex && index <= last) {
1222
+ alreadyOnDisk += 1;
1223
+ }
1224
+ }
1225
+ accounting.remade += alreadyOnDisk;
1226
+ this.#host.logger.info(
1227
+ `transcode ${session.id} moving the encoder BACK from #${previousStart} to #${startIndex} ` +
1228
+ `(head was #${head}): ${alreadyOnDisk} of the ${last - startIndex + 1} segment(s) it will walk through ` +
1229
+ `are already on disk and will be made again, and nothing is produced ahead of #${head} until it gets ` +
1230
+ `back there — ${accounting.count} backward restart(s) this session, worst ${accounting.worstBack} ` +
1231
+ `segment(s) back, ${accounting.remade} segment(s) remade in total (roadmap 64)`
1232
+ );
1233
+ })().catch(() => {
1234
+ // silent-ok: a reading that fails is not worth ending a restart over.
1235
+ });
1236
+ }
1237
+ }