@torrent-tv/proxy 2.89.7 → 2.89.8

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 (497) 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 +358 -338
  13. package/CHANGELOG.md +2340 -2335
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -19
  19. package/assets/calibration/audio/manifest.json +98 -98
  20. package/assets/diagnostics/sctpstate.gdb +31 -31
  21. package/bin/cli.js +684 -684
  22. package/bin/thread-pool.js +30 -30
  23. package/biome.json +412 -412
  24. package/docs/browser-proxy-contract.md +155 -155
  25. package/docs/container-architecture.md +273 -273
  26. package/docs/download-architecture.md +261 -261
  27. package/docs/encode-architecture.md +765 -765
  28. package/docs/encode-run-state.md +96 -96
  29. package/docs/logs.md +97 -97
  30. package/docs/playback-start-forecast.md +113 -113
  31. package/docs/socks5-egress-research.md +114 -114
  32. package/docs/storage-architecture.md +273 -273
  33. package/knip.json +14 -14
  34. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  35. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  36. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  37. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  38. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  39. package/openspec/changes/binary-distribution/proposal.md +53 -53
  40. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  41. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  42. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  43. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  44. package/openspec/changes/cold-start/design.md +98 -98
  45. package/openspec/changes/cold-start/proposal.md +62 -62
  46. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  47. package/openspec/changes/cold-start/tasks.md +48 -48
  48. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  49. package/openspec/changes/disk-cap/proposal.md +37 -37
  50. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  51. package/openspec/changes/disk-cap/tasks.md +19 -19
  52. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  53. package/openspec/changes/proxy-observability/design.md +38 -38
  54. package/openspec/changes/proxy-observability/proposal.md +49 -49
  55. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  56. package/openspec/changes/proxy-observability/tasks.md +21 -21
  57. package/openspec/changes/seek-debounce/design.md +118 -118
  58. package/openspec/changes/seek-debounce/proposal.md +59 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  60. package/openspec/changes/seek-debounce/tasks.md +33 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  62. package/openspec/changes/subtitle-language/proposal.md +50 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  64. package/openspec/changes/subtitle-language/tasks.md +26 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  66. package/openspec/changes/track-selection/design.md +47 -47
  67. package/openspec/changes/track-selection/proposal.md +45 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  69. package/openspec/changes/track-selection/tasks.md +20 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  71. package/openspec/changes/transcode-quality/proposal.md +51 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  73. package/openspec/changes/transcode-quality/tasks.md +63 -63
  74. package/openspec/config.yaml +38 -38
  75. package/package.json +51 -50
  76. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  77. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  78. package/research/double-spawn-2026-09-10.md +171 -171
  79. package/research/handover-2026-09-13.md +209 -209
  80. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  81. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  82. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  83. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  84. package/routes/api/client-logs/post.js +86 -86
  85. package/routes/api/delivery-sink/get.js +72 -72
  86. package/routes/api/link-probe/get.js +51 -51
  87. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  88. package/routes/api/playback-plan/post.js +53 -53
  89. package/routes/api/sources/files/get.js +106 -106
  90. package/routes/api/sources/post.js +36 -36
  91. package/routes/api/sources/stats/get.js +132 -132
  92. package/routes/api/sources/warm/post.js +217 -217
  93. package/routes/api/subtitles/get.js +230 -230
  94. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  95. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  96. package/routes/api/transcode-sessions/post.js +178 -178
  97. package/routes/api/transcode-sessions/progress/get.js +27 -27
  98. package/routes/api/transcode-sessions/release/post.js +41 -41
  99. package/routes/api/transcode-sessions/seek/post.js +63 -63
  100. package/routes/health/get.js +13 -13
  101. package/routes/healthz/get.js +13 -13
  102. package/routes/stream/get.js +416 -416
  103. package/routes/transcode/audio-file/get.js +64 -64
  104. package/routes/transcode/audio-warm/get.js +95 -95
  105. package/routes/transcode/session-file/get.js +52 -52
  106. package/routes/transcode/variant-file/get.js +95 -95
  107. package/routes/transcode/variant-warm/get.js +84 -84
  108. package/scripts/build-audio-samples.mjs +31 -31
  109. package/scripts/render-run-graph.js +173 -173
  110. package/server.js +748 -748
  111. package/services/encode/CoverageMap.js +428 -428
  112. package/services/encode/CushionReport.js +345 -345
  113. package/services/encode/EncodeAdmission.js +376 -376
  114. package/services/encode/EncodeOrchestrator.js +1192 -1192
  115. package/services/encode/EncodePlan.js +1368 -1368
  116. package/services/encode/EncodeRun.js +36 -12
  117. package/services/encode/Encoder.js +142 -142
  118. package/services/encode/EncoderSelection.js +38 -38
  119. package/services/encode/LocalObservations.js +359 -359
  120. package/services/encode/NvencEncoder.js +50 -50
  121. package/services/encode/OutputOpening.js +784 -784
  122. package/services/encode/OutputTimes.js +579 -579
  123. package/services/encode/QsvEncoder.js +58 -58
  124. package/services/encode/Renditions.js +2283 -2283
  125. package/services/encode/RunClock.js +87 -87
  126. package/services/encode/RunProgress.js +79 -79
  127. package/services/encode/SegmentDemand.js +95 -95
  128. package/services/encode/SoftwareEncoder.js +121 -121
  129. package/services/encode/V4l2m2mEncoder.js +53 -53
  130. package/services/encode/VaapiEncoder.js +66 -66
  131. package/services/encode/args.js +418 -418
  132. package/services/encode/audio-calibration.js +67 -67
  133. package/services/encode/audio-work-rate.js +15 -15
  134. package/services/encode/available-share.js +89 -89
  135. package/services/encode/calibration.js +361 -361
  136. package/services/encode/contention.js +128 -128
  137. package/services/encode/decode-cost-fit.js +363 -363
  138. package/services/encode/encode-exit.js +116 -116
  139. package/services/encode/encode-run-state.js +551 -551
  140. package/services/encode/encoder-readings.js +43 -43
  141. package/services/encode/fingerprint.js +322 -322
  142. package/services/encode/host-load.js +174 -174
  143. package/services/encode/hwaccel.js +1894 -1894
  144. package/services/encode/index.js +9 -9
  145. package/services/encode/learned-median.js +90 -90
  146. package/services/encode/output/EncodedOutput.js +57 -57
  147. package/services/encode/output/Output.js +131 -131
  148. package/services/encode/output/OutputCatalog.js +414 -414
  149. package/services/encode/output/OutputSpec.js +390 -390
  150. package/services/encode/output/Timeline.js +308 -308
  151. package/services/encode/output/cut-grid.js +107 -107
  152. package/services/encode/output/index.js +24 -24
  153. package/services/encode/output/ladder.js +32 -32
  154. package/services/encode/output/playlists.js +284 -284
  155. package/services/encode/output/rates.js +125 -125
  156. package/services/encode/output-key-format.js +21 -21
  157. package/services/encode/quality/EncodeCost.js +1043 -1043
  158. package/services/encode/quality/HostLoad.js +525 -525
  159. package/services/encode/quality/HostTimings.js +249 -249
  160. package/services/encode/quality/OutputQualityState.js +24 -24
  161. package/services/encode/quality/QualityController.js +758 -758
  162. package/services/encode/quality/QualityOffer.js +416 -416
  163. package/services/encode/quality/drain-threat.js +47 -47
  164. package/services/encode/quality/link-budget.js +337 -337
  165. package/services/encode/quality/output-format.js +319 -319
  166. package/services/encode/quality/serving-output.js +216 -216
  167. package/services/encode/quality/visible-rung.js +57 -57
  168. package/services/encode/run-budget.js +97 -97
  169. package/services/encode/run-command.js +757 -757
  170. package/services/encode/run-costs.js +155 -155
  171. package/services/encode/run-inputs.js +53 -53
  172. package/services/encode/segment-formats/fmp4.js +476 -476
  173. package/services/encode/segment-formats/init-compat.js +324 -324
  174. package/services/encode/segment-formats/mpegts.js +134 -134
  175. package/services/encode/start-stop-cost.js +178 -178
  176. package/services/encode/throughput.js +97 -97
  177. package/services/encode/torrent-cost.js +88 -88
  178. package/services/identity/installation-id.js +38 -38
  179. package/services/media/ContainerOrchestrator.js +202 -202
  180. package/services/media/KeyframeTables.js +356 -356
  181. package/services/media/SourceFile.js +298 -298
  182. package/services/media/SubtitleCues.js +592 -592
  183. package/services/media/SubtitleOrchestrator.js +167 -167
  184. package/services/media/audio-inventory.js +299 -299
  185. package/services/media/container/AviContainer.js +268 -268
  186. package/services/media/container/Container.js +632 -632
  187. package/services/media/container/ContainerFactory.js +38 -38
  188. package/services/media/container/KeyframeTable.js +272 -272
  189. package/services/media/container/MatroskaContainer.js +1031 -1031
  190. package/services/media/container/Mp4Container.js +1354 -1354
  191. package/services/media/container/SubtitleFileContainer.js +339 -339
  192. package/services/media/container/ebml-reader.js +159 -159
  193. package/services/media/container/ebml-stream.js +174 -174
  194. package/services/media/container/matroska-clusters.js +1165 -1165
  195. package/services/media/container/unavailable.js +77 -77
  196. package/services/media/ffmpeg-banner.js +260 -260
  197. package/services/media/keyframe-probe.js +157 -157
  198. package/services/media/media-info-probe.js +93 -93
  199. package/services/media/playback-planner.js +931 -931
  200. package/services/media/tracks/AudioTrack.js +143 -143
  201. package/services/media/tracks/ContainerTrack.js +84 -84
  202. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  203. package/services/media/tracks/SubtitleTrack.js +38 -38
  204. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  205. package/services/media/tracks/VideoTrack.js +33 -33
  206. package/services/media/tracks/audio-bound.js +233 -233
  207. package/services/media/tracks/index.js +15 -15
  208. package/services/media/tracks/language-detect.js +174 -174
  209. package/services/server/OutputLifecycle.js +418 -418
  210. package/services/server/SegmentServing.js +1152 -1152
  211. package/services/server/ViewerRequests.js +785 -785
  212. package/services/server/WaitLedger.js +142 -142
  213. package/services/server/controllers/PlaybackController.js +40 -40
  214. package/services/server/controllers/SubtitleController.js +109 -109
  215. package/services/server/http-responses.js +60 -60
  216. package/services/server/transcode-session-files.js +325 -325
  217. package/services/server/wire-outputs.js +1029 -1029
  218. package/services/storage/Diagnostics.js +202 -202
  219. package/services/storage/MachineBudget.js +235 -235
  220. package/services/storage/allowance.js +107 -107
  221. package/services/storage/core-dumps.js +106 -106
  222. package/services/storage/files/CompletedFiles.js +391 -391
  223. package/services/storage/files/piece-from-whole-file.js +118 -118
  224. package/services/storage/free.js +71 -71
  225. package/services/storage/keep.js +48 -48
  226. package/services/storage/machine-memory.js +56 -56
  227. package/services/storage/memory-report.js +575 -575
  228. package/services/storage/piece-store/piece-disk-store.js +539 -539
  229. package/services/storage/piece-store/piece-lru.js +495 -495
  230. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  231. package/services/storage/returns.js +103 -103
  232. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  233. package/services/storage/wire.js +175 -175
  234. package/services/torrent/Contents.js +382 -382
  235. package/services/torrent/demand/DemandRegister.js +202 -202
  236. package/services/torrent/demand/Urgency.js +137 -137
  237. package/services/torrent/demand/Window.js +118 -118
  238. package/services/torrent/demand/index.js +8 -8
  239. package/services/torrent/demand/pieces.js +140 -140
  240. package/services/torrent/download/SwarmSelection.js +386 -386
  241. package/services/torrent/download/registry.js +102 -102
  242. package/services/torrent/download/withdraw-claim.js +80 -80
  243. package/services/torrent/episode-naming.js +182 -182
  244. package/services/torrent/files.js +455 -455
  245. package/services/torrent/naming.js +619 -619
  246. package/services/torrent/piece-store-of.js +29 -29
  247. package/services/torrent/supply-margin.js +264 -264
  248. package/services/torrent/torrent-pool.js +2688 -2688
  249. package/services/torrent/worker/background-fill.js +213 -213
  250. package/services/torrent/worker/channel.js +271 -271
  251. package/services/torrent/worker/client.js +836 -836
  252. package/services/torrent/worker/fastest-wires.js +342 -342
  253. package/services/torrent/worker/handle-state.js +23 -23
  254. package/services/torrent/worker/held-bytes.js +231 -231
  255. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  256. package/services/torrent/worker/piece-reader.js +1078 -1078
  257. package/services/torrent/worker/pool-adapter.js +371 -371
  258. package/services/torrent/worker/protocol.js +184 -184
  259. package/services/torrent/worker/resume-warm.js +107 -107
  260. package/services/torrent/worker/webrtc-shim.js +219 -219
  261. package/services/torrent/worker/worker.js +1137 -1137
  262. package/services/transport/data-channel-handler.js +1373 -1373
  263. package/services/transport/delivery-probe.js +842 -842
  264. package/services/transport/health-collector.js +53 -53
  265. package/services/transport/nat-classifier.js +231 -231
  266. package/services/transport/packet-witness.js +808 -808
  267. package/services/transport/port-mapper.js +235 -235
  268. package/services/transport/registry-api.js +81 -81
  269. package/services/transport/tunnel-client.js +447 -447
  270. package/services/transport/usrsctp-state.js +175 -175
  271. package/services/transport/webrtc-manager.js +484 -484
  272. package/services/viewer/Assignments.js +340 -340
  273. package/services/viewer/PriorityMap.js +421 -421
  274. package/services/viewer/PriorityOrchestrator.js +325 -325
  275. package/services/viewer/Viewer.js +691 -691
  276. package/services/viewer/Viewers.js +368 -368
  277. package/services/viewer/active-output.js +37 -37
  278. package/services/viewer/audio-start.js +46 -46
  279. package/services/viewer/buffer-trend.js +81 -81
  280. package/services/viewer/choices.js +647 -647
  281. package/services/viewer/media-time.js +355 -355
  282. package/services/viewer/playback-readiness.js +635 -635
  283. package/services/viewer/positions.js +126 -126
  284. package/services/viewer/report-intake.js +46 -46
  285. package/store/source-registry.js +71 -71
  286. package/test/asset-paths.test.js +28 -28
  287. package/test/audio-buffer-continuity.test.js +77 -77
  288. package/test/audio-calibration.test.js +23 -23
  289. package/test/audio-inventory.test.js +212 -212
  290. package/test/audio-track-bitrate.test.js +40 -40
  291. package/test/auto-quality-step.test.js +762 -762
  292. package/test/available-share.test.js +76 -76
  293. package/test/background-fill.test.js +190 -190
  294. package/test/client-log-file.test.js +110 -110
  295. package/test/coalesce.test.js +39 -39
  296. package/test/completed-files.test.js +115 -115
  297. package/test/concurrent-cost.test.js +148 -148
  298. package/test/container-media-info.test.js +228 -228
  299. package/test/contention.test.js +76 -76
  300. package/test/core-dumps.test.js +57 -57
  301. package/test/coverage-follows-the-disk.test.js +233 -228
  302. package/test/coverage-map.test.js +195 -195
  303. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  304. package/test/cut-times-timeline.test.js +65 -65
  305. package/test/cuts-follow-published-grid.test.js +109 -109
  306. package/test/data-channel-frame.test.js +63 -63
  307. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  308. package/test/dead-torrent-handle.test.js +52 -52
  309. package/test/declared-rates.test.js +116 -116
  310. package/test/declared-tracks.test.js +43 -43
  311. package/test/decode-cost-fit.test.js +202 -202
  312. package/test/decode-cost.test.js +475 -475
  313. package/test/delivery-probe.test.js +393 -393
  314. package/test/demand-register.test.js +206 -206
  315. package/test/dht-bootstrap.test.js +72 -72
  316. package/test/drain-threat.test.js +65 -65
  317. package/test/duplicate-add.test.js +48 -48
  318. package/test/encode-admission.test.js +146 -146
  319. package/test/encode-cost.test.js +275 -275
  320. package/test/encode-exit.test.js +111 -111
  321. package/test/encode-orchestrator.test.js +426 -426
  322. package/test/encode-plan-viewers.test.js +725 -725
  323. package/test/encode-plan.test.js +545 -545
  324. package/test/encode-run-state.test.js +314 -314
  325. package/test/encode-run.test.js +20 -0
  326. package/test/encode-slope.test.js +46 -46
  327. package/test/encoded-output.test.js +39 -39
  328. package/test/encoder-kinds.test.js +122 -122
  329. package/test/encoder-readings.test.js +68 -68
  330. package/test/episode-naming.test.js +131 -131
  331. package/test/every-claimant-is-told-its-share.test.js +152 -152
  332. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  333. package/test/failed-start-breaker.test.js +202 -202
  334. package/test/fastest-wires.test.js +143 -143
  335. package/test/ffmpeg-stream-counts.test.js +78 -78
  336. package/test/file-edges.test.js +191 -191
  337. package/test/fixtures/audio-frame-boundary.json +1 -1
  338. package/test/fmp4-self-contained.test.js +80 -80
  339. package/test/fmp4-timestamps.test.js +140 -140
  340. package/test/fmp4-track-completeness.test.js +41 -41
  341. package/test/format-keyed-outputs.test.js +78 -78
  342. package/test/health-metrics.test.js +36 -38
  343. package/test/held-read-ownership.test.js +77 -77
  344. package/test/held-request-width.test.js +157 -157
  345. package/test/helpers/encode-run.js +248 -240
  346. package/test/helpers/held-file.js +77 -77
  347. package/test/helpers/manager.js +62 -62
  348. package/test/helpers/matroska-file.js +296 -296
  349. package/test/helpers/output-spec.js +63 -63
  350. package/test/host-load.test.js +68 -68
  351. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  352. package/test/init-compat.test.js +219 -219
  353. package/test/input-lost-quiets-the-plan.test.js +261 -261
  354. package/test/input-unavailable.test.js +48 -48
  355. package/test/installation-id.test.js +39 -39
  356. package/test/keeping-period.test.js +83 -83
  357. package/test/keyframe-index-accuracy.test.js +188 -188
  358. package/test/keyframe-table-budget.test.js +123 -123
  359. package/test/keyframes-belong-to-the-file.test.js +249 -249
  360. package/test/learned-median.test.js +37 -37
  361. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  362. package/test/limit-row-by-area.test.js +60 -60
  363. package/test/link-budget.test.js +111 -111
  364. package/test/link-probe.test.js +56 -56
  365. package/test/local-encoding-data.test.js +273 -273
  366. package/test/logger-repeats.test.js +134 -134
  367. package/test/look-ahead-coverage.test.js +56 -56
  368. package/test/manager-surface.test.js +291 -291
  369. package/test/matroska-cluster-discovery.test.js +323 -323
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/media-time.test.js +62 -62
  372. package/test/memory-budget.test.js +390 -390
  373. package/test/move-cost.test.js +216 -216
  374. package/test/mp4-subtitles.test.js +173 -173
  375. package/test/one-authority.test.js +285 -285
  376. package/test/one-budget.test.js +149 -149
  377. package/test/one-output-one-session.test.js +273 -273
  378. package/test/one-resource-divided.test.js +185 -185
  379. package/test/one-serving-output-per-height.test.js +458 -458
  380. package/test/orchestrator-wired.test.js +207 -207
  381. package/test/output-catalog.test.js +39 -39
  382. package/test/output-shape.test.js +21 -21
  383. package/test/output-spec.test.js +251 -251
  384. package/test/output-speed.test.js +92 -87
  385. package/test/outputs-being-prepared.test.js +40 -40
  386. package/test/packet-witness-ring.test.js +236 -236
  387. package/test/packet-witness.test.js +148 -148
  388. package/test/piece-disk-store.test.js +401 -401
  389. package/test/piece-from-whole-file.test.js +129 -129
  390. package/test/piece-lru.test.js +319 -319
  391. package/test/piece-reader.test.js +162 -162
  392. package/test/piece-selection-offset.test.js +85 -85
  393. package/test/piece-store-eviction.test.js +595 -595
  394. package/test/piece-store-never-refuses.test.js +153 -153
  395. package/test/piece-store-reservations.test.js +403 -403
  396. package/test/piece-store-slow-disk.test.js +159 -159
  397. package/test/piece-tail.test.js +97 -97
  398. package/test/plan-host-timings.test.js +68 -68
  399. package/test/plan-is-steady.test.js +225 -225
  400. package/test/playback-readiness.test.js +518 -518
  401. package/test/playback-start-regressions.test.js +101 -101
  402. package/test/priority-map-download.test.js +174 -174
  403. package/test/priority-map-emptied.test.js +204 -204
  404. package/test/priority-map-per-output.test.js +215 -215
  405. package/test/priority-map.test.js +163 -163
  406. package/test/probe-wedge-certainty.test.js +60 -60
  407. package/test/produced-copy-choice.test.js +213 -213
  408. package/test/published-timeline.test.js +83 -83
  409. package/test/quality-offer.test.js +160 -160
  410. package/test/quality-variants.test.js +1274 -1270
  411. package/test/rate-control-in-the-output.test.js +181 -180
  412. package/test/read-survives-withdrawal.test.js +164 -164
  413. package/test/read-window.test.js +365 -365
  414. package/test/reads-not-yet-arrived.test.js +218 -218
  415. package/test/resume-warm.test.js +39 -39
  416. package/test/run-graph-drift.test.js +37 -37
  417. package/test/run-inputs.test.js +80 -80
  418. package/test/run-intervals.test.js +100 -100
  419. package/test/run-position-follows-published-grid.test.js +89 -89
  420. package/test/run-progress.test.js +64 -64
  421. package/test/same-height-switch.test.js +663 -663
  422. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  423. package/test/seek-landing.test.js +109 -109
  424. package/test/seek-target-not-superseded.test.js +195 -195
  425. package/test/segment-closed-on-both-branches.test.js +114 -114
  426. package/test/segment-cut-times.test.js +63 -63
  427. package/test/segment-demand.test.js +80 -80
  428. package/test/segment-hold-supersede.test.js +91 -91
  429. package/test/segment-judged-by-its-own-header.test.js +222 -222
  430. package/test/segment-serve-wiring.test.js +481 -481
  431. package/test/segment-store-eviction.test.js +247 -247
  432. package/test/segment-store.test.js +352 -352
  433. package/test/segment-true-start.test.js +86 -86
  434. package/test/segments-are-shared.test.js +176 -176
  435. package/test/send-chunk-bytes.test.js +86 -86
  436. package/test/serving-existing-output.test.js +111 -111
  437. package/test/serving-output.test.js +102 -102
  438. package/test/shared-piece-store.test.js +310 -310
  439. package/test/sidecar-naming.test.js +142 -142
  440. package/test/soundtrack-rate-bound.test.js +289 -289
  441. package/test/source-file.test.js +133 -133
  442. package/test/source-files-route.test.js +113 -113
  443. package/test/source-registry.test.js +18 -18
  444. package/test/split-a-stretch.test.js +139 -139
  445. package/test/stale-request-after-seek.test.js +158 -158
  446. package/test/startup-readings.test.js +124 -124
  447. package/test/stream-route.test.js +237 -237
  448. package/test/subtitle-cue-framing.test.js +200 -200
  449. package/test/subtitle-cue-source.test.js +102 -102
  450. package/test/subtitle-cue-walk.test.js +385 -385
  451. package/test/subtitle-cursor.test.js +63 -63
  452. package/test/subtitle-defaults.test.js +118 -118
  453. package/test/subtitle-language.test.js +252 -252
  454. package/test/subtitle-metadata.test.js +15 -15
  455. package/test/subtitle-plan-waits.test.js +125 -125
  456. package/test/subtitle-track-numbering.test.js +391 -391
  457. package/test/supply-margin.test.js +190 -190
  458. package/test/swarm-follows-readers.test.js +174 -174
  459. package/test/swarm-reach.test.js +130 -130
  460. package/test/swarm-selection.test.js +220 -220
  461. package/test/tail-duplication.test.js +167 -167
  462. package/test/the-channel-has-both-ends.test.js +91 -91
  463. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  464. package/test/thread-pool.test.js +53 -53
  465. package/test/timeline.test.js +84 -84
  466. package/test/torrent-contents.test.js +235 -235
  467. package/test/torrent-cost.test.js +71 -71
  468. package/test/torrent-files.test.js +221 -221
  469. package/test/torrent-naming.test.js +255 -255
  470. package/test/tracks-begin-together.test.js +209 -209
  471. package/test/tunnel-renewal.test.js +167 -167
  472. package/test/two-viewers-one-picture.test.js +508 -508
  473. package/test/upload-hurry.test.js +186 -186
  474. package/test/usrsctp-state.test.js +131 -131
  475. package/test/video-facts.test.js +102 -102
  476. package/test/viewer-assignments.test.js +143 -143
  477. package/test/viewer-outputs.test.js +285 -285
  478. package/test/viewer-presence.test.js +116 -116
  479. package/test/viewer-requests-admission.test.js +92 -92
  480. package/test/viewer-starts-on-map.test.js +27 -27
  481. package/test/viewer-states.test.js +192 -189
  482. package/test/viewer-subtitle-subscription.test.js +96 -96
  483. package/test/viewer.test.js +139 -139
  484. package/test/viewing-generations.test.js +390 -390
  485. package/test/visible-rung.test.js +37 -37
  486. package/test/wait-ledger.test.js +100 -100
  487. package/test/webrtc-shim.test.js +149 -149
  488. package/test/wedge-certainty.test.js +131 -131
  489. package/test/whole-file-held-read.test.js +30 -30
  490. package/test/withdraw-piece-claim.test.js +212 -212
  491. package/test/worker-channel.test.js +158 -158
  492. package/utils/client-log-file.js +151 -151
  493. package/utils/coalesce.js +41 -41
  494. package/utils/logger.js +312 -312
  495. package/utils/parse-range.js +25 -25
  496. package/utils/perf.js +59 -59
  497. package/utils/torrent-source-key.js +38 -38
