@torrent-tv/proxy 2.87.21 → 2.88.0

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