@torrent-tv/proxy 2.89.1 → 2.89.2

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 (442) 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 +6 -1
  13. package/CHANGELOG.md +2299 -2288
  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/docs/download-architecture.md +261 -261
  22. package/docs/encode-architecture.md +729 -729
  23. package/docs/encode-run-state.md +96 -96
  24. package/docs/logs.md +97 -97
  25. package/docs/playback-start-forecast.md +42 -8
  26. package/docs/socks5-egress-research.md +114 -114
  27. package/docs/storage-architecture.md +267 -267
  28. package/knip.json +14 -14
  29. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  30. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  31. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  32. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  33. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  34. package/openspec/changes/binary-distribution/proposal.md +53 -53
  35. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  36. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  37. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  38. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  39. package/openspec/changes/cold-start/design.md +98 -98
  40. package/openspec/changes/cold-start/proposal.md +62 -62
  41. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  42. package/openspec/changes/cold-start/tasks.md +48 -48
  43. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  44. package/openspec/changes/disk-cap/proposal.md +37 -37
  45. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  46. package/openspec/changes/disk-cap/tasks.md +19 -19
  47. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  48. package/openspec/changes/proxy-observability/design.md +38 -38
  49. package/openspec/changes/proxy-observability/proposal.md +49 -49
  50. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  51. package/openspec/changes/proxy-observability/tasks.md +21 -21
  52. package/openspec/changes/seek-debounce/design.md +118 -118
  53. package/openspec/changes/seek-debounce/proposal.md +59 -59
  54. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  55. package/openspec/changes/seek-debounce/tasks.md +33 -33
  56. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  57. package/openspec/changes/subtitle-language/proposal.md +50 -50
  58. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  59. package/openspec/changes/subtitle-language/tasks.md +26 -26
  60. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  61. package/openspec/changes/track-selection/design.md +47 -47
  62. package/openspec/changes/track-selection/proposal.md +45 -45
  63. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  64. package/openspec/changes/track-selection/tasks.md +20 -20
  65. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  66. package/openspec/changes/transcode-quality/proposal.md +51 -51
  67. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  68. package/openspec/changes/transcode-quality/tasks.md +63 -63
  69. package/openspec/config.yaml +38 -38
  70. package/package.json +1 -1
  71. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  72. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  73. package/research/double-spawn-2026-09-10.md +171 -171
  74. package/research/handover-2026-09-13.md +209 -209
  75. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  76. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  77. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  78. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  79. package/routes/api/client-logs/post.js +86 -86
  80. package/routes/api/delivery-sink/get.js +72 -72
  81. package/routes/api/link-probe/get.js +51 -51
  82. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  83. package/routes/api/playback-plan/post.js +53 -53
  84. package/routes/api/sources/post.js +36 -36
  85. package/routes/api/sources/stats/get.js +132 -132
  86. package/routes/api/sources/warm/post.js +217 -217
  87. package/routes/api/subtitles/get.js +230 -230
  88. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  89. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  90. package/routes/api/transcode-sessions/post.js +178 -178
  91. package/routes/api/transcode-sessions/progress/get.js +27 -27
  92. package/routes/api/transcode-sessions/release/post.js +41 -41
  93. package/routes/api/transcode-sessions/seek/post.js +63 -63
  94. package/routes/health/get.js +13 -13
  95. package/routes/healthz/get.js +13 -13
  96. package/routes/stream/get.js +380 -380
  97. package/routes/transcode/audio-file/get.js +64 -64
  98. package/routes/transcode/audio-warm/get.js +95 -95
  99. package/routes/transcode/session-file/get.js +52 -52
  100. package/routes/transcode/variant-file/get.js +95 -95
  101. package/routes/transcode/variant-warm/get.js +84 -84
  102. package/scripts/render-run-graph.js +173 -173
  103. package/services/encode/CoverageMap.js +428 -428
  104. package/services/encode/CushionReport.js +345 -345
  105. package/services/encode/EncodeAdmission.js +376 -376
  106. package/services/encode/EncodeOrchestrator.js +1217 -1217
  107. package/services/encode/EncodePlan.js +1366 -1366
  108. package/services/encode/EncodeRun.js +813 -813
  109. package/services/encode/Encoder.js +142 -142
  110. package/services/encode/EncoderSelection.js +38 -38
  111. package/services/encode/LocalObservations.js +359 -359
  112. package/services/encode/NvencEncoder.js +50 -50
  113. package/services/encode/OutputTimes.js +579 -579
  114. package/services/encode/QsvEncoder.js +58 -58
  115. package/services/encode/Renditions.js +2282 -2282
  116. package/services/encode/RunProgress.js +84 -84
  117. package/services/encode/SegmentDemand.js +95 -95
  118. package/services/encode/SoftwareEncoder.js +121 -121
  119. package/services/encode/V4l2m2mEncoder.js +53 -53
  120. package/services/encode/VaapiEncoder.js +66 -66
  121. package/services/encode/args.js +418 -418
  122. package/services/encode/available-share.js +89 -89
  123. package/services/encode/calibration.js +361 -361
  124. package/services/encode/contention.js +128 -128
  125. package/services/encode/decode-cost-fit.js +363 -363
  126. package/services/encode/encode-exit.js +114 -114
  127. package/services/encode/encode-run-state.js +551 -551
  128. package/services/encode/encoder-readings.js +45 -45
  129. package/services/encode/fingerprint.js +322 -322
  130. package/services/encode/host-load.js +174 -174
  131. package/services/encode/index.js +9 -9
  132. package/services/encode/learned-median.js +90 -90
  133. package/services/encode/output/EncodedOutput.js +57 -57
  134. package/services/encode/output/Output.js +131 -131
  135. package/services/encode/output/OutputCatalog.js +414 -414
  136. package/services/encode/output/OutputSpec.js +390 -390
  137. package/services/encode/output/Timeline.js +308 -308
  138. package/services/encode/output/cut-grid.js +107 -107
  139. package/services/encode/output/ladder.js +32 -32
  140. package/services/encode/output/playlists.js +284 -284
  141. package/services/encode/output/rates.js +125 -125
  142. package/services/encode/output-key-format.js +21 -21
  143. package/services/encode/quality/HostLoad.js +525 -525
  144. package/services/encode/quality/HostTimings.js +249 -249
  145. package/services/encode/quality/OutputQualityState.js +28 -28
  146. package/services/encode/quality/QualityController.js +935 -935
  147. package/services/encode/quality/QualityOffer.js +416 -416
  148. package/services/encode/quality/drain-threat.js +47 -47
  149. package/services/encode/quality/link-budget.js +349 -349
  150. package/services/encode/quality/output-format.js +319 -319
  151. package/services/encode/quality/serving-output.js +216 -216
  152. package/services/encode/quality/visible-rung.js +57 -57
  153. package/services/encode/run-budget.js +97 -97
  154. package/services/encode/run-costs.js +155 -155
  155. package/services/encode/run-inputs.js +42 -42
  156. package/services/encode/segment-formats/fmp4.js +476 -458
  157. package/services/encode/segment-formats/index.js +103 -85
  158. package/services/encode/segment-formats/init-compat.js +324 -324
  159. package/services/encode/segment-formats/mp4-boxes.js +604 -541
  160. package/services/encode/segment-formats/mpegts.js +134 -134
  161. package/services/encode/start-stop-cost.js +178 -178
  162. package/services/encode/throughput.js +97 -97
  163. package/services/encode/torrent-cost.js +88 -88
  164. package/services/identity/installation-id.js +38 -38
  165. package/services/media/SourceFile.js +298 -298
  166. package/services/media/SubtitleOrchestrator.js +167 -167
  167. package/services/media/container/KeyframeTable.js +272 -272
  168. package/services/media/container/ebml-reader.js +159 -159
  169. package/services/media/ffmpeg-banner.js +260 -260
  170. package/services/media/keyframe-probe.js +157 -157
  171. package/services/media/media-info-probe.js +93 -93
  172. package/services/media/playback-planner.js +931 -931
  173. package/services/media/tracks/AudioTrack.js +143 -143
  174. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  175. package/services/media/tracks/SubtitleTrack.js +38 -38
  176. package/services/media/tracks/VideoTrack.js +33 -33
  177. package/services/media/tracks/audio-bound.js +233 -233
  178. package/services/media/tracks/index.js +15 -15
  179. package/services/media/tracks/language-detect.js +174 -174
  180. package/services/server/OutputLifecycle.js +418 -418
  181. package/services/server/SegmentServing.js +1150 -1150
  182. package/services/server/ViewerRequests.js +790 -786
  183. package/services/server/WaitLedger.js +142 -142
  184. package/services/server/controllers/PlaybackController.js +40 -40
  185. package/services/server/controllers/SubtitleController.js +109 -109
  186. package/services/server/http-responses.js +60 -60
  187. package/services/server/transcode-session-files.js +325 -325
  188. package/services/server/wire-outputs.js +1024 -1020
  189. package/services/storage/Diagnostics.js +202 -202
  190. package/services/storage/MachineBudget.js +235 -235
  191. package/services/storage/allowance.js +107 -107
  192. package/services/storage/core-dumps.js +106 -106
  193. package/services/storage/files/CompletedFiles.js +391 -391
  194. package/services/storage/files/piece-from-whole-file.js +118 -118
  195. package/services/storage/free.js +71 -71
  196. package/services/storage/keep.js +48 -48
  197. package/services/storage/machine-memory.js +56 -56
  198. package/services/storage/memory-report.js +575 -575
  199. package/services/storage/piece-store/piece-disk-store.js +539 -539
  200. package/services/storage/piece-store/piece-lru.js +495 -495
  201. package/services/storage/returns.js +103 -103
  202. package/services/storage/segment-store/SegmentStore.js +1255 -1255
  203. package/services/storage/wire.js +175 -175
  204. package/services/torrent/demand/DemandRegister.js +202 -202
  205. package/services/torrent/demand/Urgency.js +137 -137
  206. package/services/torrent/demand/Window.js +118 -118
  207. package/services/torrent/demand/index.js +8 -8
  208. package/services/torrent/demand/pieces.js +140 -140
  209. package/services/torrent/download/SwarmSelection.js +386 -386
  210. package/services/torrent/download/registry.js +102 -102
  211. package/services/torrent/download/withdraw-claim.js +80 -80
  212. package/services/torrent/files.js +455 -455
  213. package/services/torrent/naming.js +619 -619
  214. package/services/torrent/piece-store-of.js +29 -29
  215. package/services/torrent/supply-margin.js +262 -262
  216. package/services/torrent/worker/background-fill.js +213 -213
  217. package/services/torrent/worker/channel.js +271 -271
  218. package/services/torrent/worker/client.js +836 -836
  219. package/services/torrent/worker/fastest-wires.js +342 -342
  220. package/services/torrent/worker/handle-state.js +23 -23
  221. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  222. package/services/torrent/worker/piece-reader.js +1078 -1078
  223. package/services/torrent/worker/pool-adapter.js +371 -371
  224. package/services/torrent/worker/protocol.js +184 -184
  225. package/services/torrent/worker/resume-warm.js +107 -107
  226. package/services/torrent/worker/webrtc-shim.js +219 -219
  227. package/services/transport/delivery-probe.js +842 -842
  228. package/services/transport/health-collector.js +53 -53
  229. package/services/transport/nat-classifier.js +231 -231
  230. package/services/transport/packet-witness.js +808 -808
  231. package/services/transport/port-mapper.js +235 -235
  232. package/services/transport/registry-api.js +81 -81
  233. package/services/transport/tunnel-client.js +447 -447
  234. package/services/transport/usrsctp-state.js +175 -175
  235. package/services/transport/webrtc-manager.js +484 -484
  236. package/services/viewer/Assignments.js +340 -340
  237. package/services/viewer/PriorityOrchestrator.js +325 -325
  238. package/services/viewer/Viewer.js +691 -691
  239. package/services/viewer/Viewers.js +368 -368
  240. package/services/viewer/active-output.js +37 -37
  241. package/services/viewer/audio-start.js +46 -46
  242. package/services/viewer/buffer-trend.js +81 -81
  243. package/services/viewer/choices.js +647 -647
  244. package/services/viewer/media-time.js +355 -0
  245. package/services/{server → viewer}/playback-readiness.js +635 -516
  246. package/services/viewer/positions.js +126 -126
  247. package/services/viewer/report-intake.js +46 -46
  248. package/store/source-registry.js +71 -71
  249. package/test/asset-paths.test.js +28 -28
  250. package/test/audio-buffer-continuity.test.js +77 -70
  251. package/test/audio-inventory.test.js +212 -212
  252. package/test/audio-track-bitrate.test.js +40 -40
  253. package/test/auto-quality-step.test.js +707 -707
  254. package/test/available-share.test.js +76 -76
  255. package/test/background-fill.test.js +190 -190
  256. package/test/client-log-file.test.js +110 -110
  257. package/test/completed-files.test.js +115 -115
  258. package/test/concurrent-cost.test.js +148 -148
  259. package/test/container-media-info.test.js +228 -228
  260. package/test/contention.test.js +76 -76
  261. package/test/core-dumps.test.js +57 -57
  262. package/test/coverage-follows-the-disk.test.js +228 -228
  263. package/test/coverage-map.test.js +195 -195
  264. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  265. package/test/cut-times-timeline.test.js +65 -65
  266. package/test/cuts-follow-published-grid.test.js +109 -109
  267. package/test/data-channel-frame.test.js +63 -63
  268. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  269. package/test/dead-torrent-handle.test.js +52 -52
  270. package/test/declared-rates.test.js +116 -116
  271. package/test/declared-tracks.test.js +43 -43
  272. package/test/decode-cost-fit.test.js +202 -202
  273. package/test/decode-cost.test.js +462 -462
  274. package/test/delivery-probe.test.js +393 -393
  275. package/test/demand-register.test.js +206 -206
  276. package/test/dht-bootstrap.test.js +72 -72
  277. package/test/drain-threat.test.js +65 -65
  278. package/test/duplicate-add.test.js +48 -48
  279. package/test/encode-admission.test.js +146 -146
  280. package/test/encode-cost.test.js +283 -283
  281. package/test/encode-exit.test.js +111 -111
  282. package/test/encode-orchestrator.test.js +419 -419
  283. package/test/encode-plan-viewers.test.js +718 -718
  284. package/test/encode-plan.test.js +545 -545
  285. package/test/encode-run-state.test.js +314 -314
  286. package/test/encode-run.test.js +162 -162
  287. package/test/encode-slope.test.js +46 -46
  288. package/test/encoded-output.test.js +39 -39
  289. package/test/encoder-kinds.test.js +122 -122
  290. package/test/encoder-readings.test.js +46 -46
  291. package/test/every-claimant-is-told-its-share.test.js +152 -152
  292. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  293. package/test/failed-start-breaker.test.js +202 -202
  294. package/test/fastest-wires.test.js +143 -143
  295. package/test/ffmpeg-stream-counts.test.js +78 -78
  296. package/test/file-edges.test.js +191 -191
  297. package/test/fmp4-presentation-clock.test.js +146 -162
  298. package/test/fmp4-self-contained.test.js +80 -80
  299. package/test/fmp4-timestamps.test.js +140 -140
  300. package/test/fmp4-track-completeness.test.js +41 -41
  301. package/test/format-keyed-outputs.test.js +78 -78
  302. package/test/health-metrics.test.js +38 -38
  303. package/test/held-request-width.test.js +157 -157
  304. package/test/helpers/encode-run.js +136 -136
  305. package/test/helpers/manager.js +57 -57
  306. package/test/helpers/output-spec.js +63 -63
  307. package/test/host-load.test.js +68 -68
  308. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  309. package/test/init-compat.test.js +219 -219
  310. package/test/input-lost-quiets-the-plan.test.js +261 -261
  311. package/test/input-unavailable.test.js +48 -48
  312. package/test/installation-id.test.js +39 -39
  313. package/test/keeping-period.test.js +83 -83
  314. package/test/keyframe-index-accuracy.test.js +188 -188
  315. package/test/keyframes-belong-to-the-file.test.js +249 -249
  316. package/test/learned-median.test.js +37 -37
  317. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  318. package/test/limit-row-by-area.test.js +60 -60
  319. package/test/link-budget.test.js +112 -112
  320. package/test/link-probe.test.js +56 -56
  321. package/test/local-encoding-data.test.js +273 -273
  322. package/test/logger-repeats.test.js +134 -134
  323. package/test/look-ahead-coverage.test.js +56 -56
  324. package/test/manager-surface.test.js +292 -292
  325. package/test/matroska-cues-track.test.js +192 -192
  326. package/test/media-time.test.js +62 -0
  327. package/test/memory-budget.test.js +390 -390
  328. package/test/move-cost.test.js +216 -216
  329. package/test/mp4-subtitles.test.js +173 -173
  330. package/test/one-authority.test.js +285 -285
  331. package/test/one-budget.test.js +149 -149
  332. package/test/one-resource-divided.test.js +185 -185
  333. package/test/one-serving-output-per-height.test.js +458 -458
  334. package/test/orchestrator-wired.test.js +206 -206
  335. package/test/output-catalog.test.js +39 -39
  336. package/test/output-shape.test.js +21 -21
  337. package/test/output-spec.test.js +251 -251
  338. package/test/output-speed.test.js +88 -88
  339. package/test/outputs-being-prepared.test.js +40 -40
  340. package/test/packet-witness-ring.test.js +236 -236
  341. package/test/packet-witness.test.js +148 -148
  342. package/test/piece-disk-store.test.js +401 -401
  343. package/test/piece-from-whole-file.test.js +129 -129
  344. package/test/piece-lru.test.js +319 -319
  345. package/test/piece-reader.test.js +162 -162
  346. package/test/piece-selection-offset.test.js +85 -85
  347. package/test/piece-store-eviction.test.js +595 -595
  348. package/test/piece-store-never-refuses.test.js +153 -153
  349. package/test/piece-store-reservations.test.js +403 -403
  350. package/test/piece-store-slow-disk.test.js +159 -159
  351. package/test/piece-tail.test.js +97 -97
  352. package/test/plan-host-timings.test.js +68 -68
  353. package/test/plan-is-steady.test.js +225 -225
  354. package/test/playback-readiness.test.js +518 -483
  355. package/test/playback-start-regressions.test.js +101 -99
  356. package/test/priority-map-download.test.js +174 -174
  357. package/test/priority-map-emptied.test.js +204 -204
  358. package/test/priority-map-per-output.test.js +215 -215
  359. package/test/priority-map.test.js +163 -163
  360. package/test/probe-wedge-certainty.test.js +60 -60
  361. package/test/produced-copy-choice.test.js +213 -213
  362. package/test/published-timeline.test.js +83 -83
  363. package/test/quality-offer.test.js +162 -162
  364. package/test/quality-variants.test.js +1257 -1257
  365. package/test/rate-control-in-the-output.test.js +180 -180
  366. package/test/read-survives-withdrawal.test.js +164 -164
  367. package/test/read-window.test.js +365 -365
  368. package/test/resume-warm.test.js +39 -39
  369. package/test/run-graph-drift.test.js +37 -37
  370. package/test/run-inputs.test.js +80 -80
  371. package/test/run-intervals.test.js +100 -100
  372. package/test/run-position-follows-published-grid.test.js +89 -89
  373. package/test/run-progress.test.js +65 -65
  374. package/test/same-height-switch.test.js +664 -664
  375. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  376. package/test/seek-landing.test.js +109 -109
  377. package/test/seek-target-not-superseded.test.js +195 -195
  378. package/test/segment-closed-on-both-branches.test.js +96 -96
  379. package/test/segment-cut-times.test.js +63 -63
  380. package/test/segment-demand.test.js +80 -80
  381. package/test/segment-hold-supersede.test.js +91 -91
  382. package/test/segment-judged-by-its-own-header.test.js +221 -221
  383. package/test/segment-store-eviction.test.js +247 -247
  384. package/test/segment-store.test.js +352 -352
  385. package/test/segment-true-start.test.js +86 -86
  386. package/test/segments-are-shared.test.js +176 -176
  387. package/test/send-chunk-bytes.test.js +86 -86
  388. package/test/serving-existing-output.test.js +105 -105
  389. package/test/serving-output.test.js +102 -102
  390. package/test/shared-piece-store.test.js +310 -310
  391. package/test/sidecar-naming.test.js +142 -142
  392. package/test/soundtrack-rate-bound.test.js +289 -289
  393. package/test/source-file.test.js +133 -133
  394. package/test/source-registry.test.js +18 -18
  395. package/test/split-a-stretch.test.js +139 -139
  396. package/test/stale-request-after-seek.test.js +158 -158
  397. package/test/startup-readings.test.js +124 -124
  398. package/test/stream-route.test.js +209 -209
  399. package/test/subtitle-cue-framing.test.js +200 -200
  400. package/test/subtitle-cue-source.test.js +102 -102
  401. package/test/subtitle-cursor.test.js +63 -63
  402. package/test/subtitle-defaults.test.js +118 -118
  403. package/test/subtitle-language.test.js +252 -252
  404. package/test/supply-margin.test.js +177 -177
  405. package/test/swarm-follows-readers.test.js +174 -174
  406. package/test/swarm-reach.test.js +130 -130
  407. package/test/swarm-selection.test.js +220 -220
  408. package/test/tail-duplication.test.js +167 -167
  409. package/test/the-channel-has-both-ends.test.js +91 -91
  410. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  411. package/test/thread-pool.test.js +53 -53
  412. package/test/timeline.test.js +84 -84
  413. package/test/torrent-contents.test.js +235 -235
  414. package/test/torrent-cost.test.js +71 -71
  415. package/test/torrent-files.test.js +221 -221
  416. package/test/torrent-naming.test.js +255 -255
  417. package/test/tracks-begin-together.test.js +209 -209
  418. package/test/tunnel-renewal.test.js +167 -167
  419. package/test/two-viewers-one-picture.test.js +504 -504
  420. package/test/upload-hurry.test.js +186 -186
  421. package/test/usrsctp-state.test.js +131 -131
  422. package/test/video-facts.test.js +102 -102
  423. package/test/viewer-assignments.test.js +143 -143
  424. package/test/viewer-outputs.test.js +285 -285
  425. package/test/viewer-presence.test.js +116 -116
  426. package/test/viewer-requests-admission.test.js +89 -89
  427. package/test/viewer-states.test.js +189 -189
  428. package/test/viewer-subtitle-subscription.test.js +96 -96
  429. package/test/viewer.test.js +139 -139
  430. package/test/viewing-generations.test.js +390 -390
  431. package/test/visible-rung.test.js +37 -37
  432. package/test/wait-ledger.test.js +100 -100
  433. package/test/webrtc-shim.test.js +149 -149
  434. package/test/wedge-certainty.test.js +131 -131
  435. package/test/whole-file-held-read.test.js +30 -30
  436. package/test/withdraw-piece-claim.test.js +212 -212
  437. package/test/worker-channel.test.js +158 -158
  438. package/utils/client-log-file.js +151 -151
  439. package/utils/logger.js +312 -312
  440. package/utils/parse-range.js +25 -25
  441. package/utils/perf.js +59 -59
  442. package/utils/torrent-source-key.js +38 -38