@@ -1,579 +1,579 @@
1
- /**
2
- * Where each segment of an output begins in the film, and how the cut table is
3
- * corrected from what the encoder actually produced.
4
- *
5
- * The published grid is what the playlist told the player; a run's landing and
6
- * the true start of each piece are what ffmpeg did. The two are compared here,
7
- * a disagreement is said, and a boundary the index got wrong is corrected once
8
- * for every output of the file.
9
- */
10
-
11
- import { segmentIndexForTime } from "./output/playlists.js";
12
- import { publishedGridFor as publishedGridOf, publishedStartTime } from "./run-command.js";
13
- import { liveRunsOf } from "./encode-run-state.js";
14
- // How many produced segments' true start times to remember, so a player's
15
- // report about one of them can be answered. Two hundred is about twenty
16
- // minutes of playback at these segment lengths — far more than the recent past
17
- // a stall report can be about, and small enough to be free.
18
- const TRUE_START_MEMORY = 200;
19
- // How far a segment's own recorded start may sit from the one the playlist
20
- // assigned it before the disagreement is worth a log line. The two are built
21
- // from the same keyframe index and normally match to the sample; a quarter of a
22
- // second is below any drift a viewer could notice, so anything above it is the
23
- // index being wrong about where a keyframe is rather than rounding.
24
- const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
25
- /**
26
- * How far a fragment may land from where the playlist put it before the player
27
- * stops recognising it as buffered.
28
- *
29
- * hls.js's own `maxBufferHole`, whose default is 0.5 s: a gap smaller than this
30
- * is skipped, a gap larger is a hole, and a fragment appended across one is
31
- * judged not to have loaded — so the player asks for it again, and again.
32
- * Taken from the player's published default rather than chosen here.
33
- */
34
- export const PLAYER_BUFFER_HOLE_SEC = 0.5;
35
- /**
36
- * A live run of this session that begins exactly at this number, if there is
37
- * one.
38
- *
39
- * @param {object[] | Set<object>} runs
40
- * @param {number} index
41
- * @returns {object | null}
42
- */
43
- function runStartingAt(runs, index) {
44
- return liveRunsOf(runs).find((run) => run.from === index) ?? null;
45
- }
46
-
47
- /**
48
- * How far the live boundary table has moved from the one the player holds, said
49
- * in words.
50
- *
51
- * The corrections are applied one boundary at a time and each is small enough
52
- * to look harmless; what nobody was watching is the total. It matters because a
53
- * run positioned on one table and cut on the other carries their difference into
54
- * every cut it makes — the fault of 2026-08-21, where the distance reached two
55
- * whole segments after one seek and four after the next. Printed beside each
56
- * correction so the total is visible while it is still small.
57
- *
58
- * @param {number[]} published - The table the playlist text was written from.
59
- * @param {number[]} live - The table corrected from produced segments.
60
- * @returns {string} A phrase, always readable, never throwing on odd input.
61
- */
62
- export function describeGridDrift(published, live) {
63
- if (!Array.isArray(published) || !Array.isArray(live) || published.length === 0) {
64
- return "not comparable";
65
- }
66
- if (published.length !== live.length) {
67
- return `a different length (${published.length} against ${live.length})`;
68
- }
69
- let apart = 0;
70
- let worst = 0;
71
- let worstAt = -1;
72
- for (let index = 0; index < published.length; index += 1) {
73
- // Rounded to the millisecond BEFORE comparing, not only before printing.
74
- // Two boundaries moved by the same amount differ in the last bits of a
75
- // double, so an unrounded comparison picks between them by an accident
76
- // invisible in the printed figure — and the line would name a boundary the
77
- // reader cannot tell apart from the one before it. Rounded, ties keep the
78
- // earliest, which is also the one worth looking at first.
79
- const distance = Math.round(Math.abs(live[index] - published[index]) * 1000) / 1000;
80
- if (distance <= 0.001) {
81
- continue;
82
- }
83
- apart += 1;
84
- if (distance > worst) {
85
- worst = distance;
86
- worstAt = index;
87
- }
88
- }
89
- if (apart === 0) {
90
- return "identical";
91
- }
92
- return `${apart} of ${published.length} boundaries apart, worst ${worst.toFixed(3)}s at #${worstAt}`;
93
- }
94
-
95
- export class OutputTimes {
96
- #state = new WeakMap();
97
- /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
98
- #host;
99
-
100
- /**
101
- * @param {object} host - `logger`, `runsOf`, `stopEncodeRun`, `planEncodersSoon`, `outputs`, `segmentDurationSec`
102
- */
103
- constructor(host) {
104
- this.#host = host;
105
- }
106
-
107
- #stateFor(output) {
108
- let state = this.#state.get(output);
109
- if (!state) {
110
- state = {
111
- stampWarnedAt: new Map(),
112
- landingReportedForRun: undefined,
113
- trueStartByIndex: new Map(),
114
- deviationWarnedAt: new Map()
115
- };
116
- this.#state.set(output, state);
117
- }
118
- return state;
119
- }
120
-
121
- trueStartAt(output, index) {
122
- return this.#stateFor(output).trueStartByIndex.get(index);
123
- }
124
-
125
- /**
126
- * Start time (seconds, 0-based) of segment `index`, from the session's
127
- * boundary table. Clamped to valid range.
128
- *
129
- * @param {HlsSession} session
130
- * @param {number} index
131
- * @returns {number}
132
- */
133
- segmentStartTime(session, index) {
134
- // The LIVE table, deliberately: this is where the file is cut now, which is
135
- // a different question from what the player was told. The published table
136
- // is passed as absent so that one arithmetic serves both.
137
- return publishedStartTime({ boundaries: session.timeline.boundaries }, index, this.#host.segmentDurationSec);
138
- }
139
-
140
- /**
141
- * The boundary table the player is working from: the one its playlist was
142
- * written from, falling back to the live table when no playlist was built
143
- * from a table at all (no duration, so no synthetic playlist — and then
144
- * nothing the player holds contradicts it).
145
- *
146
- * @param {HlsSession} session
147
- * @returns {number[]}
148
- */
149
- publishedGridFor(session) {
150
- return publishedGridOf(session.timeline);
151
- }
152
-
153
- /**
154
- * Where a run beginning at `index` must be positioned: the time the PLAYER
155
- * was told that segment starts at.
156
- *
157
- * Public because it is the invariant this class has broken twice, and a
158
- * private one cannot be pinned by a test. It must always be the table the cut
159
- * list is taken from — see the comment where a run is started.
160
- *
161
- * @param {HlsSession} session
162
- * @param {number} index
163
- * @returns {number}
164
- */
165
- runStartTimeFor(session, index) {
166
- return this.publishedStartTime(session, index);
167
- }
168
-
169
- publishedStartTime(session, index) {
170
- return publishedStartTime(session.timeline, index, this.#host.segmentDurationSec);
171
- }
172
-
173
- /**
174
- * Report a segment whose own timeline disagrees with the playlist by more
175
- * than a player will bridge.
176
- *
177
- * Once per segment per five seconds, like every other repeating condition
178
- * here: the same segment is requested again and again while it is refused,
179
- * and a line each time buries the first one.
180
- *
181
- * @param {HlsSession} session
182
- * @param {number} index
183
- * @param {number} trueStart
184
- * @param {number} publishedStart
185
- * @returns {void}
186
- */
187
- notePlaylistDisagreement(session, index, trueStart, publishedStart) {
188
- // Within what the player bridges it is no disagreement worth a line.
189
- if (!(Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC)) {
190
- return;
191
- }
192
- const now = Date.now();
193
- const state = this.#stateFor(session);
194
- if (now - (state.stampWarnedAt.get(index) ?? 0) < 5_000) {
195
- return;
196
- }
197
- state.stampWarnedAt.set(index, now);
198
- this.#host.logger.warn(
199
- `transcode ${session.id} segment #${index} carries ${trueStart.toFixed(3)}s while the playlist ` +
200
- `the player holds says ${publishedStart.toFixed(3)}s — a gap of ` +
201
- `${Math.abs(trueStart - publishedStart).toFixed(3)}s, beyond the ${PLAYER_BUFFER_HOLE_SEC}s a player ` +
202
- "bridges; stamping it where the playlist says so the fragment lands where it was asked for"
203
- );
204
- }
205
-
206
- /**
207
- * Segment index whose span contains time `t` (0-based), via the boundary
208
- * table.
209
- *
210
- * @param {HlsSession} session
211
- * @param {number} t
212
- * @returns {number}
213
- */
214
- segmentIndexForTime(session, t) {
215
- // The player's grid, for the same reason the cut list uses it: the time
216
- // being resolved came from the playlist the player holds, so the index it
217
- // means is the index that playlist gives it.
218
- return segmentIndexForTime(this.publishedGridFor(session), t, this.#host.segmentDurationSec);
219
- }
220
-
221
- /**
222
- * Where the run REALLY began, against where it was asked to begin.
223
- *
224
- * The first piece a run produces is the only statement of this that exists,
225
- * and until now nothing compared the two. They disagree whenever the seek
226
- * lands somewhere other than the time asked for — which, before the landing
227
- * offset, was every run on a Matroska source with B-frames, by exactly one
228
- * keyframe interval. Every cut of the run then inherits it, because
229
- * `-segment_times` is measured from the landing.
230
- *
231
- * Said once per run, and only when it matters: within what a player bridges
232
- * there is nothing to report.
233
- *
234
- * @param {HlsSession} session
235
- * @param {number} index
236
- * @param {number} trueStart
237
- * @returns {void}
238
- */
239
- noteRunLanding(session, index, trueStart) {
240
- const state = this.#stateFor(session);
241
- if (runStartingAt(this.#host.runsOf(session), index) === null || state.landingReportedForRun === index) {
242
- return;
243
- }
244
- state.landingReportedForRun = index;
245
- // What the run was ASKED for, taken from the run itself rather than looked
246
- // up again in a table. The two used to be the same lookup; they stopped
247
- // being so when a run began positioning on the published grid while this
248
- // read the live one, which made a perfect landing report a drift equal to
249
- // the distance between the tables — and cancelled a real landing error of
250
- // the same size to zero. A run also has one legitimate position that is in
251
- // no table at all: the realignment that starts the sound where the copied
252
- // picture truly begins.
253
- const asked = runStartingAt(this.#host.runsOf(session), index)?.progress?.startPositionSeconds ??
254
- this.runStartTimeFor(session, index);
255
- const drift = trueStart - asked;
256
- if (!Number.isFinite(drift) || Math.abs(drift) <= PLAYER_BUFFER_HOLE_SEC) {
257
- return;
258
- }
259
- this.#host.logger.warn(
260
- `transcode ${session.id} run began at ${trueStart.toFixed(3)}s but was asked for ` +
261
- `${asked.toFixed(3)}s — ${drift > 0 ? "+" : ""}${drift.toFixed(3)}s, and every cut of this ` +
262
- "run is measured from where it began, so the whole run is that far from its playlist"
263
- );
264
- }
265
-
266
- noteIndexAccuracy(session, index, trueStart, declaredStart) {
267
- const deviation = Math.abs(trueStart - declaredStart);
268
- // Where each produced segment truly began, kept so that a player reporting
269
- // a stall can be ANSWERED rather than merely believed. Bounded: only the
270
- // recent past can be the subject of such a report, and an unbounded map on
271
- // a two-hour film is a leak.
272
- const state = this.#stateFor(session);
273
- state.trueStartByIndex.set(index, trueStart);
274
- if (state.trueStartByIndex.size > TRUE_START_MEMORY) {
275
- const oldest = state.trueStartByIndex.keys().next();
276
- if (!oldest.done) {
277
- state.trueStartByIndex.delete(oldest.value);
278
- }
279
- }
280
- // ONE READING, AND WHOSE FACT IT IS DEPENDS ON HOW THIS OUTPUT IS MADE. The
281
- // three branches below already say the distinction when they WARN; until
282
- // 2026-09-15 what they COUNTED threw it away, into a tally held per (file,
283
- // grid) that a picture and the soundtrack inside the same file share.
284
- //
285
- // Only a COPY can say anything about the file: it is cut at a keyframe of
286
- // that file and nowhere else. So a copy's landing goes to the file's own
287
- // table, where it outlives this grid and reaches every step and every later
288
- // session of the file.
289
- if (session.spec.carries !== "audio-only" && !session.spec.transcodesVideo) {
290
- session.keyframes?.witness({
291
- index,
292
- trueStart,
293
- deviationSec: deviation,
294
- toleranceSec: SEGMENT_START_DISAGREEMENT_SEC
295
- });
296
- }
297
- // And every output records its own landing against its own published grid,
298
- // which is what says whether a step will splice and how far a soundtrack
299
- // stands from the picture.
300
- session.output?.noteLanding({ index, deviationSec: deviation, toleranceSec: SEGMENT_START_DISAGREEMENT_SEC });
301
- if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
302
- // Which boundary the true start DOES match, if any. This is what tells
303
- // the two possible faults apart, and they need opposite fixes: matching
304
- // boundary #N-1 means our numbering is shifted by one — a fault in this
305
- // code, where the run begins — while matching nothing means the container
306
- // index describes times the file does not have. Measured 2026-08-11,
307
- // three samples all matched N-1, which is why the line now says so
308
- // instead of leaving it to be inferred from the numbers.
309
- const at = this.boundaryIndexAt(session, trueStart);
310
- // Once per segment per five seconds, like `#notePlaylistDisagreement`
311
- // beside it. The same segment is produced and served again and again
312
- // while it is refused, and — since a run keeps cutting on the list it was
313
- // launched with — a soundtrack whose grid has moved under it deviates on
314
- // EVERY segment for the life of that run. A line each time buries the
315
- // first one, which is the one somebody is reading the log for.
316
- const state = this.#stateFor(session);
317
- const lastWarnedAt = state.deviationWarnedAt.get(index) ?? 0;
318
- if (Date.now() - lastWarnedAt >= 5_000) {
319
- state.deviationWarnedAt.set(index, Date.now());
320
- this.#host.logger.warn(
321
- `transcode ${session.id} segment #${index} really starts at ` +
322
- `${trueStart.toFixed(3)}s (boundary ${at === null ? "none" : `#${at}`}), ` +
323
- `the grid says ${declaredStart.toFixed(3)}s — ` +
324
- (session.spec.carries === "audio-only"
325
- // A soundtrack is cut exactly where it was asked to be, so a
326
- // disagreement here is not a reading about the file at all: it is the
327
- // distance between this run's own cuts and a grid the picture has
328
- // since corrected under it. Said plainly, because the same sentence
329
- // used to claim a keyframe index was wrong when no keyframe was
330
- // involved on this side of the stream.
331
- ? "sound is cut where it is asked to be; this is the picture's grid having moved, not the index"
332
- : session.spec.transcodesVideo
333
- // A re-encode was TOLD to put a keyframe here and did not, so this
334
- // rung's segments no longer stand where the stream it accompanies
335
- // would have put them. That is a broken splice, not a wrong index.
336
- ? "this rung did not cut where its grid says; a switch to it will not join cleanly"
337
- // A copy. The two possible faults need opposite fixes and the
338
- // numbers already tell them apart, so the sentence follows THEM
339
- // rather than the branch it is printed from. Until 2026-08-21 it
340
- // blamed the index either way — including through a session whose
341
- // segments each held the boundary two, then four places before
342
- // their own number, which is this code's own definition of a fault
343
- // in this code. That sentence is what sent the reading of that
344
- // session after the file instead of after the arithmetic.
345
- : at === null
346
- ? "the container's keyframe index disagrees with the file; using the file"
347
- : `this began at another boundary of the same list, ${index - at} place(s) before ` +
348
- "its own number — the numbering of this run is shifted, not the index")
349
- );
350
- }
351
- }
352
- // Said as the evidence accumulates, not only when the session is disposed.
353
- // A proxy restart takes its sessions with it — every addon update does —
354
- // and a summary that only ever appears at the end is a summary that is
355
- // routinely never written. Twenty-five distinct boundaries is enough for
356
- // the proportion to mean something and rare enough not to repeat itself.
357
- const counted = session.output?.piecesLanded ?? 0;
358
- if (counted > 0 && counted % 25 === 0) {
359
- this.logIndexAccuracy(session);
360
- }
361
- this.correctBoundaryFromSegment(session, index, trueStart);
362
- }
363
-
364
- /**
365
- * Replace a boundary the index got wrong with the time the file actually has.
366
- *
367
- * The grid of a copied stream comes from the container's keyframe index,
368
- * because a copy can only be cut where a keyframe already is and nothing
369
- * cheaper than the index can say where that is before a single byte is
370
- * encoded. An index can be wrong — proven 2026-08-12 by reproducing both
371
- * cases against the same file: with an honest index every produced segment
372
- * started exactly where declared, and with one moved 1.8 s the segments
373
- * started 1.8 s early, matching no boundary at all. The field showed the
374
- * second shape.
375
- *
376
- * The truth arrives anyway, one segment at a time: a produced piece states
377
- * where it really begins. Writing it back makes the grid describe the file
378
- * instead of the index — and it is what lets a re-encoded rung be cut to
379
- * match a copied one, because the rung is then forced onto times the copy
380
- * really uses. The alternative considered and rejected was to stop offering
381
- * quality on files with a bad index, which is not a fix but a withdrawal.
382
- *
383
- * The whole family shares one grid, so a correction reaches all of it: a rung
384
- * created afterwards inherits a table that is true wherever anyone has looked.
385
- *
386
- * @param {HlsSession} session
387
- * @param {number} index
388
- * @param {number} trueStart
389
- * @returns {void}
390
- */
391
- correctBoundaryFromSegment(session, index, trueStart) {
392
- // Only the picture may move the grid, because the grid IS the picture's
393
- // cut list: it is built from the container's keyframe index, and a copied
394
- // stream can be cut nowhere else. A soundtrack has no keyframes — it is cut
395
- // exactly where `-segment_times` asks, to within one audio frame — so its
396
- // reading measures nothing about the grid and everything about itself.
397
- //
398
- // Writing it into the shared table is how one film ended up with two
399
- // answers for one boundary, each side correctly describing its own stream
400
- // and each overwriting the other: field 2026-08-20, segment #521 of
401
- // "Minions.and.Monsters.1080p.mkv" corrected 2086.084s → 2084.082s by the
402
- // picture at 11:14:16.939 and 2084.082s → 2086.033s by the sound 1.6s
403
- // later — 1.951s apart, against the 0.25s that stops a correction and the
404
- // 0.5s a player bridges. The next reading disagrees with the table again,
405
- // so it never converges and never stops.
406
- if (session.spec.carries === "audio-only") {
407
- return;
408
- }
409
- const boundaries = session.timeline.boundaries;
410
- if (!Array.isArray(boundaries) || index <= 0 || index >= boundaries.length - 1) {
411
- // Index 0 is the start of the file and the last entry is its end; neither
412
- // is a cut, and neither can be learned from a segment.
413
- return;
414
- }
415
- if (Math.abs(boundaries[index] - trueStart) <= SEGMENT_START_DISAGREEMENT_SEC) {
416
- return;
417
- }
418
- // A correction that would put this boundary at or past its neighbours is not
419
- // a correction — it is a reading from a run that started somewhere else, and
420
- // applying it would make the table describe nothing at all.
421
- if (trueStart <= boundaries[index - 1] || trueStart >= boundaries[index + 1]) {
422
- return;
423
- }
424
- const wasAt = boundaries[index];
425
- // One write, because there is one table. Where a file is cut is a fact
426
- // about the FILE, so every session of it holds the same array rather than a
427
- // copy of it — which is what this loop used to keep in step, member by
428
- // member, and only for members that happened to exist at the time. A
429
- // session created afterwards used to inherit a copy taken at that moment;
430
- // now it is handed the table itself.
431
- boundaries[index] = trueStart;
432
- this.#host.logger.info(
433
- `transcode ${session.id} boundary #${index} corrected ${wasAt.toFixed(3)}s → ` +
434
- `${trueStart.toFixed(3)}s from the file itself` +
435
- `, and the live table is now ${describeGridDrift(this.publishedGridFor(session), boundaries)}` +
436
- " from the one the player holds"
437
- );
438
- // And every OTHER member whose run begins at this very boundary is moved
439
- // to the same instant.
440
- //
441
- // Why they were not there already: the two branches are asked for the same
442
- // time and land in different places. The picture cannot begin anywhere but
443
- // a real keyframe, and it may not begin before the time asked for — that
444
- // content belongs to the previous segment — so it moves FORWARD to the next
445
- // one, by up to the keyframe spacing (0.58-2.96 s measured 2026-08-17).
446
- // A soundtrack has no keyframes: it begins exactly where asked, to within
447
- // one audio frame. So after every restart the two runs of one film began up
448
- // to three seconds apart, each correctly labelled with where it really was,
449
- // and the viewer got sound with no new picture for the difference.
450
- //
451
- // The picture's true start is a MEASURED quantity — read from the piece it
452
- // just produced, which is what the correction above is — so the soundtrack
453
- // can be put exactly there instead of at the time the container's table
454
- // claimed. It converges: once the boundary holds the true time, the next
455
- // reading agrees with it and the guard above returns before doing anything.
456
- for (const member of this.#host.outputs.familyOf(session)) {
457
- if (member === session || runStartingAt(this.#host.runsOf(member), index) === null) {
458
- continue;
459
- }
460
- this.#host.logger.info(
461
- `transcode ${member.id} begins at #${index}, which really starts ` +
462
- `${(trueStart - wasAt).toFixed(3)}s later than the table said — restarting it there ` +
463
- `so picture and sound begin together`
464
- );
465
- // STOPPED, and its replacement is the plan's to place. What has been
466
- // learned here is that this run is producing at the wrong instant, which
467
- // nothing but the piece it produced could say — the plan reasons about
468
- // numbers and cannot know it. So the fact is acted on where it is known,
469
- // and only as far as it is known: the run that is wrong goes, the stretch
470
- // it held returns to the map, and where the next one stands follows from
471
- // where the viewers are.
472
- //
473
- // The corrected instant reaches that replacement through the live table,
474
- // which this function has just written it into and which every session of
475
- // the file shares. It used to be passed as an argument from here, so only
476
- // a run started by this line ever had it.
477
- this.#host.stopEncodeRun(member, `#${index} really begins ${(trueStart - wasAt).toFixed(3)}s later than the table said`);
478
- }
479
- this.#host.planEncodersSoon();
480
- }
481
-
482
- /**
483
- * The boundary a time falls on, or null when it falls on none of them.
484
- *
485
- * Within the same tolerance a disagreement is judged by, so "matches boundary
486
- * #N-1" and "matches nothing" mean what they say.
487
- *
488
- * @param {HlsSession} session
489
- * @param {number} seconds
490
- * @returns {number | null}
491
- */
492
- boundaryIndexAt(session, seconds, table) {
493
- // The table is nameable because the two answer different questions. The
494
- // LIVE one says "is this a cut this file actually has", which is what a
495
- // reading taken off a produced segment is about. The PUBLISHED one says "is
496
- // this a cut the player believes in", which is what a report from the player
497
- // is about. Answering one with the other prints an index from one grid
498
- // beside a time from the other.
499
- const boundaries = Array.isArray(table) ? table : session.timeline.boundaries;
500
- if (!Array.isArray(boundaries)) {
501
- return null;
502
- }
503
- for (let index = 0; index < boundaries.length; index += 1) {
504
- if (Math.abs(boundaries[index] - seconds) <= SEGMENT_START_DISAGREEMENT_SEC) {
505
- return index;
506
- }
507
- }
508
- return null;
509
- }
510
-
511
- /**
512
- * Where this output's pieces have been landing, and — where this output is a
513
- * copy — what that has shown about the FILE's own keyframe table.
514
- *
515
- * TWO LINES, because they are two facts with two owners and two lifetimes.
516
- * The landing is this output's and dies with it. The table's accuracy belongs
517
- * to the file, is learned only from a copy, and is the same finding for every
518
- * step and every later session of that file — so it names the file and not
519
- * the session, and it is written only by the output that can witness it.
520
- *
521
- * Written even when nothing disagreed, because that is the finding: with only
522
- * the per-piece warning, silence could not be told from nobody having
523
- * watched. Skipped where nothing was produced, which says neither.
524
- *
525
- * @param {HlsSession} session
526
- * @returns {void}
527
- */
528
- logIndexAccuracy(session) {
529
- const copiedPicture = session.spec.carries !== "audio-only" && !session.spec.transcodesVideo;
530
- const landing = session.output?.landing ?? null;
531
- if (landing) {
532
- this.#host.logger.info(
533
- // The session id, because without it this line cannot be attributed. A
534
- // family produces one of these per member — the picture, each step,
535
- // each soundtrack — and on 2026-08-17 the picture's was read as the
536
- // sound's, from a neighbouring log line, and a roadmap item was written
537
- // against the wrong half of the stream.
538
- `landing ${session.id.slice(0, 8)} ` +
539
- `${session.spec.carries === "audio-only" ? "sound" : copiedPicture ? "copied picture" : "re-encoded picture"} ` +
540
- `"${session.file.name}": ${landing.disagreed} of ${landing.checked} produced pieces started ` +
541
- `away from this output's own playlist, median ${landing.medianDeviationSec.toFixed(3)}s ` +
542
- `worst ${landing.maxDeviationSec.toFixed(3)}s` +
543
- (landing.firstDisagreementIndex >= 0 ? ` (first at #${landing.firstDisagreementIndex})` : "") +
544
- ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s] — ` +
545
- (session.spec.carries === "audio-only"
546
- // A soundtrack is cut exactly where it is asked to be, so this is not
547
- // a reading about any keyframe: it is how far this run's cuts stand
548
- // from a grid the picture has corrected under it.
549
- ? "sound is cut where it is asked to be, so this is the picture's grid having moved"
550
- : copiedPicture
551
- // A copy cannot be cut anywhere but a real keyframe, so this figure
552
- // is also evidence about the file, reported on the line below.
553
- ? "a copy is cut at the file's own keyframes, so this is also evidence about its table"
554
- // A re-encode was TOLD to put a keyframe at each of these instants.
555
- : "this rung was told where to cut; what it missed will not splice cleanly")
556
- );
557
- }
558
- if (!copiedPicture) {
559
- return;
560
- }
561
- const evidence = session.keyframes?.evidence ?? null;
562
- if (!evidence) {
563
- return;
564
- }
565
- this.#host.logger.info(
566
- // Named by the FILE, with no session id in it, because that is what it is
567
- // about: the same answer for every step and every viewer of these bytes.
568
- `keyframe-index ${session.keyframes?.format ?? "unknown"} "${session.file.name}": ` +
569
- `${evidence.disagreed} of ${evidence.checked} copied pieces began away from the table, ` +
570
- `median ${evidence.medianDeviationSec.toFixed(3)}s worst ${evidence.maxDeviationSec.toFixed(3)}s` +
571
- (evidence.firstDisagreementIndex >= 0 ? ` (first at #${evidence.firstDisagreementIndex})` : "") +
572
- // The discriminator, stated in the same line as the count it explains: a
573
- // piece that began at another time the SAME table names was not
574
- // mis-described by the table — the grid was built over a gap in it.
575
- `; ${evidence.landedOnAnotherKeyframe} of them began at another keyframe the table names` +
576
- ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s, ${session.keyframes?.count ?? 0} keyframes read]`
577
- );
578
- }
579
- }
1
+ /**
2
+ * Where each segment of an output begins in the film, and how the cut table is
3
+ * corrected from what the encoder actually produced.
4
+ *
5
+ * The published grid is what the playlist told the player; a run's landing and
6
+ * the true start of each piece are what ffmpeg did. The two are compared here,
7
+ * a disagreement is said, and a boundary the index got wrong is corrected once
8
+ * for every output of the file.
9
+ */
10
+
11
+ import { segmentIndexForTime } from "./output/playlists.js";
12
+ import { publishedGridFor as publishedGridOf, publishedStartTime } from "./run-command.js";
13
+ import { liveRunsOf } from "./encode-run-state.js";
14
+ // How many produced segments' true start times to remember, so a player's
15
+ // report about one of them can be answered. Two hundred is about twenty
16
+ // minutes of playback at these segment lengths — far more than the recent past
17
+ // a stall report can be about, and small enough to be free.
18
+ const TRUE_START_MEMORY = 200;
19
+ // How far a segment's own recorded start may sit from the one the playlist
20
+ // assigned it before the disagreement is worth a log line. The two are built
21
+ // from the same keyframe index and normally match to the sample; a quarter of a
22
+ // second is below any drift a viewer could notice, so anything above it is the
23
+ // index being wrong about where a keyframe is rather than rounding.
24
+ const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
25
+ /**
26
+ * How far a fragment may land from where the playlist put it before the player
27
+ * stops recognising it as buffered.
28
+ *
29
+ * hls.js's own `maxBufferHole`, whose default is 0.5 s: a gap smaller than this
30
+ * is skipped, a gap larger is a hole, and a fragment appended across one is
31
+ * judged not to have loaded — so the player asks for it again, and again.
32
+ * Taken from the player's published default rather than chosen here.
33
+ */
34
+ export const PLAYER_BUFFER_HOLE_SEC = 0.5;
35
+ /**
36
+ * A live run of this session that begins exactly at this number, if there is
37
+ * one.
38
+ *
39
+ * @param {object[] | Set<object>} runs
40
+ * @param {number} index
41
+ * @returns {object | null}
42
+ */
43
+ function runStartingAt(runs, index) {
44
+ return liveRunsOf(runs).find((run) => run.from === index) ?? null;
45
+ }
46
+
47
+ /**
48
+ * How far the live boundary table has moved from the one the player holds, said
49
+ * in words.
50
+ *
51
+ * The corrections are applied one boundary at a time and each is small enough
52
+ * to look harmless; what nobody was watching is the total. It matters because a
53
+ * run positioned on one table and cut on the other carries their difference into
54
+ * every cut it makes — the fault of 2026-08-21, where the distance reached two
55
+ * whole segments after one seek and four after the next. Printed beside each
56
+ * correction so the total is visible while it is still small.
57
+ *
58
+ * @param {number[]} published - The table the playlist text was written from.
59
+ * @param {number[]} live - The table corrected from produced segments.
60
+ * @returns {string} A phrase, always readable, never throwing on odd input.
61
+ */
62
+ export function describeGridDrift(published, live) {
63
+ if (!Array.isArray(published) || !Array.isArray(live) || published.length === 0) {
64
+ return "not comparable";
65
+ }
66
+ if (published.length !== live.length) {
67
+ return `a different length (${published.length} against ${live.length})`;
68
+ }
69
+ let apart = 0;
70
+ let worst = 0;
71
+ let worstAt = -1;
72
+ for (let index = 0; index < published.length; index += 1) {
73
+ // Rounded to the millisecond BEFORE comparing, not only before printing.
74
+ // Two boundaries moved by the same amount differ in the last bits of a
75
+ // double, so an unrounded comparison picks between them by an accident
76
+ // invisible in the printed figure — and the line would name a boundary the
77
+ // reader cannot tell apart from the one before it. Rounded, ties keep the
78
+ // earliest, which is also the one worth looking at first.
79
+ const distance = Math.round(Math.abs(live[index] - published[index]) * 1000) / 1000;
80
+ if (distance <= 0.001) {
81
+ continue;
82
+ }
83
+ apart += 1;
84
+ if (distance > worst) {
85
+ worst = distance;
86
+ worstAt = index;
87
+ }
88
+ }
89
+ if (apart === 0) {
90
+ return "identical";
91
+ }
92
+ return `${apart} of ${published.length} boundaries apart, worst ${worst.toFixed(3)}s at #${worstAt}`;
93
+ }
94
+
95
+ export class OutputTimes {
96
+ #state = new WeakMap();
97
+ /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
98
+ #host;
99
+
100
+ /**
101
+ * @param {object} host - `logger`, `runsOf`, `stopEncodeRun`, `planEncodersSoon`, `outputs`, `segmentDurationSec`
102
+ */
103
+ constructor(host) {
104
+ this.#host = host;
105
+ }
106
+
107
+ #stateFor(output) {
108
+ let state = this.#state.get(output);
109
+ if (!state) {
110
+ state = {
111
+ stampWarnedAt: new Map(),
112
+ landingReportedForRun: undefined,
113
+ trueStartByIndex: new Map(),
114
+ deviationWarnedAt: new Map()
115
+ };
116
+ this.#state.set(output, state);
117
+ }
118
+ return state;
119
+ }
120
+
121
+ trueStartAt(output, index) {
122
+ return this.#stateFor(output).trueStartByIndex.get(index);
123
+ }
124
+
125
+ /**
126
+ * Start time (seconds, 0-based) of segment `index`, from the session's
127
+ * boundary table. Clamped to valid range.
128
+ *
129
+ * @param {HlsSession} session
130
+ * @param {number} index
131
+ * @returns {number}
132
+ */
133
+ segmentStartTime(session, index) {
134
+ // The LIVE table, deliberately: this is where the file is cut now, which is
135
+ // a different question from what the player was told. The published table
136
+ // is passed as absent so that one arithmetic serves both.
137
+ return publishedStartTime({ boundaries: session.timeline.boundaries }, index, this.#host.segmentDurationSec);
138
+ }
139
+
140
+ /**
141
+ * The boundary table the player is working from: the one its playlist was
142
+ * written from, falling back to the live table when no playlist was built
143
+ * from a table at all (no duration, so no synthetic playlist — and then
144
+ * nothing the player holds contradicts it).
145
+ *
146
+ * @param {HlsSession} session
147
+ * @returns {number[]}
148
+ */
149
+ publishedGridFor(session) {
150
+ return publishedGridOf(session.timeline);
151
+ }
152
+
153
+ /**
154
+ * Where a run beginning at `index` must be positioned: the time the PLAYER
155
+ * was told that segment starts at.
156
+ *
157
+ * Public because it is the invariant this class has broken twice, and a
158
+ * private one cannot be pinned by a test. It must always be the table the cut
159
+ * list is taken from — see the comment where a run is started.
160
+ *
161
+ * @param {HlsSession} session
162
+ * @param {number} index
163
+ * @returns {number}
164
+ */
165
+ runStartTimeFor(session, index) {
166
+ return this.publishedStartTime(session, index);
167
+ }
168
+
169
+ publishedStartTime(session, index) {
170
+ return publishedStartTime(session.timeline, index, this.#host.segmentDurationSec);
171
+ }
172
+
173
+ /**
174
+ * Report a segment whose own timeline disagrees with the playlist by more
175
+ * than a player will bridge.
176
+ *
177
+ * Once per segment per five seconds, like every other repeating condition
178
+ * here: the same segment is requested again and again while it is refused,
179
+ * and a line each time buries the first one.
180
+ *
181
+ * @param {HlsSession} session
182
+ * @param {number} index
183
+ * @param {number} trueStart
184
+ * @param {number} publishedStart
185
+ * @returns {void}
186
+ */
187
+ notePlaylistDisagreement(session, index, trueStart, publishedStart) {
188
+ // Within what the player bridges it is no disagreement worth a line.
189
+ if (!(Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC)) {
190
+ return;
191
+ }
192
+ const now = Date.now();
193
+ const state = this.#stateFor(session);
194
+ if (now - (state.stampWarnedAt.get(index) ?? 0) < 5_000) {
195
+ return;
196
+ }
197
+ state.stampWarnedAt.set(index, now);
198
+ this.#host.logger.warn(
199
+ `transcode ${session.id} segment #${index} carries ${trueStart.toFixed(3)}s while the playlist ` +
200
+ `the player holds says ${publishedStart.toFixed(3)}s — a gap of ` +
201
+ `${Math.abs(trueStart - publishedStart).toFixed(3)}s, beyond the ${PLAYER_BUFFER_HOLE_SEC}s a player ` +
202
+ "bridges; stamping it where the playlist says so the fragment lands where it was asked for"
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Segment index whose span contains time `t` (0-based), via the boundary
208
+ * table.
209
+ *
210
+ * @param {HlsSession} session
211
+ * @param {number} t
212
+ * @returns {number}
213
+ */
214
+ segmentIndexForTime(session, t) {
215
+ // The player's grid, for the same reason the cut list uses it: the time
216
+ // being resolved came from the playlist the player holds, so the index it
217
+ // means is the index that playlist gives it.
218
+ return segmentIndexForTime(this.publishedGridFor(session), t, this.#host.segmentDurationSec);
219
+ }
220
+
221
+ /**
222
+ * Where the run REALLY began, against where it was asked to begin.
223
+ *
224
+ * The first piece a run produces is the only statement of this that exists,
225
+ * and until now nothing compared the two. They disagree whenever the seek
226
+ * lands somewhere other than the time asked for — which, before the landing
227
+ * offset, was every run on a Matroska source with B-frames, by exactly one
228
+ * keyframe interval. Every cut of the run then inherits it, because
229
+ * `-segment_times` is measured from the landing.
230
+ *
231
+ * Said once per run, and only when it matters: within what a player bridges
232
+ * there is nothing to report.
233
+ *
234
+ * @param {HlsSession} session
235
+ * @param {number} index
236
+ * @param {number} trueStart
237
+ * @returns {void}
238
+ */
239
+ noteRunLanding(session, index, trueStart) {
240
+ const state = this.#stateFor(session);
241
+ if (runStartingAt(this.#host.runsOf(session), index) === null || state.landingReportedForRun === index) {
242
+ return;
243
+ }
244
+ state.landingReportedForRun = index;
245
+ // What the run was ASKED for, taken from the run itself rather than looked
246
+ // up again in a table. The two used to be the same lookup; they stopped
247
+ // being so when a run began positioning on the published grid while this
248
+ // read the live one, which made a perfect landing report a drift equal to
249
+ // the distance between the tables — and cancelled a real landing error of
250
+ // the same size to zero. A run also has one legitimate position that is in
251
+ // no table at all: the realignment that starts the sound where the copied
252
+ // picture truly begins.
253
+ const asked = runStartingAt(this.#host.runsOf(session), index)?.progress?.startPositionSeconds ??
254
+ this.runStartTimeFor(session, index);
255
+ const drift = trueStart - asked;
256
+ if (!Number.isFinite(drift) || Math.abs(drift) <= PLAYER_BUFFER_HOLE_SEC) {
257
+ return;
258
+ }
259
+ this.#host.logger.warn(
260
+ `transcode ${session.id} run began at ${trueStart.toFixed(3)}s but was asked for ` +
261
+ `${asked.toFixed(3)}s — ${drift > 0 ? "+" : ""}${drift.toFixed(3)}s, and every cut of this ` +
262
+ "run is measured from where it began, so the whole run is that far from its playlist"
263
+ );
264
+ }
265
+
266
+ noteIndexAccuracy(session, index, trueStart, declaredStart) {
267
+ const deviation = Math.abs(trueStart - declaredStart);
268
+ // Where each produced segment truly began, kept so that a player reporting
269
+ // a stall can be ANSWERED rather than merely believed. Bounded: only the
270
+ // recent past can be the subject of such a report, and an unbounded map on
271
+ // a two-hour film is a leak.
272
+ const state = this.#stateFor(session);
273
+ state.trueStartByIndex.set(index, trueStart);
274
+ if (state.trueStartByIndex.size > TRUE_START_MEMORY) {
275
+ const oldest = state.trueStartByIndex.keys().next();
276
+ if (!oldest.done) {
277
+ state.trueStartByIndex.delete(oldest.value);
278
+ }
279
+ }
280
+ // ONE READING, AND WHOSE FACT IT IS DEPENDS ON HOW THIS OUTPUT IS MADE. The
281
+ // three branches below already say the distinction when they WARN; until
282
+ // 2026-09-15 what they COUNTED threw it away, into a tally held per (file,
283
+ // grid) that a picture and the soundtrack inside the same file share.
284
+ //
285
+ // Only a COPY can say anything about the file: it is cut at a keyframe of
286
+ // that file and nowhere else. So a copy's landing goes to the file's own
287
+ // table, where it outlives this grid and reaches every step and every later
288
+ // session of the file.
289
+ if (session.spec.carries !== "audio-only" && !session.spec.transcodesVideo) {
290
+ session.keyframes?.witness({
291
+ index,
292
+ trueStart,
293
+ deviationSec: deviation,
294
+ toleranceSec: SEGMENT_START_DISAGREEMENT_SEC
295
+ });
296
+ }
297
+ // And every output records its own landing against its own published grid,
298
+ // which is what says whether a step will splice and how far a soundtrack
299
+ // stands from the picture.
300
+ session.output?.noteLanding({ index, deviationSec: deviation, toleranceSec: SEGMENT_START_DISAGREEMENT_SEC });
301
+ if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
302
+ // Which boundary the true start DOES match, if any. This is what tells
303
+ // the two possible faults apart, and they need opposite fixes: matching
304
+ // boundary #N-1 means our numbering is shifted by one — a fault in this
305
+ // code, where the run begins — while matching nothing means the container
306
+ // index describes times the file does not have. Measured 2026-08-11,
307
+ // three samples all matched N-1, which is why the line now says so
308
+ // instead of leaving it to be inferred from the numbers.
309
+ const at = this.boundaryIndexAt(session, trueStart);
310
+ // Once per segment per five seconds, like `#notePlaylistDisagreement`
311
+ // beside it. The same segment is produced and served again and again
312
+ // while it is refused, and — since a run keeps cutting on the list it was
313
+ // launched with — a soundtrack whose grid has moved under it deviates on
314
+ // EVERY segment for the life of that run. A line each time buries the
315
+ // first one, which is the one somebody is reading the log for.
316
+ const state = this.#stateFor(session);
317
+ const lastWarnedAt = state.deviationWarnedAt.get(index) ?? 0;
318
+ if (Date.now() - lastWarnedAt >= 5_000) {
319
+ state.deviationWarnedAt.set(index, Date.now());
320
+ this.#host.logger.warn(
321
+ `transcode ${session.id} segment #${index} really starts at ` +
322
+ `${trueStart.toFixed(3)}s (boundary ${at === null ? "none" : `#${at}`}), ` +
323
+ `the grid says ${declaredStart.toFixed(3)}s — ` +
324
+ (session.spec.carries === "audio-only"
325
+ // A soundtrack is cut exactly where it was asked to be, so a
326
+ // disagreement here is not a reading about the file at all: it is the
327
+ // distance between this run's own cuts and a grid the picture has
328
+ // since corrected under it. Said plainly, because the same sentence
329
+ // used to claim a keyframe index was wrong when no keyframe was
330
+ // involved on this side of the stream.
331
+ ? "sound is cut where it is asked to be; this is the picture's grid having moved, not the index"
332
+ : session.spec.transcodesVideo
333
+ // A re-encode was TOLD to put a keyframe here and did not, so this
334
+ // rung's segments no longer stand where the stream it accompanies
335
+ // would have put them. That is a broken splice, not a wrong index.
336
+ ? "this rung did not cut where its grid says; a switch to it will not join cleanly"
337
+ // A copy. The two possible faults need opposite fixes and the
338
+ // numbers already tell them apart, so the sentence follows THEM
339
+ // rather than the branch it is printed from. Until 2026-08-21 it
340
+ // blamed the index either way — including through a session whose
341
+ // segments each held the boundary two, then four places before
342
+ // their own number, which is this code's own definition of a fault
343
+ // in this code. That sentence is what sent the reading of that
344
+ // session after the file instead of after the arithmetic.
345
+ : at === null
346
+ ? "the container's keyframe index disagrees with the file; using the file"
347
+ : `this began at another boundary of the same list, ${index - at} place(s) before ` +
348
+ "its own number — the numbering of this run is shifted, not the index")
349
+ );
350
+ }
351
+ }
352
+ // Said as the evidence accumulates, not only when the session is disposed.
353
+ // A proxy restart takes its sessions with it — every addon update does —
354
+ // and a summary that only ever appears at the end is a summary that is
355
+ // routinely never written. Twenty-five distinct boundaries is enough for
356
+ // the proportion to mean something and rare enough not to repeat itself.
357
+ const counted = session.output?.piecesLanded ?? 0;
358
+ if (counted > 0 && counted % 25 === 0) {
359
+ this.logIndexAccuracy(session);
360
+ }
361
+ this.correctBoundaryFromSegment(session, index, trueStart);
362
+ }
363
+
364
+ /**
365
+ * Replace a boundary the index got wrong with the time the file actually has.
366
+ *
367
+ * The grid of a copied stream comes from the container's keyframe index,
368
+ * because a copy can only be cut where a keyframe already is and nothing
369
+ * cheaper than the index can say where that is before a single byte is
370
+ * encoded. An index can be wrong — proven 2026-08-12 by reproducing both
371
+ * cases against the same file: with an honest index every produced segment
372
+ * started exactly where declared, and with one moved 1.8 s the segments
373
+ * started 1.8 s early, matching no boundary at all. The field showed the
374
+ * second shape.
375
+ *
376
+ * The truth arrives anyway, one segment at a time: a produced piece states
377
+ * where it really begins. Writing it back makes the grid describe the file
378
+ * instead of the index — and it is what lets a re-encoded rung be cut to
379
+ * match a copied one, because the rung is then forced onto times the copy
380
+ * really uses. The alternative considered and rejected was to stop offering
381
+ * quality on files with a bad index, which is not a fix but a withdrawal.
382
+ *
383
+ * The whole family shares one grid, so a correction reaches all of it: a rung
384
+ * created afterwards inherits a table that is true wherever anyone has looked.
385
+ *
386
+ * @param {HlsSession} session
387
+ * @param {number} index
388
+ * @param {number} trueStart
389
+ * @returns {void}
390
+ */
391
+ correctBoundaryFromSegment(session, index, trueStart) {
392
+ // Only the picture may move the grid, because the grid IS the picture's
393
+ // cut list: it is built from the container's keyframe index, and a copied
394
+ // stream can be cut nowhere else. A soundtrack has no keyframes — it is cut
395
+ // exactly where `-segment_times` asks, to within one audio frame — so its
396
+ // reading measures nothing about the grid and everything about itself.
397
+ //
398
+ // Writing it into the shared table is how one film ended up with two
399
+ // answers for one boundary, each side correctly describing its own stream
400
+ // and each overwriting the other: field 2026-08-20, segment #521 of
401
+ // "Minions.and.Monsters.1080p.mkv" corrected 2086.084s → 2084.082s by the
402
+ // picture at 11:14:16.939 and 2084.082s → 2086.033s by the sound 1.6s
403
+ // later — 1.951s apart, against the 0.25s that stops a correction and the
404
+ // 0.5s a player bridges. The next reading disagrees with the table again,
405
+ // so it never converges and never stops.
406
+ if (session.spec.carries === "audio-only") {
407
+ return;
408
+ }
409
+ const boundaries = session.timeline.boundaries;
410
+ if (!Array.isArray(boundaries) || index <= 0 || index >= boundaries.length - 1) {
411
+ // Index 0 is the start of the file and the last entry is its end; neither
412
+ // is a cut, and neither can be learned from a segment.
413
+ return;
414
+ }
415
+ if (Math.abs(boundaries[index] - trueStart) <= SEGMENT_START_DISAGREEMENT_SEC) {
416
+ return;
417
+ }
418
+ // A correction that would put this boundary at or past its neighbours is not
419
+ // a correction — it is a reading from a run that started somewhere else, and
420
+ // applying it would make the table describe nothing at all.
421
+ if (trueStart <= boundaries[index - 1] || trueStart >= boundaries[index + 1]) {
422
+ return;
423
+ }
424
+ const wasAt = boundaries[index];
425
+ // One write, because there is one table. Where a file is cut is a fact
426
+ // about the FILE, so every session of it holds the same array rather than a
427
+ // copy of it — which is what this loop used to keep in step, member by
428
+ // member, and only for members that happened to exist at the time. A
429
+ // session created afterwards used to inherit a copy taken at that moment;
430
+ // now it is handed the table itself.
431
+ boundaries[index] = trueStart;
432
+ this.#host.logger.info(
433
+ `transcode ${session.id} boundary #${index} corrected ${wasAt.toFixed(3)}s → ` +
434
+ `${trueStart.toFixed(3)}s from the file itself` +
435
+ `, and the live table is now ${describeGridDrift(this.publishedGridFor(session), boundaries)}` +
436
+ " from the one the player holds"
437
+ );
438
+ // And every OTHER member whose run begins at this very boundary is moved
439
+ // to the same instant.
440
+ //
441
+ // Why they were not there already: the two branches are asked for the same
442
+ // time and land in different places. The picture cannot begin anywhere but
443
+ // a real keyframe, and it may not begin before the time asked for — that
444
+ // content belongs to the previous segment — so it moves FORWARD to the next
445
+ // one, by up to the keyframe spacing (0.58-2.96 s measured 2026-08-17).
446
+ // A soundtrack has no keyframes: it begins exactly where asked, to within
447
+ // one audio frame. So after every restart the two runs of one film began up
448
+ // to three seconds apart, each correctly labelled with where it really was,
449
+ // and the viewer got sound with no new picture for the difference.
450
+ //
451
+ // The picture's true start is a MEASURED quantity — read from the piece it
452
+ // just produced, which is what the correction above is — so the soundtrack
453
+ // can be put exactly there instead of at the time the container's table
454
+ // claimed. It converges: once the boundary holds the true time, the next
455
+ // reading agrees with it and the guard above returns before doing anything.
456
+ for (const member of this.#host.outputs.familyOf(session)) {
457
+ if (member === session || runStartingAt(this.#host.runsOf(member), index) === null) {
458
+ continue;
459
+ }
460
+ this.#host.logger.info(
461
+ `transcode ${member.id} begins at #${index}, which really starts ` +
462
+ `${(trueStart - wasAt).toFixed(3)}s later than the table said — restarting it there ` +
463
+ `so picture and sound begin together`
464
+ );
465
+ // STOPPED, and its replacement is the plan's to place. What has been
466
+ // learned here is that this run is producing at the wrong instant, which
467
+ // nothing but the piece it produced could say — the plan reasons about
468
+ // numbers and cannot know it. So the fact is acted on where it is known,
469
+ // and only as far as it is known: the run that is wrong goes, the stretch
470
+ // it held returns to the map, and where the next one stands follows from
471
+ // where the viewers are.
472
+ //
473
+ // The corrected instant reaches that replacement through the live table,
474
+ // which this function has just written it into and which every session of
475
+ // the file shares. It used to be passed as an argument from here, so only
476
+ // a run started by this line ever had it.
477
+ this.#host.stopEncodeRun(member, `#${index} really begins ${(trueStart - wasAt).toFixed(3)}s later than the table said`);
478
+ }
479
+ this.#host.planEncodersSoon();
480
+ }
481
+
482
+ /**
483
+ * The boundary a time falls on, or null when it falls on none of them.
484
+ *
485
+ * Within the same tolerance a disagreement is judged by, so "matches boundary
486
+ * #N-1" and "matches nothing" mean what they say.
487
+ *
488
+ * @param {HlsSession} session
489
+ * @param {number} seconds
490
+ * @returns {number | null}
491
+ */
492
+ boundaryIndexAt(session, seconds, table) {
493
+ // The table is nameable because the two answer different questions. The
494
+ // LIVE one says "is this a cut this file actually has", which is what a
495
+ // reading taken off a produced segment is about. The PUBLISHED one says "is
496
+ // this a cut the player believes in", which is what a report from the player
497
+ // is about. Answering one with the other prints an index from one grid
498
+ // beside a time from the other.
499
+ const boundaries = Array.isArray(table) ? table : session.timeline.boundaries;
500
+ if (!Array.isArray(boundaries)) {
501
+ return null;
502
+ }
503
+ for (let index = 0; index < boundaries.length; index += 1) {
504
+ if (Math.abs(boundaries[index] - seconds) <= SEGMENT_START_DISAGREEMENT_SEC) {
505
+ return index;
506
+ }
507
+ }
508
+ return null;
509
+ }
510
+
511
+ /**
512
+ * Where this output's pieces have been landing, and — where this output is a
513
+ * copy — what that has shown about the FILE's own keyframe table.
514
+ *
515
+ * TWO LINES, because they are two facts with two owners and two lifetimes.
516
+ * The landing is this output's and dies with it. The table's accuracy belongs
517
+ * to the file, is learned only from a copy, and is the same finding for every
518
+ * step and every later session of that file — so it names the file and not
519
+ * the session, and it is written only by the output that can witness it.
520
+ *
521
+ * Written even when nothing disagreed, because that is the finding: with only
522
+ * the per-piece warning, silence could not be told from nobody having
523
+ * watched. Skipped where nothing was produced, which says neither.
524
+ *
525
+ * @param {HlsSession} session
526
+ * @returns {void}
527
+ */
528
+ logIndexAccuracy(session) {
529
+ const copiedPicture = session.spec.carries !== "audio-only" && !session.spec.transcodesVideo;
530
+ const landing = session.output?.landing ?? null;
531
+ if (landing) {
532
+ this.#host.logger.info(
533
+ // The session id, because without it this line cannot be attributed. A
534
+ // family produces one of these per member — the picture, each step,
535
+ // each soundtrack — and on 2026-08-17 the picture's was read as the
536
+ // sound's, from a neighbouring log line, and a roadmap item was written
537
+ // against the wrong half of the stream.
538
+ `landing ${session.id.slice(0, 8)} ` +
539
+ `${session.spec.carries === "audio-only" ? "sound" : copiedPicture ? "copied picture" : "re-encoded picture"} ` +
540
+ `"${session.file.name}": ${landing.disagreed} of ${landing.checked} produced pieces started ` +
541
+ `away from this output's own playlist, median ${landing.medianDeviationSec.toFixed(3)}s ` +
542
+ `worst ${landing.maxDeviationSec.toFixed(3)}s` +
543
+ (landing.firstDisagreementIndex >= 0 ? ` (first at #${landing.firstDisagreementIndex})` : "") +
544
+ ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s] — ` +
545
+ (session.spec.carries === "audio-only"
546
+ // A soundtrack is cut exactly where it is asked to be, so this is not
547
+ // a reading about any keyframe: it is how far this run's cuts stand
548
+ // from a grid the picture has corrected under it.
549
+ ? "sound is cut where it is asked to be, so this is the picture's grid having moved"
550
+ : copiedPicture
551
+ // A copy cannot be cut anywhere but a real keyframe, so this figure
552
+ // is also evidence about the file, reported on the line below.
553
+ ? "a copy is cut at the file's own keyframes, so this is also evidence about its table"
554
+ // A re-encode was TOLD to put a keyframe at each of these instants.
555
+ : "this rung was told where to cut; what it missed will not splice cleanly")
556
+ );
557
+ }
558
+ if (!copiedPicture) {
559
+ return;
560
+ }
561
+ const evidence = session.keyframes?.evidence ?? null;
562
+ if (!evidence) {
563
+ return;
564
+ }
565
+ this.#host.logger.info(
566
+ // Named by the FILE, with no session id in it, because that is what it is
567
+ // about: the same answer for every step and every viewer of these bytes.
568
+ `keyframe-index ${session.keyframes?.format ?? "unknown"} "${session.file.name}": ` +
569
+ `${evidence.disagreed} of ${evidence.checked} copied pieces began away from the table, ` +
570
+ `median ${evidence.medianDeviationSec.toFixed(3)}s worst ${evidence.maxDeviationSec.toFixed(3)}s` +
571
+ (evidence.firstDisagreementIndex >= 0 ? ` (first at #${evidence.firstDisagreementIndex})` : "") +
572
+ // The discriminator, stated in the same line as the count it explains: a
573
+ // piece that began at another time the SAME table names was not
574
+ // mis-described by the table — the grid was built over a gap in it.
575
+ `; ${evidence.landedOnAnotherKeyframe} of them began at another keyframe the table names` +
576
+ ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s, ${session.keyframes?.count ?? 0} keyframes read]`
577
+ );
578
+ }
579
+ }