@torrent-tv/proxy 2.89.7 → 2.89.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +363 -338
  13. package/CHANGELOG.md +2345 -2335
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -19
  19. package/assets/calibration/audio/manifest.json +98 -98
  20. package/assets/diagnostics/sctpstate.gdb +31 -31
  21. package/bin/cli.js +684 -684
  22. package/bin/thread-pool.js +30 -30
  23. package/biome.json +412 -412
  24. package/docs/browser-proxy-contract.md +155 -155
  25. package/docs/container-architecture.md +273 -273
  26. package/docs/download-architecture.md +261 -261
  27. package/docs/encode-architecture.md +765 -765
  28. package/docs/encode-run-state.md +96 -96
  29. package/docs/logs.md +97 -97
  30. package/docs/playback-start-forecast.md +113 -113
  31. package/docs/socks5-egress-research.md +114 -114
  32. package/docs/storage-architecture.md +273 -273
  33. package/knip.json +14 -14
  34. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  35. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  36. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  37. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  38. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  39. package/openspec/changes/binary-distribution/proposal.md +53 -53
  40. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  41. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  42. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  43. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  44. package/openspec/changes/cold-start/design.md +98 -98
  45. package/openspec/changes/cold-start/proposal.md +62 -62
  46. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  47. package/openspec/changes/cold-start/tasks.md +48 -48
  48. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  49. package/openspec/changes/disk-cap/proposal.md +37 -37
  50. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  51. package/openspec/changes/disk-cap/tasks.md +19 -19
  52. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  53. package/openspec/changes/proxy-observability/design.md +38 -38
  54. package/openspec/changes/proxy-observability/proposal.md +49 -49
  55. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  56. package/openspec/changes/proxy-observability/tasks.md +21 -21
  57. package/openspec/changes/seek-debounce/design.md +118 -118
  58. package/openspec/changes/seek-debounce/proposal.md +59 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  60. package/openspec/changes/seek-debounce/tasks.md +33 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  62. package/openspec/changes/subtitle-language/proposal.md +50 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  64. package/openspec/changes/subtitle-language/tasks.md +26 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  66. package/openspec/changes/track-selection/design.md +47 -47
  67. package/openspec/changes/track-selection/proposal.md +45 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  69. package/openspec/changes/track-selection/tasks.md +20 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  71. package/openspec/changes/transcode-quality/proposal.md +51 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  73. package/openspec/changes/transcode-quality/tasks.md +63 -63
  74. package/openspec/config.yaml +38 -38
  75. package/package.json +51 -50
  76. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  77. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  78. package/research/double-spawn-2026-09-10.md +171 -171
  79. package/research/handover-2026-09-13.md +209 -209
  80. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  81. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  82. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  83. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  84. package/routes/api/client-logs/post.js +86 -86
  85. package/routes/api/delivery-sink/get.js +72 -72
  86. package/routes/api/link-probe/get.js +51 -51
  87. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  88. package/routes/api/playback-plan/post.js +53 -53
  89. package/routes/api/sources/files/get.js +106 -106
  90. package/routes/api/sources/post.js +36 -36
  91. package/routes/api/sources/stats/get.js +132 -132
  92. package/routes/api/sources/warm/post.js +217 -217
  93. package/routes/api/subtitles/get.js +230 -230
  94. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  95. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  96. package/routes/api/transcode-sessions/post.js +178 -178
  97. package/routes/api/transcode-sessions/progress/get.js +27 -27
  98. package/routes/api/transcode-sessions/release/post.js +41 -41
  99. package/routes/api/transcode-sessions/seek/post.js +63 -63
  100. package/routes/health/get.js +13 -13
  101. package/routes/healthz/get.js +13 -13
  102. package/routes/stream/get.js +416 -416
  103. package/routes/transcode/audio-file/get.js +64 -64
  104. package/routes/transcode/audio-warm/get.js +95 -95
  105. package/routes/transcode/session-file/get.js +52 -52
  106. package/routes/transcode/variant-file/get.js +95 -95
  107. package/routes/transcode/variant-warm/get.js +84 -84
  108. package/scripts/build-audio-samples.mjs +31 -31
  109. package/scripts/render-run-graph.js +173 -173
  110. package/server.js +748 -748
  111. package/services/encode/CoverageMap.js +428 -428
  112. package/services/encode/CushionReport.js +345 -345
  113. package/services/encode/EncodeAdmission.js +376 -376
  114. package/services/encode/EncodeOrchestrator.js +1192 -1192
  115. package/services/encode/EncodePlan.js +1368 -1368
  116. package/services/encode/EncodeRun.js +36 -12
  117. package/services/encode/Encoder.js +142 -142
  118. package/services/encode/EncoderSelection.js +38 -38
  119. package/services/encode/LocalObservations.js +359 -359
  120. package/services/encode/NvencEncoder.js +50 -50
  121. package/services/encode/OutputOpening.js +784 -784
  122. package/services/encode/OutputTimes.js +579 -579
  123. package/services/encode/QsvEncoder.js +58 -58
  124. package/services/encode/Renditions.js +2283 -2283
  125. package/services/encode/RunClock.js +87 -87
  126. package/services/encode/RunProgress.js +79 -79
  127. package/services/encode/SegmentDemand.js +95 -95
  128. package/services/encode/SoftwareEncoder.js +121 -121
  129. package/services/encode/V4l2m2mEncoder.js +53 -53
  130. package/services/encode/VaapiEncoder.js +66 -66
  131. package/services/encode/args.js +418 -418
  132. package/services/encode/audio-calibration.js +67 -67
  133. package/services/encode/audio-work-rate.js +15 -15
  134. package/services/encode/available-share.js +89 -89
  135. package/services/encode/calibration.js +361 -361
  136. package/services/encode/contention.js +128 -128
  137. package/services/encode/decode-cost-fit.js +363 -363
  138. package/services/encode/encode-exit.js +116 -116
  139. package/services/encode/encode-run-state.js +551 -551
  140. package/services/encode/encoder-readings.js +43 -43
  141. package/services/encode/fingerprint.js +322 -322
  142. package/services/encode/host-load.js +174 -174
  143. package/services/encode/hwaccel.js +1894 -1894
  144. package/services/encode/index.js +9 -9
  145. package/services/encode/learned-median.js +90 -90
  146. package/services/encode/output/EncodedOutput.js +57 -57
  147. package/services/encode/output/Output.js +131 -131
  148. package/services/encode/output/OutputCatalog.js +414 -414
  149. package/services/encode/output/OutputSpec.js +390 -390
  150. package/services/encode/output/Timeline.js +308 -308
  151. package/services/encode/output/cut-grid.js +107 -107
  152. package/services/encode/output/index.js +24 -24
  153. package/services/encode/output/ladder.js +32 -32
  154. package/services/encode/output/playlists.js +284 -284
  155. package/services/encode/output/rates.js +125 -125
  156. package/services/encode/output-key-format.js +21 -21
  157. package/services/encode/quality/EncodeCost.js +1043 -1043
  158. package/services/encode/quality/HostLoad.js +525 -525
  159. package/services/encode/quality/HostTimings.js +249 -249
  160. package/services/encode/quality/OutputQualityState.js +24 -24
  161. package/services/encode/quality/QualityController.js +758 -758
  162. package/services/encode/quality/QualityOffer.js +416 -416
  163. package/services/encode/quality/drain-threat.js +47 -47
  164. package/services/encode/quality/link-budget.js +337 -337
  165. package/services/encode/quality/output-format.js +319 -319
  166. package/services/encode/quality/serving-output.js +216 -216
  167. package/services/encode/quality/visible-rung.js +57 -57
  168. package/services/encode/run-budget.js +97 -97
  169. package/services/encode/run-command.js +757 -757
  170. package/services/encode/run-costs.js +155 -155
  171. package/services/encode/run-inputs.js +53 -53
  172. package/services/encode/segment-formats/fmp4.js +476 -476
  173. package/services/encode/segment-formats/init-compat.js +324 -324
  174. package/services/encode/segment-formats/mpegts.js +134 -134
  175. package/services/encode/start-stop-cost.js +178 -178
  176. package/services/encode/throughput.js +97 -97
  177. package/services/encode/torrent-cost.js +88 -88
  178. package/services/identity/installation-id.js +38 -38
  179. package/services/media/ContainerOrchestrator.js +202 -202
  180. package/services/media/KeyframeTables.js +356 -356
  181. package/services/media/SourceFile.js +298 -298
  182. package/services/media/SubtitleCues.js +592 -592
  183. package/services/media/SubtitleOrchestrator.js +167 -167
  184. package/services/media/audio-inventory.js +299 -299
  185. package/services/media/container/AviContainer.js +268 -268
  186. package/services/media/container/Container.js +632 -632
  187. package/services/media/container/ContainerFactory.js +38 -38
  188. package/services/media/container/KeyframeTable.js +272 -272
  189. package/services/media/container/MatroskaContainer.js +1031 -1031
  190. package/services/media/container/Mp4Container.js +1354 -1354
  191. package/services/media/container/SubtitleFileContainer.js +339 -339
  192. package/services/media/container/ebml-reader.js +159 -159
  193. package/services/media/container/ebml-stream.js +174 -174
  194. package/services/media/container/matroska-clusters.js +1165 -1165
  195. package/services/media/container/unavailable.js +77 -77
  196. package/services/media/ffmpeg-banner.js +260 -260
  197. package/services/media/keyframe-probe.js +157 -157
  198. package/services/media/media-info-probe.js +93 -93
  199. package/services/media/playback-planner.js +931 -931
  200. package/services/media/tracks/AudioTrack.js +143 -143
  201. package/services/media/tracks/ContainerTrack.js +84 -84
  202. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  203. package/services/media/tracks/SubtitleTrack.js +38 -38
  204. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  205. package/services/media/tracks/VideoTrack.js +33 -33
  206. package/services/media/tracks/audio-bound.js +233 -233
  207. package/services/media/tracks/index.js +15 -15
  208. package/services/media/tracks/language-detect.js +174 -174
  209. package/services/server/OutputLifecycle.js +418 -418
  210. package/services/server/SegmentServing.js +1152 -1152
  211. package/services/server/ViewerRequests.js +785 -785
  212. package/services/server/WaitLedger.js +142 -142
  213. package/services/server/controllers/PlaybackController.js +40 -40
  214. package/services/server/controllers/SubtitleController.js +109 -109
  215. package/services/server/http-responses.js +60 -60
  216. package/services/server/transcode-session-files.js +325 -325
  217. package/services/server/wire-outputs.js +1029 -1029
  218. package/services/storage/Diagnostics.js +202 -202
  219. package/services/storage/MachineBudget.js +235 -235
  220. package/services/storage/allowance.js +107 -107
  221. package/services/storage/core-dumps.js +106 -106
  222. package/services/storage/files/CompletedFiles.js +391 -391
  223. package/services/storage/files/piece-from-whole-file.js +118 -118
  224. package/services/storage/free.js +71 -71
  225. package/services/storage/keep.js +48 -48
  226. package/services/storage/machine-memory.js +56 -56
  227. package/services/storage/memory-report.js +575 -575
  228. package/services/storage/piece-store/piece-disk-store.js +539 -539
  229. package/services/storage/piece-store/piece-lru.js +495 -495
  230. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  231. package/services/storage/returns.js +103 -103
  232. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  233. package/services/storage/wire.js +175 -175
  234. package/services/torrent/Contents.js +382 -382
  235. package/services/torrent/demand/DemandRegister.js +202 -202
  236. package/services/torrent/demand/Urgency.js +137 -137
  237. package/services/torrent/demand/Window.js +118 -118
  238. package/services/torrent/demand/index.js +8 -8
  239. package/services/torrent/demand/pieces.js +140 -140
  240. package/services/torrent/download/SwarmSelection.js +386 -386
  241. package/services/torrent/download/registry.js +102 -102
  242. package/services/torrent/download/withdraw-claim.js +80 -80
  243. package/services/torrent/episode-naming.js +182 -182
  244. package/services/torrent/files.js +455 -455
  245. package/services/torrent/naming.js +619 -619
  246. package/services/torrent/piece-store-of.js +29 -29
  247. package/services/torrent/supply-margin.js +264 -264
  248. package/services/torrent/torrent-pool.js +2688 -2688
  249. package/services/torrent/worker/background-fill.js +213 -213
  250. package/services/torrent/worker/channel.js +271 -271
  251. package/services/torrent/worker/client.js +836 -836
  252. package/services/torrent/worker/fastest-wires.js +342 -342
  253. package/services/torrent/worker/handle-state.js +23 -23
  254. package/services/torrent/worker/held-bytes.js +231 -231
  255. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  256. package/services/torrent/worker/piece-reader.js +1078 -1078
  257. package/services/torrent/worker/pool-adapter.js +371 -371
  258. package/services/torrent/worker/protocol.js +184 -184
  259. package/services/torrent/worker/resume-warm.js +107 -107
  260. package/services/torrent/worker/webrtc-shim.js +219 -219
  261. package/services/torrent/worker/worker.js +1137 -1137
  262. package/services/transport/data-channel-handler.js +1406 -1373
  263. package/services/transport/delivery-probe.js +798 -842
  264. package/services/transport/health-collector.js +53 -53
  265. package/services/transport/nat-classifier.js +231 -231
  266. package/services/transport/packet-witness.js +808 -808
  267. package/services/transport/port-mapper.js +235 -235
  268. package/services/transport/registry-api.js +81 -81
  269. package/services/transport/tunnel-client.js +447 -447
  270. package/services/transport/usrsctp-state.js +175 -175
  271. package/services/transport/webrtc-manager.js +484 -484
  272. package/services/viewer/Assignments.js +340 -340
  273. package/services/viewer/PriorityMap.js +421 -421
  274. package/services/viewer/PriorityOrchestrator.js +325 -325
  275. package/services/viewer/Viewer.js +691 -691
  276. package/services/viewer/Viewers.js +368 -368
  277. package/services/viewer/active-output.js +37 -37
  278. package/services/viewer/audio-start.js +46 -46
  279. package/services/viewer/buffer-trend.js +81 -81
  280. package/services/viewer/choices.js +647 -647
  281. package/services/viewer/media-time.js +355 -355
  282. package/services/viewer/playback-readiness.js +635 -635
  283. package/services/viewer/positions.js +126 -126
  284. package/services/viewer/report-intake.js +46 -46
  285. package/store/source-registry.js +71 -71
  286. package/test/asset-paths.test.js +28 -28
  287. package/test/audio-buffer-continuity.test.js +77 -77
  288. package/test/audio-calibration.test.js +23 -23
  289. package/test/audio-inventory.test.js +212 -212
  290. package/test/audio-track-bitrate.test.js +40 -40
  291. package/test/auto-quality-step.test.js +762 -762
  292. package/test/available-share.test.js +76 -76
  293. package/test/background-fill.test.js +190 -190
  294. package/test/client-log-file.test.js +110 -110
  295. package/test/coalesce.test.js +39 -39
  296. package/test/completed-files.test.js +115 -115
  297. package/test/concurrent-cost.test.js +148 -148
  298. package/test/container-media-info.test.js +228 -228
  299. package/test/contention.test.js +76 -76
  300. package/test/core-dumps.test.js +57 -57
  301. package/test/coverage-follows-the-disk.test.js +233 -228
  302. package/test/coverage-map.test.js +195 -195
  303. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  304. package/test/cut-times-timeline.test.js +65 -65
  305. package/test/cuts-follow-published-grid.test.js +109 -109
  306. package/test/data-channel-frame.test.js +63 -63
  307. package/test/data-channel-viewer-lifecycle.test.js +162 -92
  308. package/test/dead-torrent-handle.test.js +52 -52
  309. package/test/declared-rates.test.js +116 -116
  310. package/test/declared-tracks.test.js +43 -43
  311. package/test/decode-cost-fit.test.js +202 -202
  312. package/test/decode-cost.test.js +475 -475
  313. package/test/delivery-probe-field.test.js +173 -0
  314. package/test/delivery-probe.test.js +382 -393
  315. package/test/demand-register.test.js +206 -206
  316. package/test/dht-bootstrap.test.js +72 -72
  317. package/test/drain-threat.test.js +65 -65
  318. package/test/duplicate-add.test.js +48 -48
  319. package/test/encode-admission.test.js +146 -146
  320. package/test/encode-cost.test.js +275 -275
  321. package/test/encode-exit.test.js +111 -111
  322. package/test/encode-orchestrator.test.js +426 -426
  323. package/test/encode-plan-viewers.test.js +725 -725
  324. package/test/encode-plan.test.js +545 -545
  325. package/test/encode-run-state.test.js +314 -314
  326. package/test/encode-run.test.js +20 -0
  327. package/test/encode-slope.test.js +46 -46
  328. package/test/encoded-output.test.js +39 -39
  329. package/test/encoder-kinds.test.js +122 -122
  330. package/test/encoder-readings.test.js +68 -68
  331. package/test/episode-naming.test.js +131 -131
  332. package/test/every-claimant-is-told-its-share.test.js +152 -152
  333. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  334. package/test/failed-start-breaker.test.js +202 -202
  335. package/test/fastest-wires.test.js +143 -143
  336. package/test/ffmpeg-stream-counts.test.js +78 -78
  337. package/test/file-edges.test.js +191 -191
  338. package/test/fixtures/audio-frame-boundary.json +1 -1
  339. package/test/fmp4-self-contained.test.js +80 -80
  340. package/test/fmp4-timestamps.test.js +140 -140
  341. package/test/fmp4-track-completeness.test.js +41 -41
  342. package/test/format-keyed-outputs.test.js +78 -78
  343. package/test/health-metrics.test.js +36 -38
  344. package/test/held-read-ownership.test.js +77 -77
  345. package/test/held-request-width.test.js +157 -157
  346. package/test/helpers/encode-run.js +248 -240
  347. package/test/helpers/held-file.js +77 -77
  348. package/test/helpers/manager.js +62 -62
  349. package/test/helpers/matroska-file.js +296 -296
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +123 -123
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +111 -111
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +291 -291
  370. package/test/matroska-cluster-discovery.test.js +323 -323
  371. package/test/matroska-cues-track.test.js +192 -192
  372. package/test/media-time.test.js +62 -62
  373. package/test/memory-budget.test.js +390 -390
  374. package/test/move-cost.test.js +216 -216
  375. package/test/mp4-subtitles.test.js +173 -173
  376. package/test/one-authority.test.js +285 -285
  377. package/test/one-budget.test.js +149 -149
  378. package/test/one-output-one-session.test.js +273 -273
  379. package/test/one-resource-divided.test.js +185 -185
  380. package/test/one-serving-output-per-height.test.js +458 -458
  381. package/test/orchestrator-wired.test.js +207 -207
  382. package/test/output-catalog.test.js +39 -39
  383. package/test/output-shape.test.js +21 -21
  384. package/test/output-spec.test.js +251 -251
  385. package/test/output-speed.test.js +92 -87
  386. package/test/outputs-being-prepared.test.js +40 -40
  387. package/test/packet-witness-ring.test.js +236 -236
  388. package/test/packet-witness.test.js +148 -148
  389. package/test/piece-disk-store.test.js +401 -401
  390. package/test/piece-from-whole-file.test.js +129 -129
  391. package/test/piece-lru.test.js +319 -319
  392. package/test/piece-reader.test.js +162 -162
  393. package/test/piece-selection-offset.test.js +85 -85
  394. package/test/piece-store-eviction.test.js +595 -595
  395. package/test/piece-store-never-refuses.test.js +153 -153
  396. package/test/piece-store-reservations.test.js +403 -403
  397. package/test/piece-store-slow-disk.test.js +159 -159
  398. package/test/piece-tail.test.js +97 -97
  399. package/test/plan-host-timings.test.js +68 -68
  400. package/test/plan-is-steady.test.js +225 -225
  401. package/test/playback-readiness.test.js +518 -518
  402. package/test/playback-start-regressions.test.js +101 -101
  403. package/test/priority-map-download.test.js +174 -174
  404. package/test/priority-map-emptied.test.js +204 -204
  405. package/test/priority-map-per-output.test.js +215 -215
  406. package/test/priority-map.test.js +163 -163
  407. package/test/probe-wedge-certainty.test.js +60 -60
  408. package/test/produced-copy-choice.test.js +213 -213
  409. package/test/published-timeline.test.js +83 -83
  410. package/test/quality-offer.test.js +160 -160
  411. package/test/quality-variants.test.js +1274 -1270
  412. package/test/rate-control-in-the-output.test.js +181 -180
  413. package/test/read-survives-withdrawal.test.js +164 -164
  414. package/test/read-window.test.js +365 -365
  415. package/test/reads-not-yet-arrived.test.js +218 -218
  416. package/test/resume-warm.test.js +39 -39
  417. package/test/run-graph-drift.test.js +37 -37
  418. package/test/run-inputs.test.js +80 -80
  419. package/test/run-intervals.test.js +100 -100
  420. package/test/run-position-follows-published-grid.test.js +89 -89
  421. package/test/run-progress.test.js +64 -64
  422. package/test/same-height-switch.test.js +663 -663
  423. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  424. package/test/seek-landing.test.js +109 -109
  425. package/test/seek-target-not-superseded.test.js +195 -195
  426. package/test/segment-closed-on-both-branches.test.js +114 -114
  427. package/test/segment-cut-times.test.js +63 -63
  428. package/test/segment-demand.test.js +80 -80
  429. package/test/segment-hold-supersede.test.js +91 -91
  430. package/test/segment-judged-by-its-own-header.test.js +222 -222
  431. package/test/segment-serve-wiring.test.js +481 -481
  432. package/test/segment-store-eviction.test.js +247 -247
  433. package/test/segment-store.test.js +352 -352
  434. package/test/segment-true-start.test.js +86 -86
  435. package/test/segments-are-shared.test.js +176 -176
  436. package/test/send-chunk-bytes.test.js +86 -86
  437. package/test/serving-existing-output.test.js +111 -111
  438. package/test/serving-output.test.js +102 -102
  439. package/test/shared-piece-store.test.js +310 -310
  440. package/test/sidecar-naming.test.js +142 -142
  441. package/test/soundtrack-rate-bound.test.js +289 -289
  442. package/test/source-file.test.js +133 -133
  443. package/test/source-files-route.test.js +113 -113
  444. package/test/source-registry.test.js +18 -18
  445. package/test/split-a-stretch.test.js +139 -139
  446. package/test/stale-request-after-seek.test.js +158 -158
  447. package/test/startup-readings.test.js +124 -124
  448. package/test/stream-route.test.js +237 -237
  449. package/test/subtitle-cue-framing.test.js +200 -200
  450. package/test/subtitle-cue-source.test.js +102 -102
  451. package/test/subtitle-cue-walk.test.js +385 -385
  452. package/test/subtitle-cursor.test.js +63 -63
  453. package/test/subtitle-defaults.test.js +118 -118
  454. package/test/subtitle-language.test.js +252 -252
  455. package/test/subtitle-metadata.test.js +15 -15
  456. package/test/subtitle-plan-waits.test.js +125 -125
  457. package/test/subtitle-track-numbering.test.js +391 -391
  458. package/test/supply-margin.test.js +190 -190
  459. package/test/swarm-follows-readers.test.js +174 -174
  460. package/test/swarm-reach.test.js +130 -130
  461. package/test/swarm-selection.test.js +220 -220
  462. package/test/tail-duplication.test.js +167 -167
  463. package/test/the-channel-has-both-ends.test.js +91 -91
  464. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  465. package/test/thread-pool.test.js +53 -53
  466. package/test/timeline.test.js +84 -84
  467. package/test/torrent-contents.test.js +235 -235
  468. package/test/torrent-cost.test.js +71 -71
  469. package/test/torrent-files.test.js +221 -221
  470. package/test/torrent-naming.test.js +255 -255
  471. package/test/tracks-begin-together.test.js +209 -209
  472. package/test/tunnel-renewal.test.js +167 -167
  473. package/test/two-viewers-one-picture.test.js +508 -508
  474. package/test/upload-hurry.test.js +186 -186
  475. package/test/usrsctp-state.test.js +131 -131
  476. package/test/video-facts.test.js +102 -102
  477. package/test/viewer-assignments.test.js +143 -143
  478. package/test/viewer-outputs.test.js +285 -285
  479. package/test/viewer-presence.test.js +116 -116
  480. package/test/viewer-requests-admission.test.js +92 -92
  481. package/test/viewer-starts-on-map.test.js +27 -27
  482. package/test/viewer-states.test.js +192 -189
  483. package/test/viewer-subtitle-subscription.test.js +96 -96
  484. package/test/viewer.test.js +139 -139
  485. package/test/viewing-generations.test.js +390 -390
  486. package/test/visible-rung.test.js +37 -37
  487. package/test/wait-ledger.test.js +100 -100
  488. package/test/webrtc-shim.test.js +149 -149
  489. package/test/wedge-certainty.test.js +131 -131
  490. package/test/whole-file-held-read.test.js +30 -30
  491. package/test/withdraw-piece-claim.test.js +212 -212
  492. package/test/worker-channel.test.js +158 -158
  493. package/utils/client-log-file.js +151 -151
  494. package/utils/coalesce.js +41 -41
  495. package/utils/logger.js +312 -312
  496. package/utils/parse-range.js +25 -25
  497. package/utils/perf.js +59 -59
  498. package/utils/torrent-source-key.js +38 -38