@@ -1,458 +1,476 @@
1
- /**
2
- * @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
3
- * `init.mp4` referenced by `#EXT-X-MAP`.
4
- *
5
- * Codec configuration (SPS/PPS) lives once in the init segment instead of being
6
- * repeated per segment. That is what makes hardware encoders which do not
7
- * repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce
8
- * independently usable segments, and it lowers container overhead.
9
- *
10
- * See {@link SegmentFormat} in `./index.js` for the interface contract.
11
- */
12
-
13
- import {
14
- continuePresentationRanges,
15
- rebaseSegmentDecodeTimes,
16
- neutralizeEmptyEdits,
17
- presentationCoverageEnd,
18
- readPresentationRanges,
19
- readSelfContainedStartSeconds,
20
- readTrackTimescales,
21
- readVideoSampleSize,
22
- stampSegmentStartTime,
23
- translatePresentationRanges,
24
- walkBoxes
25
- } from "./mp4-boxes.js";
26
-
27
- /**
28
- * How many distinct tracks have a fragment in this segment.
29
- *
30
- * @param {Buffer} bytes
31
- * @returns {number}
32
- */
33
- function countFragmentTracks(bytes) {
34
- const tracks = new Set();
35
- walkBoxes(bytes, (type, bodyStart) => {
36
- if (type === "tfhd" && bodyStart + 8 <= bytes.length) {
37
- tracks.add(bytes.readUInt32BE(bodyStart + 4));
38
- }
39
- });
40
- return tracks.size;
41
- }
42
-
43
- /**
44
- * Where the fragments begin and where the trailing index starts, as offsets of
45
- * whole boxes at the TOP level.
46
- *
47
- * Deliberately not `walkBoxes`: that reports the start of a box's *body* and
48
- * descends into containers, so it cannot say where a box begins — and cutting a
49
- * file at the wrong offset by eight bytes produces something that parses as
50
- * garbage rather than failing outright.
51
- *
52
- * @param {Buffer} bytes
53
- * @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
54
- */
55
- function findFragmentBounds(bytes) {
56
- let firstFragment = -1;
57
- let trailingIndex = -1;
58
- let offset = 0;
59
- while (offset + 8 <= bytes.length) {
60
- let size = bytes.readUInt32BE(offset);
61
- const type = bytes.toString("latin1", offset + 4, offset + 8);
62
- if (size === 1) {
63
- if (offset + 16 > bytes.length) {
64
- break;
65
- }
66
- size = Number(bytes.readBigUInt64BE(offset + 8));
67
- } else if (size === 0) {
68
- size = bytes.length - offset;
69
- }
70
- if (size < 8) {
71
- break;
72
- }
73
- if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
74
- firstFragment = offset;
75
- }
76
- if (type === "mfra") {
77
- trailingIndex = offset;
78
- }
79
- offset += size;
80
- }
81
- return { firstFragment, trailingIndex };
82
- }
83
-
84
- const INIT_FILE_NAME = "init.mp4";
85
- // What a piece is called while it is still being written. See
86
- // `makingFileNameTemplate`.
87
- const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.mp4$/;
88
- const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
89
-
90
- /**
91
- * @type {import("./index.js").SegmentFormat}
92
- */
93
- export const fmp4Format = {
94
- id: "fmp4",
95
- initFileName: INIT_FILE_NAME,
96
- initContentType: "video/mp4",
97
- segmentContentType: "video/mp4",
98
- // Version 7 is the minimum that allows fMP4 media segments + `#EXT-X-MAP`.
99
- playlistVersion: 7,
100
-
101
- muxerArgs() {
102
- return [
103
- "-hls_segment_type",
104
- "fmp4",
105
- "-hls_fmp4_init_filename",
106
- INIT_FILE_NAME,
107
- "-hls_segment_filename",
108
- "segment-%05d.mp4"
109
- ];
110
- },
111
-
112
- /**
113
- * Cut at times we choose rather than times ffmpeg picks.
114
- *
115
- * The `segment` muxer writes each fMP4 piece as a **self-contained** file:
116
- * `ftyp moov moof mdat … mfra`, verified on the field host. That is not what
117
- * HLS wants — it wants one init segment named by `#EXT-X-MAP` and media
118
- * segments carrying only fragments — so the pieces are split on serve:
119
- * {@link extractInit} takes the header off the first one to serve as the init,
120
- * and {@link stripInit} removes it from every one.
121
- *
122
- * The timestamps still need stamping, exactly as before: measured on the
123
- * field host, all three pieces of a run reported a start time of 0.080 s,
124
- * i.e. each carries its own zero. That is the same defect the shared-init
125
- * path already corrects, so `prepareSegmentBytes` handles both.
126
- *
127
- * @returns {string[]}
128
- */
129
- explicitTimesMuxerArgs() {
130
- return [
131
- "-segment_format",
132
- "mp4",
133
- // `empty_moov` is what makes each piece self-describing, which is what
134
- // lets the init be lifted out of it; `default_base_moof` keeps fragment
135
- // offsets relative, so removing the header does not invalidate them.
136
- //
137
- // `delay_moov` is not optional here. The MP4 muxer builds a copied AC-3
138
- // track's `dac3` box out of the bitstream, so it cannot write `moov`
139
- // until the first audio packet has arrived — while `empty_moov` asks for
140
- // it at header time. Without this flag ffmpeg exits before producing
141
- // anything: "Cannot write moov atom before AC3 packets", which is exactly
142
- // how fMP4 playback died in the field on 2.9.84/2.9.85. The `hls` muxer
143
- // sets this flag itself, which is why the fault only appeared once the
144
- // muxing moved here. Delaying `moov` does not change the piece layout —
145
- // measured: still `ftyp moov moof mdat … mfra`.
146
- "-segment_format_options",
147
- "movflags=+frag_keyframe+empty_moov+default_base_moof+delay_moov"
148
- ];
149
- },
150
-
151
- /** The output path template for the `segment` muxer. */
152
- /**
153
- * The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
154
- *
155
- * Not the name it is served under. A piece being written is not a piece, and
156
- * under its final name it is indistinguishable from one — which is how half a
157
- * segment came to be served: the proof of completeness was "the next file
158
- * exists", true of one writer walking forward and false the moment two runs
159
- * share an output, because the next file is then written by another process.
160
- * Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
161
- * 4 510 940, and the browser refused the whole one for the rest of the session.
162
- *
163
- * With a working name, the served name appears only when the encoder has said
164
- * the piece is closed, and existence under it IS the proof — one rule, the
165
- * same for every branch, and true whether or not our own process is alive.
166
- *
167
- * @returns {string}
168
- */
169
- makingFileNameTemplate(tag) {
170
- return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.mp4`;
171
- },
172
-
173
- /**
174
- * Which run is writing this working name, or null when the name is not one.
175
- *
176
- * The tag is what makes clearing up after a dead run possible without
177
- * guessing: several runs write into one directory, so "the unfinished pieces"
178
- * is only a well-formed question per run. It used to be answered by looking
179
- * for the highest SERVED name inside the stretch the ended run was given and
180
- * judging its bytes — a guess, and under the naming rule above it would remove
181
- * a complete piece somebody else had closed.
182
- *
183
- * @param {string} name
184
- * @returns {string | null}
185
- */
186
- makingTagOf(name) {
187
- const match = MAKING_PATTERN.exec(String(name ?? "").trim());
188
- return match ? match[1] : null;
189
- },
190
-
191
- /**
192
- * The name a piece just closed under a working name is served as, or null when
193
- * the name is not one of ours.
194
- *
195
- * @param {string} makingName
196
- * @returns {string | null}
197
- */
198
- servedNameOf(makingName) {
199
- const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
200
- return match ? this.segmentFileName(Number(match[2])) : null;
201
- },
202
-
203
- segmentFileNameTemplate() {
204
- return "segment-%05d.mp4";
205
- },
206
-
207
- /**
208
- * The init part of a self-contained piece: everything before the first
209
- * fragment.
210
- *
211
- * @param {Buffer} bytes
212
- * @returns {Buffer | null} `null` when the piece carries no fragment, which
213
- * means it is not one of these and must not be cut up.
214
- */
215
- extractInit(bytes) {
216
- const { firstFragment } = findFragmentBounds(bytes);
217
- return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
218
- },
219
- prepareSharedInit: neutralizeEmptyEdits,
220
-
221
- /**
222
- * A piece with its init header removed, and the trailing random-access index
223
- * dropped — a player reading a media segment has no use for either, and
224
- * `mfra` describes offsets that stop being true once the header is gone.
225
- *
226
- * @param {Buffer} bytes
227
- * @returns {Buffer}
228
- */
229
- stripInit(bytes) {
230
- const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
231
- if (firstFragment < 0) {
232
- return bytes;
233
- }
234
- const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
235
- return bytes.subarray(firstFragment, end);
236
- },
237
-
238
- playlistHeaderLines() {
239
- return [
240
- // The init segment (codec config). Fetched once; applies to every media
241
- // segment in the playlist.
242
- `#EXT-X-MAP:URI="${INIT_FILE_NAME}"`
243
- ];
244
- },
245
-
246
- segmentFileName(index) {
247
- return `segment-${String(index).padStart(5, "0")}.mp4`;
248
- },
249
-
250
- isSegmentFileName(fileName) {
251
- return SEGMENT_PATTERN.test(fileName);
252
- },
253
-
254
- segmentIndexFromName(fileName) {
255
- const match = SEGMENT_PATTERN.exec(fileName);
256
- return match ? Number(match[1]) : -1;
257
- },
258
-
259
- /**
260
- * Where a self-contained piece says it begins, in seconds, or null.
261
- *
262
- * Only the pieces the `segment` muxer writes carry this — they have their
263
- * own `moov` — which is exactly the path where the playlist's own answer
264
- * can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
265
- * removes the header the position lives in.
266
- *
267
- * @param {Buffer} piece
268
- * @returns {number | null}
269
- */
270
- readSegmentStartSeconds(piece) {
271
- return readSelfContainedStartSeconds(piece);
272
- },
273
-
274
- /**
275
- * fMP4 segments must be read into memory and corrected before being served —
276
- * see {@link stampSegmentStartTime} for the full reasoning. Without this a
277
- * seek is permanently broken: ffmpeg leaves every segment claiming to start
278
- * at 0 and puts the real offset in the per-run init, which we do not serve
279
- * (the player fetches the session's first init once and keeps it).
280
- *
281
- * Segments are a few hundred KB, so reading one whole is cheap next to the
282
- * transcode itself; the box walk never descends into `mdat`.
283
- */
284
- needsSegmentRewrite: true,
285
- mediaCoverageEnd: presentationCoverageEnd,
286
- readMediaRanges(raw, { initBytes = null, startSeconds = 0 } = {}) {
287
- if (this.extractInit(raw)) return readPresentationRanges(raw);
288
- if (!initBytes?.length) return undefined;
289
- const served = this.prepareSegmentBytes(raw, { initBytes, startSeconds, rawBytes: raw });
290
- return readPresentationRanges(Buffer.concat([initBytes, served]));
291
- },
292
- clientMediaRanges(ranges, context) {
293
- return translatePresentationRanges(continuePresentationRanges(ranges, context.nextRanges),
294
- context.initBytes, context.timestampOffsetSeconds);
295
- },
296
-
297
- /**
298
- * Whether a segment carries every track the init promises.
299
- *
300
- * A run that is TERMINATED closes its current output file properly — trailing
301
- * index and all — but the file holds only what had been muxed by then, which
302
- * after a seek-restart is routinely one track of two. Nothing about it looks
303
- * unfinished: the file exists, the next one exists, so the readiness rule
304
- * calls it done and it is served. The player then cannot use it and the seek
305
- * never completes. Measured 2026-08-06: segment #133 carried one `tfdt`
306
- * where its neighbours carried two, and a viewer sat on a spinner while the
307
- * proxy answered every request in 98 ms.
308
- *
309
- * Cheap to check: the fragments are already walked to stamp them.
310
- *
311
- * @param {Buffer} bytes - The media segment, init header already removed.
312
- * @param {Buffer | null} initBytes
313
- * @returns {boolean} False only when a track is provably missing.
314
- */
315
- hasEveryTrack(bytes, initBytes) {
316
- if (!initBytes || initBytes.length === 0) {
317
- return true;
318
- }
319
- const expected = readTrackTimescales(initBytes);
320
- if (expected.size === 0) {
321
- return true;
322
- }
323
- return countFragmentTracks(bytes) >= expected.size;
324
- },
325
-
326
- /**
327
- * Whether this piece is whole, judged against ITS OWN header.
328
- *
329
- * A piece that disagrees with the session's cached header is one of two very
330
- * different things, and they call for opposite actions: the piece may be
331
- * short, or the HEADER may be the odd one out. A self-contained piece carries
332
- * a `moov` of its own, so it can be compared with itself, and that comparison
333
- * tells them apart without knowing anything about encoders.
334
- *
335
- * WHAT THIS REPLACED, because the difference is five minutes of a frozen
336
- * picture. The action used to be chosen by "is any run of this session
337
- * alive?" — a question about the SESSION, asked about a fact of the BYTES. A
338
- * run anywhere in the film meant "still being written; waiting for it", and
339
- * nothing re-judged or re-made the piece afterwards, so one misjudgement
340
- * became permanent. Field 2026-09-14: `segment-00033.mp4` of a copied
341
- * picture, already served whole to one viewer at 5 099 421 bytes, was refused
342
- * to a second viewer for five minutes — four holds of sixty seconds each,
343
- * then 503 — while the run in force was at #192 and beyond. That viewer's
344
- * picture stood still 196.2 s and then a further 56.2 s.
345
- *
346
- * The run question is also unanswerable and does not need asking: a piece
347
- * being written is called `making-<tag>-NNNNN.mp4` and takes its served name
348
- * by a rename, which is atomic. A file under its served name is never being
349
- * written and never half-read.
350
- *
351
- * The same file read back off the disk declares one track in its header and
352
- * carries one in its fragments, so what fired in the field was not the piece.
353
- * Until a reading names what it was, the verdict carries every count it made.
354
- *
355
- * @param {Buffer} raw - The piece as it is on disk, header and all.
356
- * @param {Buffer} bytes - Its media part, the header already removed.
357
- * @param {Buffer | null} sessionHeader - What the session serves as its init.
358
- * @returns {{ whole: boolean, serve: boolean, fragmentTracks: number,
359
- * ownTracks: number, sessionTracks: number, because: string } | null} Null
360
- * where there is nothing to compare against, which is not a verdict.
361
- */
362
- judgeTracks(raw, bytes, sessionHeader) {
363
- if (!sessionHeader || sessionHeader.length === 0) {
364
- return null;
365
- }
366
- const sessionTracks = readTrackTimescales(sessionHeader).size;
367
- if (sessionTracks === 0) {
368
- return null;
369
- }
370
- const fragmentTracks = countFragmentTracks(bytes);
371
- if (fragmentTracks >= sessionTracks) {
372
- return null;
373
- }
374
- const ownHeader = this.extractInit(raw);
375
- const ownTracks = ownHeader ? readTrackTimescales(ownHeader).size : 0;
376
- const agreesWithItself = ownTracks > 0 && fragmentTracks >= ownTracks;
377
- return {
378
- whole: agreesWithItself,
379
- serve: agreesWithItself,
380
- fragmentTracks,
381
- ownTracks,
382
- sessionTracks,
383
- because: agreesWithItself
384
- ? "it agrees with its own header, so the session's is the odd one out; serving it"
385
- : "it disagrees with its own header, so it was closed short; removing it to be made again"
386
- };
387
- },
388
-
389
- /**
390
- * How many tracks an init segment declares.
391
- *
392
- * The init is extracted from the first self-contained piece and then cached
393
- * for the WHOLE session — the player fetches `#EXT-X-MAP` once and never
394
- * again. So an init taken from a piece written before the video track was
395
- * muxed describes audio alone, and the browser then has no video source
396
- * buffer for the rest of the session however much video arrives afterwards.
397
- * Measured 2026-08-10: sixty-five seconds of playing sound with
398
- * `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element
399
- * perfectly happy, with no picture in it.
400
- *
401
- * @param {Buffer} initBytes
402
- * @returns {number}
403
- */
404
- /**
405
- * How many distinct tracks this piece's own fragments carry.
406
- *
407
- * Needs no knowledge of the source: whatever a produced piece contains, it
408
- * contains. Comparing the richest piece against a candidate header is what
409
- * lets an init be judged without assuming how many tracks a file "should"
410
- * have — a film with no soundtrack and a file with two audio tracks are both
411
- * answered correctly, and neither is guessed at.
412
- *
413
- * @param {Buffer} bytes
414
- * @returns {number}
415
- */
416
- countSegmentTracks(bytes) {
417
- return countFragmentTracks(bytes);
418
- },
419
-
420
- countInitTracks(initBytes) {
421
- if (!initBytes || initBytes.length === 0) {
422
- return 0;
423
- }
424
- return readTrackTimescales(initBytes).size;
425
- },
426
-
427
- /**
428
- * The picture size this init segment describes, or null when it describes no
429
- * picture.
430
- *
431
- * Asked because one init serves the session's whole life while an encode run
432
- * can be restarted with other settings, and a run that changes the SIZE makes
433
- * every fragment after it undecodable against the init already in the
434
- * player's hands.
435
- *
436
- * @param {Buffer | null} initBytes
437
- * @returns {{ width: number, height: number } | null}
438
- */
439
- initVideoSize(initBytes) {
440
- if (!initBytes || initBytes.length === 0) {
441
- return null;
442
- }
443
- return readVideoSampleSize(initBytes);
444
- },
445
-
446
- prepareSegmentBytes(bytes, { startSeconds, initBytes, rawBytes }) {
447
- if (!initBytes || initBytes.length === 0) {
448
- // No init cached yet — nothing to read timescales from. The player always
449
- // fetches `#EXT-X-MAP` before any segment, so this is not reachable in
450
- // practice; serve unmodified rather than guess a timescale.
451
- return bytes;
452
- }
453
- const ownInit = rawBytes ? this.extractInit(rawBytes) : null;
454
- return ownInit
455
- ? rebaseSegmentDecodeTimes(bytes, ownInit, initBytes)
456
- : stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
457
- }
458
- };
1
+ /**
2
+ * @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
3
+ * `init.mp4` referenced by `#EXT-X-MAP`.
4
+ *
5
+ * Codec configuration (SPS/PPS) lives once in the init segment instead of being
6
+ * repeated per segment. That is what makes hardware encoders which do not
7
+ * repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce
8
+ * independently usable segments, and it lowers container overhead.
9
+ *
10
+ * See {@link SegmentFormat} in `./index.js` for the interface contract.
11
+ */
12
+
13
+ import {
14
+ rebaseSegmentDecodeTimes,
15
+ neutralizeEmptyEdits,
16
+ producedThroughSeconds,
17
+ readPresentationRanges,
18
+ readSelfContainedStartSeconds,
19
+ readTrackTimescales,
20
+ readVideoSampleSize,
21
+ servedPresentationRanges,
22
+ stampSegmentStartTime,
23
+ walkBoxes
24
+ } from "./mp4-boxes.js";
25
+
26
+ /**
27
+ * Served ranges by the coverage they were derived from; see `servedMediaRanges`.
28
+ *
29
+ * @type {WeakMap<object, { initBytes: Buffer | null, served: object }>}
30
+ */
31
+ const servedRangesKept = new WeakMap();
32
+
33
+ /**
34
+ * How many distinct tracks have a fragment in this segment.
35
+ *
36
+ * @param {Buffer} bytes
37
+ * @returns {number}
38
+ */
39
+ function countFragmentTracks(bytes) {
40
+ const tracks = new Set();
41
+ walkBoxes(bytes, (type, bodyStart) => {
42
+ if (type === "tfhd" && bodyStart + 8 <= bytes.length) {
43
+ tracks.add(bytes.readUInt32BE(bodyStart + 4));
44
+ }
45
+ });
46
+ return tracks.size;
47
+ }
48
+
49
+ /**
50
+ * Where the fragments begin and where the trailing index starts, as offsets of
51
+ * whole boxes at the TOP level.
52
+ *
53
+ * Deliberately not `walkBoxes`: that reports the start of a box's *body* and
54
+ * descends into containers, so it cannot say where a box begins — and cutting a
55
+ * file at the wrong offset by eight bytes produces something that parses as
56
+ * garbage rather than failing outright.
57
+ *
58
+ * @param {Buffer} bytes
59
+ * @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
60
+ */
61
+ function findFragmentBounds(bytes) {
62
+ let firstFragment = -1;
63
+ let trailingIndex = -1;
64
+ let offset = 0;
65
+ while (offset + 8 <= bytes.length) {
66
+ let size = bytes.readUInt32BE(offset);
67
+ const type = bytes.toString("latin1", offset + 4, offset + 8);
68
+ if (size === 1) {
69
+ if (offset + 16 > bytes.length) {
70
+ break;
71
+ }
72
+ size = Number(bytes.readBigUInt64BE(offset + 8));
73
+ } else if (size === 0) {
74
+ size = bytes.length - offset;
75
+ }
76
+ if (size < 8) {
77
+ break;
78
+ }
79
+ if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
80
+ firstFragment = offset;
81
+ }
82
+ if (type === "mfra") {
83
+ trailingIndex = offset;
84
+ }
85
+ offset += size;
86
+ }
87
+ return { firstFragment, trailingIndex };
88
+ }
89
+
90
+ const INIT_FILE_NAME = "init.mp4";
91
+ // What a piece is called while it is still being written. See
92
+ // `makingFileNameTemplate`.
93
+ const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.mp4$/;
94
+ const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
95
+
96
+ /**
97
+ * @type {import("./index.js").SegmentFormat}
98
+ */
99
+ export const fmp4Format = {
100
+ id: "fmp4",
101
+ initFileName: INIT_FILE_NAME,
102
+ initContentType: "video/mp4",
103
+ segmentContentType: "video/mp4",
104
+ // Version 7 is the minimum that allows fMP4 media segments + `#EXT-X-MAP`.
105
+ playlistVersion: 7,
106
+
107
+ muxerArgs() {
108
+ return [
109
+ "-hls_segment_type",
110
+ "fmp4",
111
+ "-hls_fmp4_init_filename",
112
+ INIT_FILE_NAME,
113
+ "-hls_segment_filename",
114
+ "segment-%05d.mp4"
115
+ ];
116
+ },
117
+
118
+ /**
119
+ * Cut at times we choose rather than times ffmpeg picks.
120
+ *
121
+ * The `segment` muxer writes each fMP4 piece as a **self-contained** file:
122
+ * `ftyp moov moof mdat … mfra`, verified on the field host. That is not what
123
+ * HLS wants — it wants one init segment named by `#EXT-X-MAP` and media
124
+ * segments carrying only fragments — so the pieces are split on serve:
125
+ * {@link extractInit} takes the header off the first one to serve as the init,
126
+ * and {@link stripInit} removes it from every one.
127
+ *
128
+ * The timestamps still need stamping, exactly as before: measured on the
129
+ * field host, all three pieces of a run reported a start time of 0.080 s,
130
+ * i.e. each carries its own zero. That is the same defect the shared-init
131
+ * path already corrects, so `prepareSegmentBytes` handles both.
132
+ *
133
+ * @returns {string[]}
134
+ */
135
+ explicitTimesMuxerArgs() {
136
+ return [
137
+ "-segment_format",
138
+ "mp4",
139
+ // `empty_moov` is what makes each piece self-describing, which is what
140
+ // lets the init be lifted out of it; `default_base_moof` keeps fragment
141
+ // offsets relative, so removing the header does not invalidate them.
142
+ //
143
+ // `delay_moov` is not optional here. The MP4 muxer builds a copied AC-3
144
+ // track's `dac3` box out of the bitstream, so it cannot write `moov`
145
+ // until the first audio packet has arrived — while `empty_moov` asks for
146
+ // it at header time. Without this flag ffmpeg exits before producing
147
+ // anything: "Cannot write moov atom before AC3 packets", which is exactly
148
+ // how fMP4 playback died in the field on 2.9.84/2.9.85. The `hls` muxer
149
+ // sets this flag itself, which is why the fault only appeared once the
150
+ // muxing moved here. Delaying `moov` does not change the piece layout —
151
+ // measured: still `ftyp moov moof mdat … mfra`.
152
+ "-segment_format_options",
153
+ "movflags=+frag_keyframe+empty_moov+default_base_moof+delay_moov"
154
+ ];
155
+ },
156
+
157
+ /** The output path template for the `segment` muxer. */
158
+ /**
159
+ * The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
160
+ *
161
+ * Not the name it is served under. A piece being written is not a piece, and
162
+ * under its final name it is indistinguishable from one — which is how half a
163
+ * segment came to be served: the proof of completeness was "the next file
164
+ * exists", true of one writer walking forward and false the moment two runs
165
+ * share an output, because the next file is then written by another process.
166
+ * Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
167
+ * 4 510 940, and the browser refused the whole one for the rest of the session.
168
+ *
169
+ * With a working name, the served name appears only when the encoder has said
170
+ * the piece is closed, and existence under it IS the proof — one rule, the
171
+ * same for every branch, and true whether or not our own process is alive.
172
+ *
173
+ * @returns {string}
174
+ */
175
+ makingFileNameTemplate(tag) {
176
+ return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.mp4`;
177
+ },
178
+
179
+ /**
180
+ * Which run is writing this working name, or null when the name is not one.
181
+ *
182
+ * The tag is what makes clearing up after a dead run possible without
183
+ * guessing: several runs write into one directory, so "the unfinished pieces"
184
+ * is only a well-formed question per run. It used to be answered by looking
185
+ * for the highest SERVED name inside the stretch the ended run was given and
186
+ * judging its bytes — a guess, and under the naming rule above it would remove
187
+ * a complete piece somebody else had closed.
188
+ *
189
+ * @param {string} name
190
+ * @returns {string | null}
191
+ */
192
+ makingTagOf(name) {
193
+ const match = MAKING_PATTERN.exec(String(name ?? "").trim());
194
+ return match ? match[1] : null;
195
+ },
196
+
197
+ /**
198
+ * The name a piece just closed under a working name is served as, or null when
199
+ * the name is not one of ours.
200
+ *
201
+ * @param {string} makingName
202
+ * @returns {string | null}
203
+ */
204
+ servedNameOf(makingName) {
205
+ const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
206
+ return match ? this.segmentFileName(Number(match[2])) : null;
207
+ },
208
+
209
+ segmentFileNameTemplate() {
210
+ return "segment-%05d.mp4";
211
+ },
212
+
213
+ /**
214
+ * The init part of a self-contained piece: everything before the first
215
+ * fragment.
216
+ *
217
+ * @param {Buffer} bytes
218
+ * @returns {Buffer | null} `null` when the piece carries no fragment, which
219
+ * means it is not one of these and must not be cut up.
220
+ */
221
+ extractInit(bytes) {
222
+ const { firstFragment } = findFragmentBounds(bytes);
223
+ return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
224
+ },
225
+ prepareSharedInit: neutralizeEmptyEdits,
226
+
227
+ /**
228
+ * A piece with its init header removed, and the trailing random-access index
229
+ * dropped — a player reading a media segment has no use for either, and
230
+ * `mfra` describes offsets that stop being true once the header is gone.
231
+ *
232
+ * @param {Buffer} bytes
233
+ * @returns {Buffer}
234
+ */
235
+ stripInit(bytes) {
236
+ const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
237
+ if (firstFragment < 0) {
238
+ return bytes;
239
+ }
240
+ const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
241
+ return bytes.subarray(firstFragment, end);
242
+ },
243
+
244
+ playlistHeaderLines() {
245
+ return [
246
+ // The init segment (codec config). Fetched once; applies to every media
247
+ // segment in the playlist.
248
+ `#EXT-X-MAP:URI="${INIT_FILE_NAME}"`
249
+ ];
250
+ },
251
+
252
+ segmentFileName(index) {
253
+ return `segment-${String(index).padStart(5, "0")}.mp4`;
254
+ },
255
+
256
+ isSegmentFileName(fileName) {
257
+ return SEGMENT_PATTERN.test(fileName);
258
+ },
259
+
260
+ segmentIndexFromName(fileName) {
261
+ const match = SEGMENT_PATTERN.exec(fileName);
262
+ return match ? Number(match[1]) : -1;
263
+ },
264
+
265
+ /**
266
+ * Where a self-contained piece says it begins, in seconds, or null.
267
+ *
268
+ * Only the pieces the `segment` muxer writes carry this — they have their
269
+ * own `moov` — which is exactly the path where the playlist's own answer
270
+ * can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
271
+ * removes the header the position lives in.
272
+ *
273
+ * @param {Buffer} piece
274
+ * @returns {number | null}
275
+ */
276
+ readSegmentStartSeconds(piece) {
277
+ return readSelfContainedStartSeconds(piece);
278
+ },
279
+
280
+ /**
281
+ * fMP4 segments must be read into memory and corrected before being served —
282
+ * see {@link stampSegmentStartTime} for the full reasoning. Without this a
283
+ * seek is permanently broken: ffmpeg leaves every segment claiming to start
284
+ * at 0 and puts the real offset in the per-run init, which we do not serve
285
+ * (the player fetches the session's first init once and keeps it).
286
+ *
287
+ * Segments are a few hundred KB, so reading one whole is cheap next to the
288
+ * transcode itself; the box walk never descends into `mdat`.
289
+ */
290
+ needsSegmentRewrite: true,
291
+ producedThroughSeconds,
292
+ readMediaRanges(raw, { initBytes = null, startSeconds = 0 } = {}) {
293
+ if (this.extractInit(raw)) return readPresentationRanges(raw);
294
+ if (!initBytes?.length) return undefined;
295
+ const served = this.prepareSegmentBytes(raw, { initBytes, startSeconds, rawBytes: raw });
296
+ return readPresentationRanges(Buffer.concat([initBytes, served]));
297
+ },
298
+ /**
299
+ * The same object is returned while the coverage and the init are the same
300
+ * ones, so a reader can keep what it derives from a piece instead of deriving
301
+ * it on every report.
302
+ */
303
+ servedMediaRanges(coverage, { initBytes = null } = {}) {
304
+ const kept = servedRangesKept.get(coverage);
305
+ if (kept && kept.initBytes === initBytes) {
306
+ return kept.served;
307
+ }
308
+ const served = servedPresentationRanges(coverage, initBytes);
309
+ if (coverage !== null && typeof coverage === "object") {
310
+ servedRangesKept.set(coverage, { initBytes, served });
311
+ }
312
+ return served;
313
+ },
314
+
315
+ /**
316
+ * Whether a segment carries every track the init promises.
317
+ *
318
+ * A run that is TERMINATED closes its current output file properly — trailing
319
+ * index and all — but the file holds only what had been muxed by then, which
320
+ * after a seek-restart is routinely one track of two. Nothing about it looks
321
+ * unfinished: the file exists, the next one exists, so the readiness rule
322
+ * calls it done and it is served. The player then cannot use it and the seek
323
+ * never completes. Measured 2026-08-06: segment #133 carried one `tfdt`
324
+ * where its neighbours carried two, and a viewer sat on a spinner while the
325
+ * proxy answered every request in 98 ms.
326
+ *
327
+ * Cheap to check: the fragments are already walked to stamp them.
328
+ *
329
+ * @param {Buffer} bytes - The media segment, init header already removed.
330
+ * @param {Buffer | null} initBytes
331
+ * @returns {boolean} False only when a track is provably missing.
332
+ */
333
+ hasEveryTrack(bytes, initBytes) {
334
+ if (!initBytes || initBytes.length === 0) {
335
+ return true;
336
+ }
337
+ const expected = readTrackTimescales(initBytes);
338
+ if (expected.size === 0) {
339
+ return true;
340
+ }
341
+ return countFragmentTracks(bytes) >= expected.size;
342
+ },
343
+
344
+ /**
345
+ * Whether this piece is whole, judged against ITS OWN header.
346
+ *
347
+ * A piece that disagrees with the session's cached header is one of two very
348
+ * different things, and they call for opposite actions: the piece may be
349
+ * short, or the HEADER may be the odd one out. A self-contained piece carries
350
+ * a `moov` of its own, so it can be compared with itself, and that comparison
351
+ * tells them apart without knowing anything about encoders.
352
+ *
353
+ * WHAT THIS REPLACED, because the difference is five minutes of a frozen
354
+ * picture. The action used to be chosen by "is any run of this session
355
+ * alive?" — a question about the SESSION, asked about a fact of the BYTES. A
356
+ * run anywhere in the film meant "still being written; waiting for it", and
357
+ * nothing re-judged or re-made the piece afterwards, so one misjudgement
358
+ * became permanent. Field 2026-09-14: `segment-00033.mp4` of a copied
359
+ * picture, already served whole to one viewer at 5 099 421 bytes, was refused
360
+ * to a second viewer for five minutes — four holds of sixty seconds each,
361
+ * then 503 — while the run in force was at #192 and beyond. That viewer's
362
+ * picture stood still 196.2 s and then a further 56.2 s.
363
+ *
364
+ * The run question is also unanswerable and does not need asking: a piece
365
+ * being written is called `making-<tag>-NNNNN.mp4` and takes its served name
366
+ * by a rename, which is atomic. A file under its served name is never being
367
+ * written and never half-read.
368
+ *
369
+ * The same file read back off the disk declares one track in its header and
370
+ * carries one in its fragments, so what fired in the field was not the piece.
371
+ * Until a reading names what it was, the verdict carries every count it made.
372
+ *
373
+ * @param {Buffer} raw - The piece as it is on disk, header and all.
374
+ * @param {Buffer} bytes - Its media part, the header already removed.
375
+ * @param {Buffer | null} sessionHeader - What the session serves as its init.
376
+ * @returns {{ whole: boolean, serve: boolean, fragmentTracks: number,
377
+ * ownTracks: number, sessionTracks: number, because: string } | null} Null
378
+ * where there is nothing to compare against, which is not a verdict.
379
+ */
380
+ judgeTracks(raw, bytes, sessionHeader) {
381
+ if (!sessionHeader || sessionHeader.length === 0) {
382
+ return null;
383
+ }
384
+ const sessionTracks = readTrackTimescales(sessionHeader).size;
385
+ if (sessionTracks === 0) {
386
+ return null;
387
+ }
388
+ const fragmentTracks = countFragmentTracks(bytes);
389
+ if (fragmentTracks >= sessionTracks) {
390
+ return null;
391
+ }
392
+ const ownHeader = this.extractInit(raw);
393
+ const ownTracks = ownHeader ? readTrackTimescales(ownHeader).size : 0;
394
+ const agreesWithItself = ownTracks > 0 && fragmentTracks >= ownTracks;
395
+ return {
396
+ whole: agreesWithItself,
397
+ serve: agreesWithItself,
398
+ fragmentTracks,
399
+ ownTracks,
400
+ sessionTracks,
401
+ because: agreesWithItself
402
+ ? "it agrees with its own header, so the session's is the odd one out; serving it"
403
+ : "it disagrees with its own header, so it was closed short; removing it to be made again"
404
+ };
405
+ },
406
+
407
+ /**
408
+ * How many tracks an init segment declares.
409
+ *
410
+ * The init is extracted from the first self-contained piece and then cached
411
+ * for the WHOLE session — the player fetches `#EXT-X-MAP` once and never
412
+ * again. So an init taken from a piece written before the video track was
413
+ * muxed describes audio alone, and the browser then has no video source
414
+ * buffer for the rest of the session however much video arrives afterwards.
415
+ * Measured 2026-08-10: sixty-five seconds of playing sound with
416
+ * `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element
417
+ * perfectly happy, with no picture in it.
418
+ *
419
+ * @param {Buffer} initBytes
420
+ * @returns {number}
421
+ */
422
+ /**
423
+ * How many distinct tracks this piece's own fragments carry.
424
+ *
425
+ * Needs no knowledge of the source: whatever a produced piece contains, it
426
+ * contains. Comparing the richest piece against a candidate header is what
427
+ * lets an init be judged without assuming how many tracks a file "should"
428
+ * have — a film with no soundtrack and a file with two audio tracks are both
429
+ * answered correctly, and neither is guessed at.
430
+ *
431
+ * @param {Buffer} bytes
432
+ * @returns {number}
433
+ */
434
+ countSegmentTracks(bytes) {
435
+ return countFragmentTracks(bytes);
436
+ },
437
+
438
+ countInitTracks(initBytes) {
439
+ if (!initBytes || initBytes.length === 0) {
440
+ return 0;
441
+ }
442
+ return readTrackTimescales(initBytes).size;
443
+ },
444
+
445
+ /**
446
+ * The picture size this init segment describes, or null when it describes no
447
+ * picture.
448
+ *
449
+ * Asked because one init serves the session's whole life while an encode run
450
+ * can be restarted with other settings, and a run that changes the SIZE makes
451
+ * every fragment after it undecodable against the init already in the
452
+ * player's hands.
453
+ *
454
+ * @param {Buffer | null} initBytes
455
+ * @returns {{ width: number, height: number } | null}
456
+ */
457
+ initVideoSize(initBytes) {
458
+ if (!initBytes || initBytes.length === 0) {
459
+ return null;
460
+ }
461
+ return readVideoSampleSize(initBytes);
462
+ },
463
+
464
+ prepareSegmentBytes(bytes, { startSeconds, initBytes, rawBytes }) {
465
+ if (!initBytes || initBytes.length === 0) {
466
+ // No init cached yet — nothing to read timescales from. The player always
467
+ // fetches `#EXT-X-MAP` before any segment, so this is not reachable in
468
+ // practice; serve unmodified rather than guess a timescale.
469
+ return bytes;
470
+ }
471
+ const ownInit = rawBytes ? this.extractInit(rawBytes) : null;
472
+ return ownInit
473
+ ? rebaseSegmentDecodeTimes(bytes, ownInit, initBytes)
474
+ : stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
475
+ }
476
+ };