@@ -1,757 +1,757 @@
1
- /**
2
- * @file The full argument list for one encoder run.
3
- *
4
- * What a run is given is a fact about WHAT is being produced and WHERE it
5
- * begins, and about nothing else — not the session it belongs to, not who is
6
- * watching, not how many viewers there are. It was a 377-line method of the
7
- * session manager reading fifteen of its fields, which is why a run could only
8
- * ever be built by that class, for the one session it holds.
9
- *
10
- * Stated here, over the material and the stretch alone, a run can be built by
11
- * whoever needs one. That is what lets an output have more than a single
12
- * encoder.
13
- *
14
- * **Nothing in this file runs anything.** It returns an argument list; spawning,
15
- * killing and resuming belong to whoever owns the process.
16
- */
17
-
18
- import { PLAYLIST_FILE_NAME } from "./output/playlists.js";
19
- import { AUDIO_TRANSCODE_KBPS } from "./args.js";
20
- import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
21
-
22
- /**
23
- * What ffmpeg's own CLI subtracts from an input seek, and therefore what has to
24
- * be added back to land where we asked.
25
- *
26
- * `fftools/ffmpeg_demux.c`, in `ifile_open`: when the container does not
27
- * declare `AVFMT_SEEK_TO_PTS` — Matroska does not — and any stream carries
28
- * B-frames, the seek target is moved back by `3*AV_TIME_BASE / 23` before
29
- * `avformat_seek_file` is called. Its purpose is sound: such containers seek in
30
- * decode order while the caller asks in presentation order, and with B-frames
31
- * the two differ, so it backs off far enough to be sure of reaching the frame
32
- * asked for.
33
- *
34
- * The consequence for a COPY is that asking for a keyframe lands on the one
35
- * BEFORE it — deterministically, every time. Measured 2026-08-21 on a Matroska
36
- * file with keyframes every 2 s: `-ss 10` produced a first segment starting at
37
- * 8.000; `-ss 10.130435` produced one starting at 10.000. On MP4, where the
38
- * heuristic does not fire, all of 10, 10.130435 and 10.2 produced 10.000 — so
39
- * adding this is right in one case and harmless in the other.
40
- *
41
- * That landing is what `-segment_times` is measured from, while this code
42
- * computes those offsets from the time it ASKED for. One keyframe interval
43
- * apart, inherited by every cut of the run: 119 of 125 segments arriving a
44
- * uniform 2.002 s early in the field, four times what a player bridges.
45
- *
46
- * Not applied when the picture is re-encoded: a re-encode decodes from the
47
- * keyframe and discards frames up to the requested time, so its output already
48
- * begins exactly where asked (measured the same day: `-ss 11` copied starts at
49
- * 10.000, re-encoded at 11.000).
50
- */
51
- export const SEEK_LANDING_OFFSET_SEC = 3 / 23;
52
-
53
- /**
54
- * A number of seconds as ffmpeg will accept it.
55
- *
56
- * `String(n)` switches to exponential notation below 1e-6, and ffmpeg's
57
- * duration parser rejects that outright: a field session died on
58
- * `Invalid duration for option ss: 3.3333333249174757e-7`, after which the
59
- * transcode was in state `failed` and every segment request answered 500 for
60
- * as long as the viewer kept trying. Anything under a millisecond is also not a
61
- * real offset — it is the residue of subtracting two nearly equal floats — so
62
- * it is dropped rather than passed on.
63
- *
64
- * @param {number} value
65
- * @returns {string}
66
- */
67
- export function ffmpegSeconds(value) {
68
- if (!Number.isFinite(value) || Math.abs(value) < 0.001) {
69
- return "0";
70
- }
71
- // Microsecond resolution, fixed notation, no trailing zero noise.
72
- return value.toFixed(6).replace(/\.?0+$/, "");
73
- }
74
-
75
- /**
76
- * Which timeline an output's own ffmpeg works on.
77
- *
78
- * True — the COPY branch: the source's timestamps are kept (`-copyts`) and the
79
- * output is re-labelled 0-based. Everything handed to the muxer is therefore
80
- * stated in the source's terms, and everything read back out of a produced
81
- * piece is 0-based.
82
- *
83
- * False — the re-encode branch: the output is labelled from the run's start on
84
- * the 0-based timeline, and the muxer is addressed in those same terms.
85
- *
86
- * One predicate for both callers, because the two used to answer it separately
87
- * and a disagreement between them is exactly what desynced picture from sound.
88
- *
89
- * @param {{ audioOnly?: boolean, timeline?: { cutGrid?: string }, transcodeVideo?: boolean }} material
90
- * @returns {boolean}
91
- */
92
- export function onKeyframeGridFor(material) {
93
- return material?.audioOnly === true
94
- ? material?.timeline?.cutGrid === "keyframe"
95
- : material?.transcodeVideo !== true;
96
- }
97
-
98
- /**
99
- * The boundary table the player is working from: the one its playlist was
100
- * written from, falling back to the live table when no playlist was built from
101
- * a table at all (no duration, so no synthetic playlist — and then nothing the
102
- * player holds contradicts it).
103
- *
104
- * @param {{ published?: number[], boundaries?: number[] }} timeline
105
- * @returns {number[]}
106
- */
107
- export function publishedGridFor(timeline) {
108
- return Array.isArray(timeline?.published) && timeline.published.length > 0
109
- ? timeline.published
110
- : (timeline?.boundaries ?? []);
111
- }
112
-
113
- /**
114
- * Where a run beginning at `index` must be positioned: the time the PLAYER was
115
- * told that segment starts at.
116
- *
117
- * Two tables, deliberately: the live one is corrected as produced segments
118
- * reveal where the file's cuts truly are, and those corrections are what let a
119
- * re-encoded rung be forced onto a copied stream's real grid. But the playlist
120
- * a player is holding was written once and never changes, so a position taken
121
- * from the corrected table describes a timeline nobody sent the player. That is
122
- * not a subtlety: it cost ten minutes of a dead film on 2026-08-17, the browser
123
- * asking for two segments 1908 times each.
124
- *
125
- * @param {{ published?: number[], boundaries?: number[] }} timeline
126
- * @param {number} index
127
- * @param {number} segmentDurationSec - Used only when the file has no table at
128
- * all, where a segment is a plain multiple of the nominal length.
129
- * @returns {number}
130
- */
131
- export function publishedStartTime(timeline, index, segmentDurationSec) {
132
- const published = Array.isArray(timeline?.published) && timeline.published.length > 0 ? timeline.published : null;
133
- const table = published ?? (Array.isArray(timeline?.boundaries) ? timeline.boundaries : []);
134
- if (table.length === 0) {
135
- return index * segmentDurationSec;
136
- }
137
- const clamped = Math.max(0, Math.min(index, table.length - 1));
138
- return table[clamped];
139
- }
140
-
141
- /**
142
- * Where a segment REALLY begins, when a produced piece has said so.
143
- *
144
- * The live table is corrected as produced pieces reveal where a file's cuts
145
- * truly are; the published one is what the player was told and may never move.
146
- * Where the two disagree, the live table is a measurement and the published one
147
- * a prediction — and this answers with the measurement, or with nothing when
148
- * they agree or nothing has been measured.
149
- *
150
- * @param {object} timeline
151
- * @param {number} index
152
- * @returns {number | undefined}
153
- */
154
- export function trueStartOf(timeline, index) {
155
- const live = Array.isArray(timeline?.boundaries) ? timeline.boundaries : null;
156
- const published = Array.isArray(timeline?.published) ? timeline.published : null;
157
- if (!live || !published || index < 0 || index >= live.length || index >= published.length) {
158
- return undefined;
159
- }
160
- return live[index] === published[index] ? undefined : live[index];
161
- }
162
-
163
- /**
164
- * The cut times to hand ffmpeg for a run that starts at `startIndex`.
165
- *
166
- * Two adjustments, both of which cost a broken session to learn:
167
- *
168
- * - **Rebased.** `-segment_times` is measured from the start of the run, not
169
- * of the file. Measured: starting at 12 s and asking for a cut at 18 s put
170
- * it at 29.4 s — 12 + 18. So every boundary has the run's own start
171
- * subtracted.
172
- * - **Interior only.** The first boundary is where the run begins and the last
173
- * is where the file ends; neither is a cut. Sending them would produce an
174
- * empty leading segment and a spurious trailing one.
175
- *
176
- * @param {number[]} boundaries
177
- * @param {number} startIndex
178
- * @returns {number[] | null}
179
- */
180
- export function segmentCutTimesFrom(boundaries, startIndex) {
181
- if (!Array.isArray(boundaries) || boundaries.length < 2) {
182
- return null;
183
- }
184
- const index = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
185
- if (index >= boundaries.length - 1) {
186
- return null;
187
- }
188
- const base = boundaries[index];
189
- const times = [];
190
- for (let at = index + 1; at < boundaries.length - 1; at += 1) {
191
- times.push(Number((boundaries[at] - base).toFixed(6)));
192
- }
193
- return times;
194
- }
195
-
196
- /**
197
- * The largest keyframe time that does not exceed `target`, from a SORTED
198
- * (ascending) array of keyframe times. Null when `target` is before the first
199
- * keyframe or the array is empty — the caller then falls back to its unsnapped
200
- * target.
201
- *
202
- * @param {number[]} keyframeTimes - Sorted ascending.
203
- * @param {number} target
204
- * @returns {number | null}
205
- */
206
- export function nearestKeyframeAtOrBefore(keyframeTimes, target) {
207
- let result = null;
208
- for (const time of keyframeTimes) {
209
- if (time > target) {
210
- break;
211
- }
212
- result = time;
213
- }
214
- return result;
215
- }
216
-
217
- /**
218
- * How much later than a keyframe to ASK, so that ffmpeg lands on that keyframe.
219
- *
220
- * Bounded by half the distance to the next keyframe, which matters only where
221
- * keyframes stand closer together than twice the offset. There no single value
222
- * can satisfy both worlds — asking too little lands a keyframe early when the
223
- * heuristic fires, asking too much lands a keyframe late when it does not — and
224
- * the bound picks the smaller error, which is then under one keyframe interval
225
- * and therefore under what a player bridges.
226
- *
227
- * @param {{ transcodeVideo?: boolean, audioOnly?: boolean, keyframes?: { times?: number[] | null, tolerance?: number } }} material
228
- * @param {number} keyframe - A real keyframe time the run is to begin at.
229
- * @returns {number} Seconds to add to the request.
230
- */
231
- export function seekLandingOffsetFor(material, keyframe) {
232
- // A re-encode trims to the requested time itself, so it needs no help and
233
- // must not be pushed past what it was asked for.
234
- //
235
- // AN OUTPUT CARRYING ONLY SOUND IS SUCH A RE-ENCODE, and asking about the
236
- // picture missed it: the flag says whether the PICTURE is re-encoded, and an
237
- // output with no picture answers no. So the whole offset was added to the
238
- // soundtrack's own seek, and the sound then played 130 ms ahead of the picture
239
- // from every restart onward — reported by the viewer 2026-09-06 as lips out of
240
- // step with the voice from two minutes in, and measured: both outputs were
241
- // repositioned to the same boundary and both given `-ss 166.963435`, after
242
- // which the copied picture landed on the keyframe at 166.833 while the
243
- // re-encoded sound began where it was asked.
244
- if (material?.transcodeVideo === true || material?.audioOnly === true) {
245
- return 0;
246
- }
247
- // A grid whose times are approximate needs that error added on top, or a name
248
- // sitting just below its real keyframe seeks to before it and lands on the
249
- // one before that. Only AVI declares one.
250
- const tolerance = Number.isFinite(material?.keyframes?.tolerance)
251
- ? Math.max(0, material.keyframes.tolerance)
252
- : 0;
253
- const wanted = SEEK_LANDING_OFFSET_SEC + tolerance;
254
- const times = Array.isArray(material?.keyframes?.times) ? material.keyframes.times : [];
255
- const next = times.find((time) => time > keyframe + 0.001);
256
- if (next === undefined) {
257
- return wanted;
258
- }
259
- return Math.min(wanted, (next - keyframe) / 2);
260
- }
261
-
262
- /**
263
- * Everything ffmpeg is told for one run.
264
- *
265
- * @param {object} params
266
- * @param {{ times?: number[] | null, tolerance?: number }} params.keyframes - Where
267
- * the PICTURE's file has its keyframes: what a seek snaps to. Plain values,
268
- * so this can be asked without a container, a torrent or a session.
269
- * @param {{ startTime: number }} params.inputFile - The file this run reads.
270
- * @param {{ startTime: number }} params.audioFile - The file the chosen
271
- * soundtrack lives in, which for a dub shipped beside the picture is not the
272
- * picture's own.
273
- * @param {string} params.inputUrl
274
- * @param {string} params.audioInputUrl - Empty unless a browser that takes its
275
- * audio muxed is watching a release whose soundtrack is a file of its own.
276
- * @param {{ published?: number[], boundaries?: number[], cutGrid?: string }} params.timeline
277
- * @param {{ encodeWidth: number, encodeHeight: number, outputFps: number, softwarePreset: string | null, applyTonemap: boolean }} params.output
278
- * @param {object} params.segmentFormat
279
- * @param {boolean} params.transcodeVideo
280
- * @param {boolean} params.transcodeAudio
281
- * @param {boolean} params.audioOnly - An audio rendition: one track, no picture.
282
- * @param {boolean} params.audioSeparate - The picture's sound is published as a
283
- * rendition, so this output carries none.
284
- * @param {number} params.audioSourceTrackIndex - `0:a:N` within its own file.
285
- * @param {{ maxrateKbps: number, bufsizeKbps: number, level: string | null } | null} params.rateControl
286
- * The output's own, as its key names it; null where nothing bounds the rate.
287
- * @param {number} params.startIndex - First segment number this run makes.
288
- * @param {number} params.endIndex - Last it makes, inclusive; below the start
289
- * means it has no end.
290
- * @param {number | undefined} params.positionSecondsOverride - Where to begin,
291
- * when the caller knows better than the table.
292
- * @param {object} params.videoEncoder
293
- * @param {number} params.segmentDurationSec
294
- * @returns {{ args: string[], safeIndex: number, startSeconds: number, cutTimes: number[] | null }}
295
- */
296
- export function buildRunCommand({
297
- keyframes,
298
- inputFile,
299
- audioFile,
300
- inputUrl,
301
- audioInputUrl: audioInputUrlGiven,
302
- timeline,
303
- output,
304
- segmentFormat,
305
- transcodeVideo,
306
- transcodeAudio,
307
- audioOnly,
308
- audioSeparate,
309
- audioSourceTrackIndex,
310
- rateControl,
311
- startIndex,
312
- endIndex,
313
- positionSecondsOverride,
314
- videoEncoder,
315
- segmentDurationSec
316
- }) {
317
- const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
318
- // 0-based output time of this segment, from the table the PLAYER holds —
319
- // the same one the cut list below is taken from.
320
- //
321
- // These two were read from different tables until 2026-08-21, and that is
322
- // one fault, not two: `-segment_times` are measured from wherever the run
323
- // really began, so any distance between the position and the cut list moves
324
- // EVERY cut of that run by it. The live table keeps being corrected as
325
- // produced segments reveal where the file's cuts truly are, and those
326
- // corrections run backwards, so each restart began a little earlier than
327
- // the grid the cuts were stated on — and since the corrections accumulate,
328
- // so did the distance. Measured on `JUFD665.mp4`: after one seek restart a
329
- // produced segment held the boundary two places before its own number
330
- // (16.684 s, exactly 2.0000 segments), after the next it held the one four
331
- // places before (33.5 s). The player's buffer then stops extending at all,
332
- // because the content of every fragment lands before the time its playlist
333
- // entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far`
334
- // warnings until hls.js gave up and jumped the viewer 16.8 s forward.
335
- //
336
- // 2.45.0 moved the CUT LIST onto the published table for this same reason
337
- // and left the position on the live one. Both belong on the published
338
- // table: a run must begin where the player was told the segment begins.
339
- const startSeconds = Number.isFinite(positionSecondsOverride)
340
- ? positionSecondsOverride
341
- : publishedStartTime(timeline, safeIndex, segmentDurationSec);
342
- // Where each of the two timelines begins, asked of the files themselves.
343
- // Fresh by construction: the session may have been created before the
344
- // soundtrack file's header could be read, and the reading lands on the file
345
- // object this session holds — so there is nothing to re-read and nothing
346
- // that can be stale. Same property as the keyframe table, which is one
347
- // object shared by every session of the file.
348
- // Which timeline this run works on. Asked once, because the closure that
349
- // adds the second input reads it too, and two readings of one predicate is
350
- // how the picture and the sound came apart before.
351
- const keyframeGrid = onKeyframeGridFor({ audioOnly, timeline, transcodeVideo });
352
- const servesAudioSeparately = audioOnly !== true && audioSeparate === true;
353
- const audioFileStartTime = audioFile.startTime;
354
- // The start time of the file this run READS, which is the picture's own for
355
- // every session except one whose soundtrack is a separate file.
356
- const sourceStartTime = inputFile.startTime;
357
- // Cut where this session's grid says, whoever is producing the frames. The
358
- // times are measured from the start of THIS run; the same list serves as
359
- // the cut points and, when re-encoding, as the keyframes to force — one
360
- // list, so the two cannot drift apart.
361
- const explicitTimes = segmentFormat.explicitTimesMuxerArgs?.() ?? null;
362
- // Matroska seeks begin without the preceding decode timestamps. Copy one
363
- // preceding cut privately so reordered frames have established DTS before
364
- // the first requested cut. It is never published over another run's output.
365
- const inputIndex = !transcodeVideo && !audioOnly && explicitTimes && safeIndex > 0
366
- ? safeIndex - 1 : safeIndex;
367
- // A COPY is cut by this list whatever grid it ended up on. Even when no
368
- // keyframe index could be read and the boundaries are a plain grid, saying
369
- // them outright is what keeps the playlist and the muxer agreeing — ffmpeg
370
- // moves each cut forward to the first real keyframe, and serving reads back
371
- // where the piece truly begins. Requiring a keyframe grid here dropped a
372
- // copy with no index onto the `hls` muxer, which takes no cut list and
373
- // writes no self-contained pieces, so nothing could read a true start and
374
- // segments were stamped with times the file does not have — the 4.17 s
375
- // speech-against-subtitles drift, back again.
376
- //
377
- // Cut on the grid the PLAYER WAS GIVEN, not on the corrected one. A player
378
- // places a fragment by the playlist it holds, and that text was written
379
- // once and never changes; the live table keeps moving as produced segments
380
- // reveal where the file's cuts really are. Cutting on the moved table makes
381
- // every run faithful to a timeline nobody sent the player — measured
382
- // 2026-08-20, the picture's segments arriving a uniform 2.002 s before the
383
- // times the playlist named for them, which is four times what hls.js will
384
- // bridge, so the fragment does not land and is asked for again.
385
- //
386
- // The corrections keep their purpose: they describe the file, and a variant
387
- // created later inherits the corrected table and PUBLISHES it, so its own
388
- // playlist and its own cuts agree from the start. What they may not do is
389
- // move the cuts of a session whose playlist is already being read.
390
- const gridCutTimes = explicitTimes && (!transcodeVideo || timeline.cutGrid === "keyframe")
391
- ? segmentCutTimesFrom(publishedGridFor(timeline), inputIndex)
392
- : null;
393
- // Cut times are stated on the grid, for both branches.
394
- //
395
- // 2.28.0 added `sourceStartTime` to them on the copy branch, reasoning that
396
- // the muxer decides its cuts before the output is relabelled. The field
397
- // measured it the next session and the reasoning was wrong: of 75 pieces
398
- // the picture produced, only NINE began at a time the container's own
399
- // keyframe table names (the soundtrack, untouched by the change, scored 70
400
- // of 75). Before it, every piece began exactly on a named keyframe and it
401
- // was the PLAYLIST that disagreed with them. So the shift moved the cuts
402
- // OFF the keyframes rather than onto them, and it is gone.
403
- //
404
- // What remains true, and is what that measurement is really about: the
405
- // picture cuts where the source's keyframes are, and the playlist must be
406
- // built from those same times. That is the correction path's job, not the
407
- // cut list's.
408
- const cutTimes = gridCutTimes;
409
-
410
- // Video: re-encode only when required, using the detected encoder
411
- // (hardware-accelerated or software). The descriptor builds the filter +
412
- // codec args (including keyframe alignment on segment boundaries).
413
- const videoCodecArgs = transcodeVideo
414
- ? videoEncoder.buildVideoArgs({
415
- // Budget-selected encode box (may be below the client target on weak
416
- // software hosts); falls back to the client target for hardware.
417
- targetWidth: output.encodeWidth,
418
- targetHeight: output.encodeHeight,
419
- segmentDurationSec: segmentDurationSec,
420
- // Source-inherited output rate (integer, capped); descriptors that
421
- // use time-based keyframes just apply it as the frame rate.
422
- fps: output.outputFps,
423
- // Software-only; hardware descriptors ignore it.
424
- preset: output.softwarePreset ?? undefined,
425
- // HDR→SDR tone map (software path only; gated on filter availability).
426
- tonemap: output.applyTonemap === true,
427
- // On the source's grid the cuts are not evenly spaced, so no frame
428
- // count can describe them: the encoder is told the times outright,
429
- // the same ones the muxer will cut at.
430
- forcedKeyframeTimes: cutTimes,
431
- // The output's own rate control and level. Read off the output, whose
432
- // key names them: two limits are two outputs, and nothing moves a
433
- // limit under an output that already exists.
434
- rateControl: rateControl ?? null
435
- })
436
- : ["-c:v", "copy"];
437
- const audioCodecArgs = transcodeAudio
438
- ? ["-c:a", "aac", "-ac", "2", "-b:a", `${AUDIO_TRANSCODE_KBPS}k`]
439
- : ["-c:a", "copy"];
440
-
441
- const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
442
- // Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
443
- // only applies when we actually re-encode the video track.
444
- if (transcodeVideo && Array.isArray(videoEncoder.inputArgs)) {
445
- args.push(...videoEncoder.inputArgs);
446
- }
447
- // Seek position in SOURCE time. On the keyframe grid `startSeconds` is a
448
- // real keyframe's offset from zero, so the container's own start time goes
449
- // back on to reach it; on the uniform grid it is a plain offset. This
450
- // follows the GRID, not whether the video is re-encoded — a variant cut on
451
- // the source's keyframes has to seek to them like the copy it accompanies.
452
- const inputStartSeconds = inputIndex === safeIndex ? startSeconds :
453
- publishedStartTime(timeline, inputIndex, segmentDurationSec);
454
- const seekSeconds = timeline.cutGrid === "keyframe"
455
- ? inputStartSeconds + sourceStartTime
456
- : inputStartSeconds;
457
- // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
458
- // keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
459
- // not the container's own on-the-fly seek/index) and trim the short
460
- // residual (bounded by the keyframe interval) precisely AFTER -i, which is
461
- // always frame-accurate regardless of -accurate_seek.
462
- //
463
- // Root cause this works around: `-accurate_seek -ss X` before -i trusts the
464
- // CONTAINER's own seek to land near X. For some containers (observed: AVI
465
- // with VBR MP3 audio) that on-the-fly seek can point at a position with no
466
- // valid frame boundary at all — ffmpeg fails outright ("Seek failed" /
467
- // "Header missing"), not just imprecisely, and repeatedly so since every
468
- // retry re-tries the SAME bad container-computed position. A keyframe we
469
- // read directly from the packet list is a position ffmpeg has already
470
- // proven it can decode.
471
- //
472
- // The keyframe is CARRIED from the table that named this boundary, not looked
473
- // up by value. A boundary is stored on the player's clock, rounded, and
474
- // `seekSeconds` above puts the container's start time back on to reach the
475
- // file's clock — a lossy round trip. A keyframe at 26.234 s in a container
476
- // starting at 0.083 s comes back as 26.233999999999998, and "the keyframe at
477
- // or before that" is then the PREVIOUS one, 8.717 s earlier: two parts in a
478
- // quadrillion, one whole keyframe interval. That interval became a trim, the
479
- // trim moved every cut of the run backwards by another interval, and the
480
- // run's files were numbered from #36 while carrying film 17.4 s before what
481
- // the playlist says #36 holds (field 2026-09-05: the picture covered the
482
- // playhead, the sound had a 17.4 s hole across it, and the viewer waited two
483
- // minutes for a buffer that could never fill).
484
- //
485
- // The search stays for a grid restored without its source clock, which is the
486
- // only case that has nothing to carry.
487
- const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
488
- ? timeline.sourceStartOf(inputIndex)
489
- : null;
490
- const snappedKeyframe = Number.isFinite(carriedKeyframe)
491
- ? carriedKeyframe
492
- : (Array.isArray(keyframes?.times) && keyframes.times.length > 0
493
- ? nearestKeyframeAtOrBefore(keyframes.times, seekSeconds)
494
- : null);
495
- // A second input, and it exists for exactly one case: a browser that takes
496
- // its audio muxed into the picture, watching a release whose soundtrack is a
497
- // file of its own. An audio RENDITION reads that file as its only input and
498
- // has none of this — which is why the ordinary path, and every browser that
499
- // understands rendition groups, still runs on a single input.
500
- const audioInputUrl =
501
- typeof audioInputUrlGiven === "string" && audioInputUrlGiven.length > 0
502
- ? audioInputUrlGiven
503
- : "";
504
- // Where the picture's own start sits on the soundtrack file's timeline. Both
505
- // files begin at their own container start time, and those need not be the
506
- // same number; the difference is what keeps the two aligned.
507
- const audioTimelineShift = audioInputUrl
508
- ? audioFileStartTime - sourceStartTime
509
- : 0;
510
- /**
511
- * Add the second input, if there is one, with its own seek.
512
- *
513
- * Called between the first `-i` and any OUTPUT option, because ffmpeg reads
514
- * these positionally: an option written after the last `-i` applies to the
515
- * output, and the residual seek below is exactly such an option. Getting the
516
- * order wrong would silently turn the audio file's seek into a trim of the
517
- * finished stream.
518
- *
519
- * @param {number} inputSeekSeconds - Where to start, on the PICTURE's
520
- * timeline. Translated to the soundtrack file's own here.
521
- */
522
- const pushAudioInput = (inputSeekSeconds) => {
523
- if (!audioInputUrl) {
524
- return;
525
- }
526
- // `-itsoffset` states the soundtrack's timestamps on the picture's
527
- // timeline, so everything after this point — `-copyts`, the output offset,
528
- // the cut list — goes on treating the two as one timeline, unchanged.
529
- //
530
- // ONLY on the branch that keeps the source's own timestamps. Without
531
- // `-copyts` ffmpeg rebases each input from its own seek point, and both
532
- // inputs are seeked to the same instant just below — so the two are
533
- // already aligned and adding the offset would pull them apart by exactly
534
- // the amount it exists to remove.
535
- if (audioTimelineShift !== 0 && keyframeGrid) {
536
- args.push("-itsoffset", ffmpegSeconds(-audioTimelineShift));
537
- }
538
- const audioSeek = Math.max(0, inputSeekSeconds + audioTimelineShift);
539
- if (audioSeek > 0) {
540
- // No keyframe to snap to and none needed: every audio frame is a sync
541
- // point, so the seek can be accurate outright.
542
- args.push("-accurate_seek", "-ss", ffmpegSeconds(audioSeek));
543
- }
544
- args.push("-i", audioInputUrl);
545
- };
546
-
547
- if (snappedKeyframe !== null) {
548
- const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
549
- if (snappedKeyframe > 0) {
550
- args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, keyframes }, snappedKeyframe)));
551
- }
552
- args.push("-i", inputUrl);
553
- // The coarse landing, not the exact target: the residual below is discarded
554
- // from the OUTPUT and so takes the same slice off every stream. Seeking the
555
- // soundtrack to the exact target as well would take that slice twice and
556
- // leave the sound running ahead of the picture by it.
557
- pushAudioInput(snappedKeyframe);
558
- // An output-side trim, and ONLY where the output is labelled from zero.
559
- //
560
- // Beside `-copyts` it does the opposite of what it says. Measured
561
- // 2026-09-06 on a file with a 5 s keyframe interval: a run landed at 15 s
562
- // and asked to trim to the cut at 20 s produced its first file starting at
563
- // 10 s, and a run asked to trim to 17 s produced cuts at 13.129, 18.129,
564
- // 23.129 — the whole grid moved back by the trim itself. The muxer's cut
565
- // times are absolute under `-copyts` while the trim is not, so every cut of
566
- // the run inherits the difference and the numbering, fixed at spawn, is
567
- // wrong by however many cuts that is.
568
- //
569
- // On the keyframe grid the run begins AT a cut, so there is nothing to
570
- // trim: the carried keyframe above makes this exactly zero rather than
571
- // nearly zero.
572
- if (residualSeconds > 0 && !keyframeGrid) {
573
- args.push("-ss", ffmpegSeconds(residualSeconds));
574
- }
575
- } else {
576
- if (seekSeconds > 0) {
577
- // No keyframe map (probe failed/timed out) — fall back to the previous
578
- // behaviour: trust the container's own accurate seek.
579
- args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
580
- }
581
- args.push("-i", inputUrl);
582
- pushAudioInput(seekSeconds);
583
- }
584
- // Which timeline the output is labelled on. An audio rendition has no
585
- // picture of its own to follow, so it follows the grid it was given — the
586
- // same one the video it plays with is on. Deciding by `transcodeVideo`, as
587
- // everything else here does, would put the audio of a re-encoded stream on
588
- // the copy branch: `-copyts` and a shift by the container's start time,
589
- // against a picture labelled from zero. The two would be offset by
590
- // `sourceStartTime` for the whole file.
591
- if (!keyframeGrid) {
592
- // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
593
- // segment grid; relabel output onto the original timeline so segment N
594
- // carries PTS = N × segmentDuration.
595
- if (startSeconds > 0) {
596
- args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
597
- }
598
- } else {
599
- // Branch B (video copied — only audio is transcoded): we cannot insert
600
- // keyframes, so segments are cut at the source's own keyframes (the
601
- // playlist boundaries were built from those keyframes). Keep the source's
602
- // real timestamps (`-copyts`) so copied frames stay continuous across
603
- // boundaries/seeks, and shift by -startTime so the output timeline is
604
- // 0-based (a non-zero container start otherwise puts a hole at the very
605
- // beginning and desyncs audio/video). Audio is transcoded on this timeline.
606
- args.push("-copyts");
607
- if (sourceStartTime !== 0) {
608
- args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
609
- }
610
- }
611
- // Where this run STOPS. Until now a run had a start and no end — neither
612
- // `-to` nor `-t` appeared anywhere in the arguments this proxy builds — so
613
- // every stop was a kill from outside, and two runs on one output could only
614
- // be kept apart by giving each its own directory. With an end they cannot
615
- // reach each other's numbers at all, and a run that finishes its stretch
616
- // exits by itself instead of having to be noticed and killed.
617
- //
618
- // WHICH argument states it is a property of the branch, and it is measured
619
- // rather than reasoned (2026-09-04, `research/encoder-layer-2026-09-04.md`
620
- // §11): `-t` is a duration on the output's own clock, and `-to` a point on
621
- // the input's. The copy branch runs with `-copyts`, where the input's clock
622
- // IS the source's, so `-to` takes the absolute time; the re-encode branch
623
- // has no `-copyts` and takes the duration. Swapping them is not a near
624
- // miss — on the copy branch `-t` produced one segment where five were
625
- // wanted, because the time it names is already past when the run starts.
626
- const runEnd = Number.isInteger(endIndex) ? endIndex : -1;
627
- const publishedGrid = publishedGridFor(timeline);
628
- if (runEnd >= safeIndex && Array.isArray(publishedGrid) && publishedGrid[runEnd + 1] > 0) {
629
- const endsAt = publishedGrid[runEnd + 1];
630
- if (transcodeVideo) {
631
- args.push("-t", ffmpegSeconds(Math.max(0.1, endsAt - publishedGrid[safeIndex])));
632
- } else {
633
- args.push("-to", ffmpegSeconds(endsAt));
634
- }
635
- }
636
- if (audioOnly === true) {
637
- // An audio RENDITION: one track, no picture. Published as its own
638
- // `#EXT-X-MEDIA` and shared by every video variant, so the track is
639
- // encoded once for the file instead of once per rung, and changing it is
640
- // the player switching rendition rather than this proxy rebuilding the
641
- // session. Cut on the same grid as the video it accompanies, which is
642
- // what lets the two be played together.
643
- // `0:` because a rendition's only input IS the file its track lives in —
644
- // the picture's own file, or the one beside it that carries this dub.
645
- args.push("-vn", "-map", `0:a:${audioSourceTrackIndex}?`, ...audioCodecArgs);
646
- } else if (servesAudioSeparately) {
647
- // The other half of the same arrangement: the picture alone, because its
648
- // audio is published as a rendition and would otherwise play twice.
649
- args.push("-an", "-map", "0:v:0?", ...videoCodecArgs);
650
- } else {
651
- args.push(
652
- "-map",
653
- "0:v:0?",
654
- "-map",
655
- // The audio track the viewer chose: input 1 when their choice is a
656
- // soundtrack shipped as its own file, input 0 when it is one of the
657
- // picture's own. Type-relative within that input, which is what
658
- // `audioSourceTrackIndex` holds — the number the browser sent is flat
659
- // across both files and was resolved when the session was made.
660
- `${audioInputUrl ? 1 : 0}:a:${audioSourceTrackIndex}?`,
661
- ...videoCodecArgs,
662
- ...audioCodecArgs
663
- );
664
- }
665
-
666
- // Where the cuts come from. On the copy path they are the source's own
667
- // keyframes, and until now they were only ever GUESSED: ffmpeg got a target
668
- // duration and chose its own cut points, while the playlist was built from
669
- // the container index — two independent calculations with nothing tying
670
- // them together but the hope that they agree. They do not. The index is a
671
- // navigation table and is not obliged to list every keyframe; for a field
672
- // file it held 1902 while ffmpeg found roughly twice as many and cut twice
673
- // as often. Segment #876 then meant 1:26:50 to the player and about minute
674
- // 58 to ffmpeg, which is why a seek landed nowhere near where it was aimed
675
- // and the reported duration drifted.
676
- //
677
- // So stop guessing and say it: the `segment` muxer takes the list of times
678
- // outright. Passing the very boundaries the playlist was built from makes
679
- // the two agree by construction. Only cut points already known to be real
680
- // keyframes are sent, so ffmpeg never has to move one forward.
681
- //
682
- // The list is built above, before the encoder args, because a re-encoded
683
- // variant of a copied stream needs the same times twice over: once as the
684
- // cuts, once as the keyframes to force at them.
685
- if (cutTimes && cutTimes.length > 0) {
686
- args.push(
687
- "-f",
688
- "segment",
689
- // Times are measured from the START OF THIS RUN, not from the start of
690
- // the file — verified: starting at 12 s and asking for a cut at 18 s
691
- // produced one at 29.4 s. `segmentCutTimesFrom` rebases them.
692
- "-segment_times",
693
- cutTimes.join(","),
694
- // A cut lands on the first keyframe at or after its time, so a boundary
695
- // recorded a hair late would skip to the next one and double the
696
- // segment. The tolerance absorbs that rounding.
697
- "-segment_time_delta",
698
- String(SEGMENT_CUT_TIME_DELTA_SECONDS),
699
- "-segment_start_number",
700
- String(inputIndex),
701
- // THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
702
- //
703
- // Measured on the addon host 2026-09-05: a name appears in this list when
704
- // the file is CLOSED, not when it is created — at the third sample
705
- // `seg-000.mp4` was on disk and absent from the list, and it appeared at
706
- // the fourth, in the same moment `seg-001.mp4` came into being. So a name
707
- // here is the writer's own statement that the piece is whole.
708
- //
709
- // Without it, a finished file is indistinguishable from one still being
710
- // written, and the only proof available was the existence of the NEXT
711
- // one — which never comes for the last piece of every run.
712
- "-segment_list",
713
- "pipe:3",
714
- "-segment_list_type",
715
- "csv",
716
- "-segment_list_flags",
717
- "+live",
718
- ...explicitTimes,
719
- // UNDER A WORKING NAME, not the one it is served as. A piece under its
720
- // served name is complete by construction then, whoever else is writing
721
- // into the same directory — and the name arrives on the channel above the
722
- // instant ffmpeg closes it, which is what turns it into the served one.
723
- //
724
- // The other branch needs none of this: the HLS muxer writes through a
725
- // temporary name of its own (`+temp_file`), so its files appear under
726
- // their final name whole.
727
- //
728
- // TAGGED WITH THE STRETCH IT WAS GIVEN, which names the run without any
729
- // counter to keep: intervals never overlap, so two live runs of one output
730
- // begin at different numbers by construction. That is what makes clearing
731
- // up after a dead run a well-formed question — its unfinished pieces are
732
- // the ones carrying its own tag — where before it was answered by taking
733
- // the highest SERVED name inside its stretch and judging the bytes.
734
- segmentFormat.makingFileNameTemplate(String(safeIndex))
735
- );
736
- } else {
737
- args.push(
738
- "-f",
739
- "hls",
740
- "-hls_time",
741
- String(segmentDurationSec),
742
- "-hls_list_size",
743
- "0",
744
- "-hls_flags",
745
- "independent_segments+temp_file",
746
- // Container selection + segment naming, from the active format module.
747
- ...segmentFormat.muxerArgs(),
748
- "-start_number",
749
- String(safeIndex),
750
- // ffmpeg writes its own playlist here; we ignore it and serve the
751
- // synthetic VOD playlist instead (see getFileStream).
752
- PLAYLIST_FILE_NAME
753
- );
754
- }
755
- return { args, safeIndex, inputIndex, startSeconds, cutTimes };
756
-
757
- }
1
+ /**
2
+ * @file The full argument list for one encoder run.
3
+ *
4
+ * What a run is given is a fact about WHAT is being produced and WHERE it
5
+ * begins, and about nothing else — not the session it belongs to, not who is
6
+ * watching, not how many viewers there are. It was a 377-line method of the
7
+ * session manager reading fifteen of its fields, which is why a run could only
8
+ * ever be built by that class, for the one session it holds.
9
+ *
10
+ * Stated here, over the material and the stretch alone, a run can be built by
11
+ * whoever needs one. That is what lets an output have more than a single
12
+ * encoder.
13
+ *
14
+ * **Nothing in this file runs anything.** It returns an argument list; spawning,
15
+ * killing and resuming belong to whoever owns the process.
16
+ */
17
+
18
+ import { PLAYLIST_FILE_NAME } from "./output/playlists.js";
19
+ import { AUDIO_TRANSCODE_KBPS } from "./args.js";
20
+ import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
21
+
22
+ /**
23
+ * What ffmpeg's own CLI subtracts from an input seek, and therefore what has to
24
+ * be added back to land where we asked.
25
+ *
26
+ * `fftools/ffmpeg_demux.c`, in `ifile_open`: when the container does not
27
+ * declare `AVFMT_SEEK_TO_PTS` — Matroska does not — and any stream carries
28
+ * B-frames, the seek target is moved back by `3*AV_TIME_BASE / 23` before
29
+ * `avformat_seek_file` is called. Its purpose is sound: such containers seek in
30
+ * decode order while the caller asks in presentation order, and with B-frames
31
+ * the two differ, so it backs off far enough to be sure of reaching the frame
32
+ * asked for.
33
+ *
34
+ * The consequence for a COPY is that asking for a keyframe lands on the one
35
+ * BEFORE it — deterministically, every time. Measured 2026-08-21 on a Matroska
36
+ * file with keyframes every 2 s: `-ss 10` produced a first segment starting at
37
+ * 8.000; `-ss 10.130435` produced one starting at 10.000. On MP4, where the
38
+ * heuristic does not fire, all of 10, 10.130435 and 10.2 produced 10.000 — so
39
+ * adding this is right in one case and harmless in the other.
40
+ *
41
+ * That landing is what `-segment_times` is measured from, while this code
42
+ * computes those offsets from the time it ASKED for. One keyframe interval
43
+ * apart, inherited by every cut of the run: 119 of 125 segments arriving a
44
+ * uniform 2.002 s early in the field, four times what a player bridges.
45
+ *
46
+ * Not applied when the picture is re-encoded: a re-encode decodes from the
47
+ * keyframe and discards frames up to the requested time, so its output already
48
+ * begins exactly where asked (measured the same day: `-ss 11` copied starts at
49
+ * 10.000, re-encoded at 11.000).
50
+ */
51
+ export const SEEK_LANDING_OFFSET_SEC = 3 / 23;
52
+
53
+ /**
54
+ * A number of seconds as ffmpeg will accept it.
55
+ *
56
+ * `String(n)` switches to exponential notation below 1e-6, and ffmpeg's
57
+ * duration parser rejects that outright: a field session died on
58
+ * `Invalid duration for option ss: 3.3333333249174757e-7`, after which the
59
+ * transcode was in state `failed` and every segment request answered 500 for
60
+ * as long as the viewer kept trying. Anything under a millisecond is also not a
61
+ * real offset — it is the residue of subtracting two nearly equal floats — so
62
+ * it is dropped rather than passed on.
63
+ *
64
+ * @param {number} value
65
+ * @returns {string}
66
+ */
67
+ export function ffmpegSeconds(value) {
68
+ if (!Number.isFinite(value) || Math.abs(value) < 0.001) {
69
+ return "0";
70
+ }
71
+ // Microsecond resolution, fixed notation, no trailing zero noise.
72
+ return value.toFixed(6).replace(/\.?0+$/, "");
73
+ }
74
+
75
+ /**
76
+ * Which timeline an output's own ffmpeg works on.
77
+ *
78
+ * True — the COPY branch: the source's timestamps are kept (`-copyts`) and the
79
+ * output is re-labelled 0-based. Everything handed to the muxer is therefore
80
+ * stated in the source's terms, and everything read back out of a produced
81
+ * piece is 0-based.
82
+ *
83
+ * False — the re-encode branch: the output is labelled from the run's start on
84
+ * the 0-based timeline, and the muxer is addressed in those same terms.
85
+ *
86
+ * One predicate for both callers, because the two used to answer it separately
87
+ * and a disagreement between them is exactly what desynced picture from sound.
88
+ *
89
+ * @param {{ audioOnly?: boolean, timeline?: { cutGrid?: string }, transcodeVideo?: boolean }} material
90
+ * @returns {boolean}
91
+ */
92
+ export function onKeyframeGridFor(material) {
93
+ return material?.audioOnly === true
94
+ ? material?.timeline?.cutGrid === "keyframe"
95
+ : material?.transcodeVideo !== true;
96
+ }
97
+
98
+ /**
99
+ * The boundary table the player is working from: the one its playlist was
100
+ * written from, falling back to the live table when no playlist was built from
101
+ * a table at all (no duration, so no synthetic playlist — and then nothing the
102
+ * player holds contradicts it).
103
+ *
104
+ * @param {{ published?: number[], boundaries?: number[] }} timeline
105
+ * @returns {number[]}
106
+ */
107
+ export function publishedGridFor(timeline) {
108
+ return Array.isArray(timeline?.published) && timeline.published.length > 0
109
+ ? timeline.published
110
+ : (timeline?.boundaries ?? []);
111
+ }
112
+
113
+ /**
114
+ * Where a run beginning at `index` must be positioned: the time the PLAYER was
115
+ * told that segment starts at.
116
+ *
117
+ * Two tables, deliberately: the live one is corrected as produced segments
118
+ * reveal where the file's cuts truly are, and those corrections are what let a
119
+ * re-encoded rung be forced onto a copied stream's real grid. But the playlist
120
+ * a player is holding was written once and never changes, so a position taken
121
+ * from the corrected table describes a timeline nobody sent the player. That is
122
+ * not a subtlety: it cost ten minutes of a dead film on 2026-08-17, the browser
123
+ * asking for two segments 1908 times each.
124
+ *
125
+ * @param {{ published?: number[], boundaries?: number[] }} timeline
126
+ * @param {number} index
127
+ * @param {number} segmentDurationSec - Used only when the file has no table at
128
+ * all, where a segment is a plain multiple of the nominal length.
129
+ * @returns {number}
130
+ */
131
+ export function publishedStartTime(timeline, index, segmentDurationSec) {
132
+ const published = Array.isArray(timeline?.published) && timeline.published.length > 0 ? timeline.published : null;
133
+ const table = published ?? (Array.isArray(timeline?.boundaries) ? timeline.boundaries : []);
134
+ if (table.length === 0) {
135
+ return index * segmentDurationSec;
136
+ }
137
+ const clamped = Math.max(0, Math.min(index, table.length - 1));
138
+ return table[clamped];
139
+ }
140
+
141
+ /**
142
+ * Where a segment REALLY begins, when a produced piece has said so.
143
+ *
144
+ * The live table is corrected as produced pieces reveal where a file's cuts
145
+ * truly are; the published one is what the player was told and may never move.
146
+ * Where the two disagree, the live table is a measurement and the published one
147
+ * a prediction — and this answers with the measurement, or with nothing when
148
+ * they agree or nothing has been measured.
149
+ *
150
+ * @param {object} timeline
151
+ * @param {number} index
152
+ * @returns {number | undefined}
153
+ */
154
+ export function trueStartOf(timeline, index) {
155
+ const live = Array.isArray(timeline?.boundaries) ? timeline.boundaries : null;
156
+ const published = Array.isArray(timeline?.published) ? timeline.published : null;
157
+ if (!live || !published || index < 0 || index >= live.length || index >= published.length) {
158
+ return undefined;
159
+ }
160
+ return live[index] === published[index] ? undefined : live[index];
161
+ }
162
+
163
+ /**
164
+ * The cut times to hand ffmpeg for a run that starts at `startIndex`.
165
+ *
166
+ * Two adjustments, both of which cost a broken session to learn:
167
+ *
168
+ * - **Rebased.** `-segment_times` is measured from the start of the run, not
169
+ * of the file. Measured: starting at 12 s and asking for a cut at 18 s put
170
+ * it at 29.4 s — 12 + 18. So every boundary has the run's own start
171
+ * subtracted.
172
+ * - **Interior only.** The first boundary is where the run begins and the last
173
+ * is where the file ends; neither is a cut. Sending them would produce an
174
+ * empty leading segment and a spurious trailing one.
175
+ *
176
+ * @param {number[]} boundaries
177
+ * @param {number} startIndex
178
+ * @returns {number[] | null}
179
+ */
180
+ export function segmentCutTimesFrom(boundaries, startIndex) {
181
+ if (!Array.isArray(boundaries) || boundaries.length < 2) {
182
+ return null;
183
+ }
184
+ const index = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
185
+ if (index >= boundaries.length - 1) {
186
+ return null;
187
+ }
188
+ const base = boundaries[index];
189
+ const times = [];
190
+ for (let at = index + 1; at < boundaries.length - 1; at += 1) {
191
+ times.push(Number((boundaries[at] - base).toFixed(6)));
192
+ }
193
+ return times;
194
+ }
195
+
196
+ /**
197
+ * The largest keyframe time that does not exceed `target`, from a SORTED
198
+ * (ascending) array of keyframe times. Null when `target` is before the first
199
+ * keyframe or the array is empty — the caller then falls back to its unsnapped
200
+ * target.
201
+ *
202
+ * @param {number[]} keyframeTimes - Sorted ascending.
203
+ * @param {number} target
204
+ * @returns {number | null}
205
+ */
206
+ export function nearestKeyframeAtOrBefore(keyframeTimes, target) {
207
+ let result = null;
208
+ for (const time of keyframeTimes) {
209
+ if (time > target) {
210
+ break;
211
+ }
212
+ result = time;
213
+ }
214
+ return result;
215
+ }
216
+
217
+ /**
218
+ * How much later than a keyframe to ASK, so that ffmpeg lands on that keyframe.
219
+ *
220
+ * Bounded by half the distance to the next keyframe, which matters only where
221
+ * keyframes stand closer together than twice the offset. There no single value
222
+ * can satisfy both worlds — asking too little lands a keyframe early when the
223
+ * heuristic fires, asking too much lands a keyframe late when it does not — and
224
+ * the bound picks the smaller error, which is then under one keyframe interval
225
+ * and therefore under what a player bridges.
226
+ *
227
+ * @param {{ transcodeVideo?: boolean, audioOnly?: boolean, keyframes?: { times?: number[] | null, tolerance?: number } }} material
228
+ * @param {number} keyframe - A real keyframe time the run is to begin at.
229
+ * @returns {number} Seconds to add to the request.
230
+ */
231
+ export function seekLandingOffsetFor(material, keyframe) {
232
+ // A re-encode trims to the requested time itself, so it needs no help and
233
+ // must not be pushed past what it was asked for.
234
+ //
235
+ // AN OUTPUT CARRYING ONLY SOUND IS SUCH A RE-ENCODE, and asking about the
236
+ // picture missed it: the flag says whether the PICTURE is re-encoded, and an
237
+ // output with no picture answers no. So the whole offset was added to the
238
+ // soundtrack's own seek, and the sound then played 130 ms ahead of the picture
239
+ // from every restart onward — reported by the viewer 2026-09-06 as lips out of
240
+ // step with the voice from two minutes in, and measured: both outputs were
241
+ // repositioned to the same boundary and both given `-ss 166.963435`, after
242
+ // which the copied picture landed on the keyframe at 166.833 while the
243
+ // re-encoded sound began where it was asked.
244
+ if (material?.transcodeVideo === true || material?.audioOnly === true) {
245
+ return 0;
246
+ }
247
+ // A grid whose times are approximate needs that error added on top, or a name
248
+ // sitting just below its real keyframe seeks to before it and lands on the
249
+ // one before that. Only AVI declares one.
250
+ const tolerance = Number.isFinite(material?.keyframes?.tolerance)
251
+ ? Math.max(0, material.keyframes.tolerance)
252
+ : 0;
253
+ const wanted = SEEK_LANDING_OFFSET_SEC + tolerance;
254
+ const times = Array.isArray(material?.keyframes?.times) ? material.keyframes.times : [];
255
+ const next = times.find((time) => time > keyframe + 0.001);
256
+ if (next === undefined) {
257
+ return wanted;
258
+ }
259
+ return Math.min(wanted, (next - keyframe) / 2);
260
+ }
261
+
262
+ /**
263
+ * Everything ffmpeg is told for one run.
264
+ *
265
+ * @param {object} params
266
+ * @param {{ times?: number[] | null, tolerance?: number }} params.keyframes - Where
267
+ * the PICTURE's file has its keyframes: what a seek snaps to. Plain values,
268
+ * so this can be asked without a container, a torrent or a session.
269
+ * @param {{ startTime: number }} params.inputFile - The file this run reads.
270
+ * @param {{ startTime: number }} params.audioFile - The file the chosen
271
+ * soundtrack lives in, which for a dub shipped beside the picture is not the
272
+ * picture's own.
273
+ * @param {string} params.inputUrl
274
+ * @param {string} params.audioInputUrl - Empty unless a browser that takes its
275
+ * audio muxed is watching a release whose soundtrack is a file of its own.
276
+ * @param {{ published?: number[], boundaries?: number[], cutGrid?: string }} params.timeline
277
+ * @param {{ encodeWidth: number, encodeHeight: number, outputFps: number, softwarePreset: string | null, applyTonemap: boolean }} params.output
278
+ * @param {object} params.segmentFormat
279
+ * @param {boolean} params.transcodeVideo
280
+ * @param {boolean} params.transcodeAudio
281
+ * @param {boolean} params.audioOnly - An audio rendition: one track, no picture.
282
+ * @param {boolean} params.audioSeparate - The picture's sound is published as a
283
+ * rendition, so this output carries none.
284
+ * @param {number} params.audioSourceTrackIndex - `0:a:N` within its own file.
285
+ * @param {{ maxrateKbps: number, bufsizeKbps: number, level: string | null } | null} params.rateControl
286
+ * The output's own, as its key names it; null where nothing bounds the rate.
287
+ * @param {number} params.startIndex - First segment number this run makes.
288
+ * @param {number} params.endIndex - Last it makes, inclusive; below the start
289
+ * means it has no end.
290
+ * @param {number | undefined} params.positionSecondsOverride - Where to begin,
291
+ * when the caller knows better than the table.
292
+ * @param {object} params.videoEncoder
293
+ * @param {number} params.segmentDurationSec
294
+ * @returns {{ args: string[], safeIndex: number, startSeconds: number, cutTimes: number[] | null }}
295
+ */
296
+ export function buildRunCommand({
297
+ keyframes,
298
+ inputFile,
299
+ audioFile,
300
+ inputUrl,
301
+ audioInputUrl: audioInputUrlGiven,
302
+ timeline,
303
+ output,
304
+ segmentFormat,
305
+ transcodeVideo,
306
+ transcodeAudio,
307
+ audioOnly,
308
+ audioSeparate,
309
+ audioSourceTrackIndex,
310
+ rateControl,
311
+ startIndex,
312
+ endIndex,
313
+ positionSecondsOverride,
314
+ videoEncoder,
315
+ segmentDurationSec
316
+ }) {
317
+ const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
318
+ // 0-based output time of this segment, from the table the PLAYER holds —
319
+ // the same one the cut list below is taken from.
320
+ //
321
+ // These two were read from different tables until 2026-08-21, and that is
322
+ // one fault, not two: `-segment_times` are measured from wherever the run
323
+ // really began, so any distance between the position and the cut list moves
324
+ // EVERY cut of that run by it. The live table keeps being corrected as
325
+ // produced segments reveal where the file's cuts truly are, and those
326
+ // corrections run backwards, so each restart began a little earlier than
327
+ // the grid the cuts were stated on — and since the corrections accumulate,
328
+ // so did the distance. Measured on `JUFD665.mp4`: after one seek restart a
329
+ // produced segment held the boundary two places before its own number
330
+ // (16.684 s, exactly 2.0000 segments), after the next it held the one four
331
+ // places before (33.5 s). The player's buffer then stops extending at all,
332
+ // because the content of every fragment lands before the time its playlist
333
+ // entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far`
334
+ // warnings until hls.js gave up and jumped the viewer 16.8 s forward.
335
+ //
336
+ // 2.45.0 moved the CUT LIST onto the published table for this same reason
337
+ // and left the position on the live one. Both belong on the published
338
+ // table: a run must begin where the player was told the segment begins.
339
+ const startSeconds = Number.isFinite(positionSecondsOverride)
340
+ ? positionSecondsOverride
341
+ : publishedStartTime(timeline, safeIndex, segmentDurationSec);
342
+ // Where each of the two timelines begins, asked of the files themselves.
343
+ // Fresh by construction: the session may have been created before the
344
+ // soundtrack file's header could be read, and the reading lands on the file
345
+ // object this session holds — so there is nothing to re-read and nothing
346
+ // that can be stale. Same property as the keyframe table, which is one
347
+ // object shared by every session of the file.
348
+ // Which timeline this run works on. Asked once, because the closure that
349
+ // adds the second input reads it too, and two readings of one predicate is
350
+ // how the picture and the sound came apart before.
351
+ const keyframeGrid = onKeyframeGridFor({ audioOnly, timeline, transcodeVideo });
352
+ const servesAudioSeparately = audioOnly !== true && audioSeparate === true;
353
+ const audioFileStartTime = audioFile.startTime;
354
+ // The start time of the file this run READS, which is the picture's own for
355
+ // every session except one whose soundtrack is a separate file.
356
+ const sourceStartTime = inputFile.startTime;
357
+ // Cut where this session's grid says, whoever is producing the frames. The
358
+ // times are measured from the start of THIS run; the same list serves as
359
+ // the cut points and, when re-encoding, as the keyframes to force — one
360
+ // list, so the two cannot drift apart.
361
+ const explicitTimes = segmentFormat.explicitTimesMuxerArgs?.() ?? null;
362
+ // Matroska seeks begin without the preceding decode timestamps. Copy one
363
+ // preceding cut privately so reordered frames have established DTS before
364
+ // the first requested cut. It is never published over another run's output.
365
+ const inputIndex = !transcodeVideo && !audioOnly && explicitTimes && safeIndex > 0
366
+ ? safeIndex - 1 : safeIndex;
367
+ // A COPY is cut by this list whatever grid it ended up on. Even when no
368
+ // keyframe index could be read and the boundaries are a plain grid, saying
369
+ // them outright is what keeps the playlist and the muxer agreeing — ffmpeg
370
+ // moves each cut forward to the first real keyframe, and serving reads back
371
+ // where the piece truly begins. Requiring a keyframe grid here dropped a
372
+ // copy with no index onto the `hls` muxer, which takes no cut list and
373
+ // writes no self-contained pieces, so nothing could read a true start and
374
+ // segments were stamped with times the file does not have — the 4.17 s
375
+ // speech-against-subtitles drift, back again.
376
+ //
377
+ // Cut on the grid the PLAYER WAS GIVEN, not on the corrected one. A player
378
+ // places a fragment by the playlist it holds, and that text was written
379
+ // once and never changes; the live table keeps moving as produced segments
380
+ // reveal where the file's cuts really are. Cutting on the moved table makes
381
+ // every run faithful to a timeline nobody sent the player — measured
382
+ // 2026-08-20, the picture's segments arriving a uniform 2.002 s before the
383
+ // times the playlist named for them, which is four times what hls.js will
384
+ // bridge, so the fragment does not land and is asked for again.
385
+ //
386
+ // The corrections keep their purpose: they describe the file, and a variant
387
+ // created later inherits the corrected table and PUBLISHES it, so its own
388
+ // playlist and its own cuts agree from the start. What they may not do is
389
+ // move the cuts of a session whose playlist is already being read.
390
+ const gridCutTimes = explicitTimes && (!transcodeVideo || timeline.cutGrid === "keyframe")
391
+ ? segmentCutTimesFrom(publishedGridFor(timeline), inputIndex)
392
+ : null;
393
+ // Cut times are stated on the grid, for both branches.
394
+ //
395
+ // 2.28.0 added `sourceStartTime` to them on the copy branch, reasoning that
396
+ // the muxer decides its cuts before the output is relabelled. The field
397
+ // measured it the next session and the reasoning was wrong: of 75 pieces
398
+ // the picture produced, only NINE began at a time the container's own
399
+ // keyframe table names (the soundtrack, untouched by the change, scored 70
400
+ // of 75). Before it, every piece began exactly on a named keyframe and it
401
+ // was the PLAYLIST that disagreed with them. So the shift moved the cuts
402
+ // OFF the keyframes rather than onto them, and it is gone.
403
+ //
404
+ // What remains true, and is what that measurement is really about: the
405
+ // picture cuts where the source's keyframes are, and the playlist must be
406
+ // built from those same times. That is the correction path's job, not the
407
+ // cut list's.
408
+ const cutTimes = gridCutTimes;
409
+
410
+ // Video: re-encode only when required, using the detected encoder
411
+ // (hardware-accelerated or software). The descriptor builds the filter +
412
+ // codec args (including keyframe alignment on segment boundaries).
413
+ const videoCodecArgs = transcodeVideo
414
+ ? videoEncoder.buildVideoArgs({
415
+ // Budget-selected encode box (may be below the client target on weak
416
+ // software hosts); falls back to the client target for hardware.
417
+ targetWidth: output.encodeWidth,
418
+ targetHeight: output.encodeHeight,
419
+ segmentDurationSec: segmentDurationSec,
420
+ // Source-inherited output rate (integer, capped); descriptors that
421
+ // use time-based keyframes just apply it as the frame rate.
422
+ fps: output.outputFps,
423
+ // Software-only; hardware descriptors ignore it.
424
+ preset: output.softwarePreset ?? undefined,
425
+ // HDR→SDR tone map (software path only; gated on filter availability).
426
+ tonemap: output.applyTonemap === true,
427
+ // On the source's grid the cuts are not evenly spaced, so no frame
428
+ // count can describe them: the encoder is told the times outright,
429
+ // the same ones the muxer will cut at.
430
+ forcedKeyframeTimes: cutTimes,
431
+ // The output's own rate control and level. Read off the output, whose
432
+ // key names them: two limits are two outputs, and nothing moves a
433
+ // limit under an output that already exists.
434
+ rateControl: rateControl ?? null
435
+ })
436
+ : ["-c:v", "copy"];
437
+ const audioCodecArgs = transcodeAudio
438
+ ? ["-c:a", "aac", "-ac", "2", "-b:a", `${AUDIO_TRANSCODE_KBPS}k`]
439
+ : ["-c:a", "copy"];
440
+
441
+ const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
442
+ // Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
443
+ // only applies when we actually re-encode the video track.
444
+ if (transcodeVideo && Array.isArray(videoEncoder.inputArgs)) {
445
+ args.push(...videoEncoder.inputArgs);
446
+ }
447
+ // Seek position in SOURCE time. On the keyframe grid `startSeconds` is a
448
+ // real keyframe's offset from zero, so the container's own start time goes
449
+ // back on to reach it; on the uniform grid it is a plain offset. This
450
+ // follows the GRID, not whether the video is re-encoded — a variant cut on
451
+ // the source's keyframes has to seek to them like the copy it accompanies.
452
+ const inputStartSeconds = inputIndex === safeIndex ? startSeconds :
453
+ publishedStartTime(timeline, inputIndex, segmentDurationSec);
454
+ const seekSeconds = timeline.cutGrid === "keyframe"
455
+ ? inputStartSeconds + sourceStartTime
456
+ : inputStartSeconds;
457
+ // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
458
+ // keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
459
+ // not the container's own on-the-fly seek/index) and trim the short
460
+ // residual (bounded by the keyframe interval) precisely AFTER -i, which is
461
+ // always frame-accurate regardless of -accurate_seek.
462
+ //
463
+ // Root cause this works around: `-accurate_seek -ss X` before -i trusts the
464
+ // CONTAINER's own seek to land near X. For some containers (observed: AVI
465
+ // with VBR MP3 audio) that on-the-fly seek can point at a position with no
466
+ // valid frame boundary at all — ffmpeg fails outright ("Seek failed" /
467
+ // "Header missing"), not just imprecisely, and repeatedly so since every
468
+ // retry re-tries the SAME bad container-computed position. A keyframe we
469
+ // read directly from the packet list is a position ffmpeg has already
470
+ // proven it can decode.
471
+ //
472
+ // The keyframe is CARRIED from the table that named this boundary, not looked
473
+ // up by value. A boundary is stored on the player's clock, rounded, and
474
+ // `seekSeconds` above puts the container's start time back on to reach the
475
+ // file's clock — a lossy round trip. A keyframe at 26.234 s in a container
476
+ // starting at 0.083 s comes back as 26.233999999999998, and "the keyframe at
477
+ // or before that" is then the PREVIOUS one, 8.717 s earlier: two parts in a
478
+ // quadrillion, one whole keyframe interval. That interval became a trim, the
479
+ // trim moved every cut of the run backwards by another interval, and the
480
+ // run's files were numbered from #36 while carrying film 17.4 s before what
481
+ // the playlist says #36 holds (field 2026-09-05: the picture covered the
482
+ // playhead, the sound had a 17.4 s hole across it, and the viewer waited two
483
+ // minutes for a buffer that could never fill).
484
+ //
485
+ // The search stays for a grid restored without its source clock, which is the
486
+ // only case that has nothing to carry.
487
+ const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
488
+ ? timeline.sourceStartOf(inputIndex)
489
+ : null;
490
+ const snappedKeyframe = Number.isFinite(carriedKeyframe)
491
+ ? carriedKeyframe
492
+ : (Array.isArray(keyframes?.times) && keyframes.times.length > 0
493
+ ? nearestKeyframeAtOrBefore(keyframes.times, seekSeconds)
494
+ : null);
495
+ // A second input, and it exists for exactly one case: a browser that takes
496
+ // its audio muxed into the picture, watching a release whose soundtrack is a
497
+ // file of its own. An audio RENDITION reads that file as its only input and
498
+ // has none of this — which is why the ordinary path, and every browser that
499
+ // understands rendition groups, still runs on a single input.
500
+ const audioInputUrl =
501
+ typeof audioInputUrlGiven === "string" && audioInputUrlGiven.length > 0
502
+ ? audioInputUrlGiven
503
+ : "";
504
+ // Where the picture's own start sits on the soundtrack file's timeline. Both
505
+ // files begin at their own container start time, and those need not be the
506
+ // same number; the difference is what keeps the two aligned.
507
+ const audioTimelineShift = audioInputUrl
508
+ ? audioFileStartTime - sourceStartTime
509
+ : 0;
510
+ /**
511
+ * Add the second input, if there is one, with its own seek.
512
+ *
513
+ * Called between the first `-i` and any OUTPUT option, because ffmpeg reads
514
+ * these positionally: an option written after the last `-i` applies to the
515
+ * output, and the residual seek below is exactly such an option. Getting the
516
+ * order wrong would silently turn the audio file's seek into a trim of the
517
+ * finished stream.
518
+ *
519
+ * @param {number} inputSeekSeconds - Where to start, on the PICTURE's
520
+ * timeline. Translated to the soundtrack file's own here.
521
+ */
522
+ const pushAudioInput = (inputSeekSeconds) => {
523
+ if (!audioInputUrl) {
524
+ return;
525
+ }
526
+ // `-itsoffset` states the soundtrack's timestamps on the picture's
527
+ // timeline, so everything after this point — `-copyts`, the output offset,
528
+ // the cut list — goes on treating the two as one timeline, unchanged.
529
+ //
530
+ // ONLY on the branch that keeps the source's own timestamps. Without
531
+ // `-copyts` ffmpeg rebases each input from its own seek point, and both
532
+ // inputs are seeked to the same instant just below — so the two are
533
+ // already aligned and adding the offset would pull them apart by exactly
534
+ // the amount it exists to remove.
535
+ if (audioTimelineShift !== 0 && keyframeGrid) {
536
+ args.push("-itsoffset", ffmpegSeconds(-audioTimelineShift));
537
+ }
538
+ const audioSeek = Math.max(0, inputSeekSeconds + audioTimelineShift);
539
+ if (audioSeek > 0) {
540
+ // No keyframe to snap to and none needed: every audio frame is a sync
541
+ // point, so the seek can be accurate outright.
542
+ args.push("-accurate_seek", "-ss", ffmpegSeconds(audioSeek));
543
+ }
544
+ args.push("-i", audioInputUrl);
545
+ };
546
+
547
+ if (snappedKeyframe !== null) {
548
+ const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
549
+ if (snappedKeyframe > 0) {
550
+ args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, keyframes }, snappedKeyframe)));
551
+ }
552
+ args.push("-i", inputUrl);
553
+ // The coarse landing, not the exact target: the residual below is discarded
554
+ // from the OUTPUT and so takes the same slice off every stream. Seeking the
555
+ // soundtrack to the exact target as well would take that slice twice and
556
+ // leave the sound running ahead of the picture by it.
557
+ pushAudioInput(snappedKeyframe);
558
+ // An output-side trim, and ONLY where the output is labelled from zero.
559
+ //
560
+ // Beside `-copyts` it does the opposite of what it says. Measured
561
+ // 2026-09-06 on a file with a 5 s keyframe interval: a run landed at 15 s
562
+ // and asked to trim to the cut at 20 s produced its first file starting at
563
+ // 10 s, and a run asked to trim to 17 s produced cuts at 13.129, 18.129,
564
+ // 23.129 — the whole grid moved back by the trim itself. The muxer's cut
565
+ // times are absolute under `-copyts` while the trim is not, so every cut of
566
+ // the run inherits the difference and the numbering, fixed at spawn, is
567
+ // wrong by however many cuts that is.
568
+ //
569
+ // On the keyframe grid the run begins AT a cut, so there is nothing to
570
+ // trim: the carried keyframe above makes this exactly zero rather than
571
+ // nearly zero.
572
+ if (residualSeconds > 0 && !keyframeGrid) {
573
+ args.push("-ss", ffmpegSeconds(residualSeconds));
574
+ }
575
+ } else {
576
+ if (seekSeconds > 0) {
577
+ // No keyframe map (probe failed/timed out) — fall back to the previous
578
+ // behaviour: trust the container's own accurate seek.
579
+ args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
580
+ }
581
+ args.push("-i", inputUrl);
582
+ pushAudioInput(seekSeconds);
583
+ }
584
+ // Which timeline the output is labelled on. An audio rendition has no
585
+ // picture of its own to follow, so it follows the grid it was given — the
586
+ // same one the video it plays with is on. Deciding by `transcodeVideo`, as
587
+ // everything else here does, would put the audio of a re-encoded stream on
588
+ // the copy branch: `-copyts` and a shift by the container's start time,
589
+ // against a picture labelled from zero. The two would be offset by
590
+ // `sourceStartTime` for the whole file.
591
+ if (!keyframeGrid) {
592
+ // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
593
+ // segment grid; relabel output onto the original timeline so segment N
594
+ // carries PTS = N × segmentDuration.
595
+ if (startSeconds > 0) {
596
+ args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
597
+ }
598
+ } else {
599
+ // Branch B (video copied — only audio is transcoded): we cannot insert
600
+ // keyframes, so segments are cut at the source's own keyframes (the
601
+ // playlist boundaries were built from those keyframes). Keep the source's
602
+ // real timestamps (`-copyts`) so copied frames stay continuous across
603
+ // boundaries/seeks, and shift by -startTime so the output timeline is
604
+ // 0-based (a non-zero container start otherwise puts a hole at the very
605
+ // beginning and desyncs audio/video). Audio is transcoded on this timeline.
606
+ args.push("-copyts");
607
+ if (sourceStartTime !== 0) {
608
+ args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
609
+ }
610
+ }
611
+ // Where this run STOPS. Until now a run had a start and no end — neither
612
+ // `-to` nor `-t` appeared anywhere in the arguments this proxy builds — so
613
+ // every stop was a kill from outside, and two runs on one output could only
614
+ // be kept apart by giving each its own directory. With an end they cannot
615
+ // reach each other's numbers at all, and a run that finishes its stretch
616
+ // exits by itself instead of having to be noticed and killed.
617
+ //
618
+ // WHICH argument states it is a property of the branch, and it is measured
619
+ // rather than reasoned (2026-09-04, `research/encoder-layer-2026-09-04.md`
620
+ // §11): `-t` is a duration on the output's own clock, and `-to` a point on
621
+ // the input's. The copy branch runs with `-copyts`, where the input's clock
622
+ // IS the source's, so `-to` takes the absolute time; the re-encode branch
623
+ // has no `-copyts` and takes the duration. Swapping them is not a near
624
+ // miss — on the copy branch `-t` produced one segment where five were
625
+ // wanted, because the time it names is already past when the run starts.
626
+ const runEnd = Number.isInteger(endIndex) ? endIndex : -1;
627
+ const publishedGrid = publishedGridFor(timeline);
628
+ if (runEnd >= safeIndex && Array.isArray(publishedGrid) && publishedGrid[runEnd + 1] > 0) {
629
+ const endsAt = publishedGrid[runEnd + 1];
630
+ if (transcodeVideo) {
631
+ args.push("-t", ffmpegSeconds(Math.max(0.1, endsAt - publishedGrid[safeIndex])));
632
+ } else {
633
+ args.push("-to", ffmpegSeconds(endsAt));
634
+ }
635
+ }
636
+ if (audioOnly === true) {
637
+ // An audio RENDITION: one track, no picture. Published as its own
638
+ // `#EXT-X-MEDIA` and shared by every video variant, so the track is
639
+ // encoded once for the file instead of once per rung, and changing it is
640
+ // the player switching rendition rather than this proxy rebuilding the
641
+ // session. Cut on the same grid as the video it accompanies, which is
642
+ // what lets the two be played together.
643
+ // `0:` because a rendition's only input IS the file its track lives in —
644
+ // the picture's own file, or the one beside it that carries this dub.
645
+ args.push("-vn", "-map", `0:a:${audioSourceTrackIndex}?`, ...audioCodecArgs);
646
+ } else if (servesAudioSeparately) {
647
+ // The other half of the same arrangement: the picture alone, because its
648
+ // audio is published as a rendition and would otherwise play twice.
649
+ args.push("-an", "-map", "0:v:0?", ...videoCodecArgs);
650
+ } else {
651
+ args.push(
652
+ "-map",
653
+ "0:v:0?",
654
+ "-map",
655
+ // The audio track the viewer chose: input 1 when their choice is a
656
+ // soundtrack shipped as its own file, input 0 when it is one of the
657
+ // picture's own. Type-relative within that input, which is what
658
+ // `audioSourceTrackIndex` holds — the number the browser sent is flat
659
+ // across both files and was resolved when the session was made.
660
+ `${audioInputUrl ? 1 : 0}:a:${audioSourceTrackIndex}?`,
661
+ ...videoCodecArgs,
662
+ ...audioCodecArgs
663
+ );
664
+ }
665
+
666
+ // Where the cuts come from. On the copy path they are the source's own
667
+ // keyframes, and until now they were only ever GUESSED: ffmpeg got a target
668
+ // duration and chose its own cut points, while the playlist was built from
669
+ // the container index — two independent calculations with nothing tying
670
+ // them together but the hope that they agree. They do not. The index is a
671
+ // navigation table and is not obliged to list every keyframe; for a field
672
+ // file it held 1902 while ffmpeg found roughly twice as many and cut twice
673
+ // as often. Segment #876 then meant 1:26:50 to the player and about minute
674
+ // 58 to ffmpeg, which is why a seek landed nowhere near where it was aimed
675
+ // and the reported duration drifted.
676
+ //
677
+ // So stop guessing and say it: the `segment` muxer takes the list of times
678
+ // outright. Passing the very boundaries the playlist was built from makes
679
+ // the two agree by construction. Only cut points already known to be real
680
+ // keyframes are sent, so ffmpeg never has to move one forward.
681
+ //
682
+ // The list is built above, before the encoder args, because a re-encoded
683
+ // variant of a copied stream needs the same times twice over: once as the
684
+ // cuts, once as the keyframes to force at them.
685
+ if (cutTimes && cutTimes.length > 0) {
686
+ args.push(
687
+ "-f",
688
+ "segment",
689
+ // Times are measured from the START OF THIS RUN, not from the start of
690
+ // the file — verified: starting at 12 s and asking for a cut at 18 s
691
+ // produced one at 29.4 s. `segmentCutTimesFrom` rebases them.
692
+ "-segment_times",
693
+ cutTimes.join(","),
694
+ // A cut lands on the first keyframe at or after its time, so a boundary
695
+ // recorded a hair late would skip to the next one and double the
696
+ // segment. The tolerance absorbs that rounding.
697
+ "-segment_time_delta",
698
+ String(SEGMENT_CUT_TIME_DELTA_SECONDS),
699
+ "-segment_start_number",
700
+ String(inputIndex),
701
+ // THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
702
+ //
703
+ // Measured on the addon host 2026-09-05: a name appears in this list when
704
+ // the file is CLOSED, not when it is created — at the third sample
705
+ // `seg-000.mp4` was on disk and absent from the list, and it appeared at
706
+ // the fourth, in the same moment `seg-001.mp4` came into being. So a name
707
+ // here is the writer's own statement that the piece is whole.
708
+ //
709
+ // Without it, a finished file is indistinguishable from one still being
710
+ // written, and the only proof available was the existence of the NEXT
711
+ // one — which never comes for the last piece of every run.
712
+ "-segment_list",
713
+ "pipe:3",
714
+ "-segment_list_type",
715
+ "csv",
716
+ "-segment_list_flags",
717
+ "+live",
718
+ ...explicitTimes,
719
+ // UNDER A WORKING NAME, not the one it is served as. A piece under its
720
+ // served name is complete by construction then, whoever else is writing
721
+ // into the same directory — and the name arrives on the channel above the
722
+ // instant ffmpeg closes it, which is what turns it into the served one.
723
+ //
724
+ // The other branch needs none of this: the HLS muxer writes through a
725
+ // temporary name of its own (`+temp_file`), so its files appear under
726
+ // their final name whole.
727
+ //
728
+ // TAGGED WITH THE STRETCH IT WAS GIVEN, which names the run without any
729
+ // counter to keep: intervals never overlap, so two live runs of one output
730
+ // begin at different numbers by construction. That is what makes clearing
731
+ // up after a dead run a well-formed question — its unfinished pieces are
732
+ // the ones carrying its own tag — where before it was answered by taking
733
+ // the highest SERVED name inside its stretch and judging the bytes.
734
+ segmentFormat.makingFileNameTemplate(String(safeIndex))
735
+ );
736
+ } else {
737
+ args.push(
738
+ "-f",
739
+ "hls",
740
+ "-hls_time",
741
+ String(segmentDurationSec),
742
+ "-hls_list_size",
743
+ "0",
744
+ "-hls_flags",
745
+ "independent_segments+temp_file",
746
+ // Container selection + segment naming, from the active format module.
747
+ ...segmentFormat.muxerArgs(),
748
+ "-start_number",
749
+ String(safeIndex),
750
+ // ffmpeg writes its own playlist here; we ignore it and serve the
751
+ // synthetic VOD playlist instead (see getFileStream).
752
+ PLAYLIST_FILE_NAME
753
+ );
754
+ }
755
+ return { args, safeIndex, inputIndex, startSeconds, cutTimes };
756
+
757
+ }