@torrent-tv/proxy 2.87.4 → 2.87.6

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 (460) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +8 -0
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/diagnostics/sctpstate.gdb +31 -31
  19. package/bin/cli.js +684 -684
  20. package/bin/thread-pool.js +30 -30
  21. package/biome.json +411 -411
  22. package/docs/browser-proxy-contract.md +94 -94
  23. package/docs/container-architecture.md +218 -218
  24. package/docs/download-architecture.md +261 -261
  25. package/docs/encode-architecture.md +729 -729
  26. package/docs/encode-run-state.md +96 -96
  27. package/docs/logs.md +97 -97
  28. package/docs/socks5-egress-research.md +114 -114
  29. package/docs/storage-architecture.md +267 -267
  30. package/knip.json +14 -14
  31. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  32. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  33. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  34. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  35. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  36. package/openspec/changes/binary-distribution/proposal.md +53 -53
  37. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  38. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  39. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  40. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  41. package/openspec/changes/cold-start/design.md +98 -98
  42. package/openspec/changes/cold-start/proposal.md +62 -62
  43. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  44. package/openspec/changes/cold-start/tasks.md +48 -48
  45. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  46. package/openspec/changes/disk-cap/proposal.md +37 -37
  47. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  48. package/openspec/changes/disk-cap/tasks.md +19 -19
  49. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  50. package/openspec/changes/proxy-observability/design.md +38 -38
  51. package/openspec/changes/proxy-observability/proposal.md +49 -49
  52. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  53. package/openspec/changes/proxy-observability/tasks.md +21 -21
  54. package/openspec/changes/seek-debounce/design.md +118 -118
  55. package/openspec/changes/seek-debounce/proposal.md +59 -59
  56. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  57. package/openspec/changes/seek-debounce/tasks.md +33 -33
  58. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  59. package/openspec/changes/subtitle-language/proposal.md +50 -50
  60. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  61. package/openspec/changes/subtitle-language/tasks.md +26 -26
  62. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  63. package/openspec/changes/track-selection/design.md +47 -47
  64. package/openspec/changes/track-selection/proposal.md +45 -45
  65. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  66. package/openspec/changes/track-selection/tasks.md +20 -20
  67. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  68. package/openspec/changes/transcode-quality/proposal.md +51 -51
  69. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  70. package/openspec/changes/transcode-quality/tasks.md +63 -63
  71. package/openspec/config.yaml +38 -38
  72. package/package.json +1 -1
  73. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  74. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  75. package/research/double-spawn-2026-09-10.md +171 -171
  76. package/research/handover-2026-09-13.md +209 -209
  77. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  78. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  79. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  80. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  81. package/routes/api/client-logs/post.js +86 -86
  82. package/routes/api/delivery-sink/get.js +72 -72
  83. package/routes/api/link-probe/get.js +51 -51
  84. package/routes/api/playback-plan/post.js +53 -53
  85. package/routes/api/sources/files/get.js +100 -100
  86. package/routes/api/sources/post.js +36 -36
  87. package/routes/api/sources/stats/get.js +132 -132
  88. package/routes/api/subtitles/get.js +230 -230
  89. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  90. package/routes/api/transcode-sessions/net-report/post.js +129 -129
  91. package/routes/api/transcode-sessions/post.js +160 -160
  92. package/routes/api/transcode-sessions/progress/get.js +27 -27
  93. package/routes/api/transcode-sessions/release/post.js +41 -41
  94. package/routes/api/transcode-sessions/seek/post.js +63 -63
  95. package/routes/health/get.js +13 -13
  96. package/routes/healthz/get.js +13 -13
  97. package/routes/stream/get.js +361 -361
  98. package/routes/transcode/audio-file/get.js +64 -64
  99. package/routes/transcode/audio-warm/get.js +84 -84
  100. package/routes/transcode/session-file/get.js +52 -52
  101. package/routes/transcode/variant-file/get.js +95 -95
  102. package/routes/transcode/variant-warm/get.js +84 -84
  103. package/scripts/render-run-graph.js +173 -173
  104. package/services/encode/CoverageMap.js +428 -428
  105. package/services/encode/CushionReport.js +345 -345
  106. package/services/encode/EncodeAdmission.js +332 -332
  107. package/services/encode/EncodeOrchestrator.js +1217 -1217
  108. package/services/encode/EncodePlan.js +1366 -1366
  109. package/services/encode/EncodeRun.js +776 -776
  110. package/services/encode/EncodeRuns.js +1229 -1229
  111. package/services/encode/Encoder.js +142 -142
  112. package/services/encode/EncoderSelection.js +38 -38
  113. package/services/encode/LocalObservations.js +359 -359
  114. package/services/encode/NvencEncoder.js +50 -50
  115. package/services/encode/OutputOpening.js +755 -755
  116. package/services/encode/OutputTimes.js +579 -579
  117. package/services/encode/QsvEncoder.js +58 -58
  118. package/services/encode/Renditions.js +2162 -2162
  119. package/services/encode/RunProgress.js +84 -84
  120. package/services/encode/SegmentDemand.js +95 -95
  121. package/services/encode/SoftwareEncoder.js +121 -121
  122. package/services/encode/V4l2m2mEncoder.js +53 -53
  123. package/services/encode/VaapiEncoder.js +66 -66
  124. package/services/encode/args.js +416 -416
  125. package/services/encode/available-share.js +89 -89
  126. package/services/encode/calibration.js +361 -361
  127. package/services/encode/contention.js +128 -128
  128. package/services/encode/decode-cost-fit.js +363 -363
  129. package/services/encode/encode-exit.js +114 -114
  130. package/services/encode/encode-run-state.js +551 -551
  131. package/services/encode/encoder-readings.js +45 -45
  132. package/services/encode/fingerprint.js +322 -322
  133. package/services/encode/host-load.js +174 -174
  134. package/services/encode/hwaccel.js +1894 -1894
  135. package/services/encode/index.js +9 -9
  136. package/services/encode/learned-median.js +90 -90
  137. package/services/encode/output/EncodedOutput.js +57 -57
  138. package/services/encode/output/Output.js +131 -131
  139. package/services/encode/output/OutputCatalog.js +414 -414
  140. package/services/encode/output/OutputSpec.js +390 -390
  141. package/services/encode/output/Timeline.js +308 -308
  142. package/services/encode/output/cut-grid.js +107 -107
  143. package/services/encode/output/index.js +20 -20
  144. package/services/encode/output/ladder.js +32 -32
  145. package/services/encode/output/playlists.js +284 -284
  146. package/services/encode/output/rates.js +125 -125
  147. package/services/encode/output-key-format.js +21 -21
  148. package/services/encode/quality/EncodeCost.js +1083 -1083
  149. package/services/encode/quality/HostLoad.js +525 -525
  150. package/services/encode/quality/HostTimings.js +249 -249
  151. package/services/encode/quality/OutputQualityState.js +28 -28
  152. package/services/encode/quality/QualityController.js +935 -935
  153. package/services/encode/quality/QualityOffer.js +416 -416
  154. package/services/encode/quality/drain-threat.js +47 -47
  155. package/services/encode/quality/link-budget.js +254 -254
  156. package/services/encode/quality/output-format.js +266 -266
  157. package/services/encode/quality/serving-output.js +216 -216
  158. package/services/encode/quality/visible-rung.js +57 -57
  159. package/services/encode/run-budget.js +97 -97
  160. package/services/encode/run-command.js +747 -747
  161. package/services/encode/run-costs.js +155 -155
  162. package/services/encode/run-inputs.js +42 -42
  163. package/services/encode/segment-formats/fmp4.js +437 -437
  164. package/services/encode/segment-formats/index.js +77 -77
  165. package/services/encode/segment-formats/init-compat.js +324 -324
  166. package/services/encode/segment-formats/mp4-boxes.js +311 -311
  167. package/services/encode/segment-formats/mpegts.js +134 -134
  168. package/services/encode/start-stop-cost.js +178 -178
  169. package/services/encode/throughput.js +97 -97
  170. package/services/encode/torrent-cost.js +88 -88
  171. package/services/identity/installation-id.js +38 -38
  172. package/services/media/ContainerOrchestrator.js +149 -149
  173. package/services/media/KeyframeTables.js +316 -316
  174. package/services/media/SourceFile.js +298 -298
  175. package/services/media/SubtitleCues.js +520 -520
  176. package/services/media/SubtitleOrchestrator.js +167 -167
  177. package/services/media/audio-inventory.js +212 -212
  178. package/services/media/container/AviContainer.js +266 -266
  179. package/services/media/container/Container.js +603 -603
  180. package/services/media/container/ContainerFactory.js +56 -56
  181. package/services/media/container/KeyframeTable.js +272 -272
  182. package/services/media/container/MatroskaContainer.js +1494 -1494
  183. package/services/media/container/Mp4Container.js +1244 -1244
  184. package/services/media/container/SubtitleFileContainer.js +322 -322
  185. package/services/media/container/ebml-reader.js +159 -159
  186. package/services/media/ffmpeg-banner.js +260 -260
  187. package/services/media/keyframe-probe.js +157 -157
  188. package/services/media/media-info-probe.js +93 -93
  189. package/services/media/tracks/AudioTrack.js +131 -131
  190. package/services/media/tracks/ContainerTrack.js +75 -75
  191. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  192. package/services/media/tracks/SubtitleTrack.js +38 -38
  193. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  194. package/services/media/tracks/VideoTrack.js +33 -33
  195. package/services/media/tracks/index.js +15 -15
  196. package/services/media/tracks/language-detect.js +174 -174
  197. package/services/server/OutputLifecycle.js +418 -418
  198. package/services/server/SegmentServing.js +1146 -1146
  199. package/services/server/ViewerRequests.js +522 -522
  200. package/services/server/WaitLedger.js +142 -142
  201. package/services/server/controllers/PlaybackController.js +36 -36
  202. package/services/server/controllers/SubtitleController.js +109 -109
  203. package/services/server/http-responses.js +60 -60
  204. package/services/server/transcode-session-files.js +325 -325
  205. package/services/server/wire-outputs.js +983 -983
  206. package/services/storage/Diagnostics.js +202 -202
  207. package/services/storage/MachineBudget.js +235 -235
  208. package/services/storage/allowance.js +107 -107
  209. package/services/storage/core-dumps.js +106 -106
  210. package/services/storage/files/CompletedFiles.js +391 -391
  211. package/services/storage/files/piece-from-whole-file.js +118 -118
  212. package/services/storage/free.js +71 -71
  213. package/services/storage/keep.js +48 -48
  214. package/services/storage/machine-memory.js +56 -56
  215. package/services/storage/memory-report.js +575 -575
  216. package/services/storage/piece-store/piece-disk-store.js +516 -516
  217. package/services/storage/piece-store/piece-lru.js +495 -495
  218. package/services/storage/piece-store/shared-piece-store.js +1880 -1880
  219. package/services/storage/returns.js +103 -103
  220. package/services/storage/segment-store/SegmentStore.js +1196 -1196
  221. package/services/storage/wire.js +175 -175
  222. package/services/torrent/Contents.js +324 -324
  223. package/services/torrent/demand/DemandRegister.js +202 -202
  224. package/services/torrent/demand/Urgency.js +137 -137
  225. package/services/torrent/demand/Window.js +118 -118
  226. package/services/torrent/demand/index.js +8 -8
  227. package/services/torrent/demand/pieces.js +140 -140
  228. package/services/torrent/download/SwarmSelection.js +386 -386
  229. package/services/torrent/download/registry.js +102 -102
  230. package/services/torrent/download/withdraw-claim.js +80 -80
  231. package/services/torrent/files.js +455 -455
  232. package/services/torrent/naming.js +619 -619
  233. package/services/torrent/piece-store-of.js +29 -29
  234. package/services/torrent/supply-margin.js +262 -262
  235. package/services/torrent/torrent-pool.js +2 -2
  236. package/services/torrent/worker/background-fill.js +213 -213
  237. package/services/torrent/worker/channel.js +271 -271
  238. package/services/torrent/worker/fastest-wires.js +342 -342
  239. package/services/torrent/worker/handle-state.js +23 -23
  240. package/services/torrent/worker/held-bytes.js +155 -155
  241. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  242. package/services/torrent/worker/piece-reader.js +1078 -1078
  243. package/services/torrent/worker/pool-adapter.js +1 -1
  244. package/services/torrent/worker/resume-warm.js +107 -107
  245. package/services/torrent/worker/webrtc-shim.js +219 -219
  246. package/services/transport/data-channel-handler.js +1356 -1356
  247. package/services/transport/delivery-probe.js +842 -842
  248. package/services/transport/health-collector.js +53 -53
  249. package/services/transport/nat-classifier.js +231 -231
  250. package/services/transport/packet-witness.js +808 -808
  251. package/services/transport/port-mapper.js +235 -235
  252. package/services/transport/registry-api.js +81 -81
  253. package/services/transport/tunnel-client.js +447 -447
  254. package/services/transport/usrsctp-state.js +175 -175
  255. package/services/transport/webrtc-manager.js +484 -484
  256. package/services/viewer/Assignments.js +340 -340
  257. package/services/viewer/PriorityMap.js +390 -390
  258. package/services/viewer/PriorityOrchestrator.js +325 -325
  259. package/services/viewer/Viewer.js +657 -657
  260. package/services/viewer/Viewers.js +368 -368
  261. package/services/viewer/active-output.js +37 -37
  262. package/services/viewer/audio-start.js +46 -46
  263. package/services/viewer/buffer-trend.js +81 -81
  264. package/services/viewer/choices.js +647 -647
  265. package/services/viewer/positions.js +126 -126
  266. package/services/viewer/report-intake.js +46 -46
  267. package/store/source-registry.js +71 -73
  268. package/test/asset-paths.test.js +28 -28
  269. package/test/audio-inventory.test.js +176 -176
  270. package/test/audio-track-bitrate.test.js +40 -40
  271. package/test/auto-quality-step.test.js +707 -707
  272. package/test/available-share.test.js +76 -76
  273. package/test/background-fill.test.js +190 -190
  274. package/test/client-log-file.test.js +110 -110
  275. package/test/completed-files.test.js +115 -115
  276. package/test/concurrent-cost.test.js +148 -148
  277. package/test/container-media-info.test.js +228 -228
  278. package/test/contention.test.js +76 -76
  279. package/test/core-dumps.test.js +57 -57
  280. package/test/coverage-follows-the-disk.test.js +228 -228
  281. package/test/coverage-map.test.js +195 -195
  282. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  283. package/test/cut-times-timeline.test.js +65 -65
  284. package/test/cuts-follow-published-grid.test.js +109 -109
  285. package/test/data-channel-frame.test.js +63 -63
  286. package/test/dead-torrent-handle.test.js +52 -52
  287. package/test/declared-rates.test.js +116 -116
  288. package/test/declared-tracks.test.js +35 -35
  289. package/test/decode-cost-fit.test.js +202 -202
  290. package/test/decode-cost.test.js +462 -462
  291. package/test/delivery-probe.test.js +393 -393
  292. package/test/demand-register.test.js +206 -206
  293. package/test/dht-bootstrap.test.js +72 -72
  294. package/test/drain-threat.test.js +65 -65
  295. package/test/duplicate-add.test.js +48 -48
  296. package/test/encode-admission.test.js +146 -146
  297. package/test/encode-cost.test.js +283 -283
  298. package/test/encode-exit.test.js +111 -111
  299. package/test/encode-orchestrator.test.js +419 -419
  300. package/test/encode-plan-viewers.test.js +718 -718
  301. package/test/encode-plan.test.js +545 -545
  302. package/test/encode-run-state.test.js +314 -314
  303. package/test/encode-run.test.js +161 -161
  304. package/test/encode-slope.test.js +46 -46
  305. package/test/encoded-output.test.js +39 -39
  306. package/test/encoder-kinds.test.js +122 -122
  307. package/test/encoder-readings.test.js +46 -46
  308. package/test/every-claimant-is-told-its-share.test.js +152 -152
  309. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  310. package/test/failed-start-breaker.test.js +202 -202
  311. package/test/fastest-wires.test.js +143 -143
  312. package/test/ffmpeg-stream-counts.test.js +78 -78
  313. package/test/file-edges.test.js +191 -191
  314. package/test/fmp4-self-contained.test.js +80 -80
  315. package/test/fmp4-timestamps.test.js +140 -140
  316. package/test/fmp4-track-completeness.test.js +41 -41
  317. package/test/format-keyed-outputs.test.js +78 -78
  318. package/test/health-metrics.test.js +38 -38
  319. package/test/held-request-width.test.js +157 -157
  320. package/test/helpers/encode-run.js +136 -136
  321. package/test/helpers/held-file.js +29 -29
  322. package/test/helpers/manager.js +57 -57
  323. package/test/helpers/output-spec.js +63 -63
  324. package/test/host-load.test.js +68 -68
  325. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  326. package/test/init-compat.test.js +219 -219
  327. package/test/input-lost-quiets-the-plan.test.js +261 -261
  328. package/test/input-unavailable.test.js +48 -48
  329. package/test/installation-id.test.js +39 -39
  330. package/test/keeping-period.test.js +83 -83
  331. package/test/keyframe-index-accuracy.test.js +188 -188
  332. package/test/keyframe-table-budget.test.js +102 -102
  333. package/test/keyframes-belong-to-the-file.test.js +249 -249
  334. package/test/learned-median.test.js +37 -37
  335. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  336. package/test/limit-row-by-area.test.js +60 -60
  337. package/test/link-budget.test.js +111 -111
  338. package/test/link-probe.test.js +56 -56
  339. package/test/local-encoding-data.test.js +273 -273
  340. package/test/logger-repeats.test.js +134 -134
  341. package/test/look-ahead-coverage.test.js +56 -56
  342. package/test/manager-surface.test.js +292 -292
  343. package/test/matroska-cues-track.test.js +192 -192
  344. package/test/memory-budget.test.js +390 -390
  345. package/test/move-cost.test.js +216 -216
  346. package/test/mp4-subtitles.test.js +173 -173
  347. package/test/one-authority.test.js +285 -285
  348. package/test/one-budget.test.js +149 -149
  349. package/test/one-output-one-session.test.js +204 -204
  350. package/test/one-resource-divided.test.js +185 -185
  351. package/test/one-serving-output-per-height.test.js +458 -458
  352. package/test/orchestrator-wired.test.js +206 -206
  353. package/test/output-catalog.test.js +39 -39
  354. package/test/output-shape.test.js +21 -21
  355. package/test/output-spec.test.js +251 -251
  356. package/test/output-speed.test.js +88 -88
  357. package/test/outputs-being-prepared.test.js +40 -40
  358. package/test/packet-witness-ring.test.js +236 -236
  359. package/test/packet-witness.test.js +148 -148
  360. package/test/piece-disk-store.test.js +381 -381
  361. package/test/piece-from-whole-file.test.js +129 -129
  362. package/test/piece-lru.test.js +319 -319
  363. package/test/piece-reader.test.js +162 -162
  364. package/test/piece-selection-offset.test.js +85 -85
  365. package/test/piece-store-eviction.test.js +560 -560
  366. package/test/piece-store-never-refuses.test.js +153 -153
  367. package/test/piece-store-reservations.test.js +318 -318
  368. package/test/piece-store-slow-disk.test.js +159 -159
  369. package/test/piece-tail.test.js +97 -97
  370. package/test/plan-host-timings.test.js +68 -68
  371. package/test/plan-is-steady.test.js +225 -225
  372. package/test/priority-map-download.test.js +174 -174
  373. package/test/priority-map-emptied.test.js +204 -204
  374. package/test/priority-map-per-output.test.js +215 -215
  375. package/test/priority-map.test.js +163 -163
  376. package/test/probe-wedge-certainty.test.js +60 -60
  377. package/test/produced-copy-choice.test.js +213 -213
  378. package/test/published-timeline.test.js +83 -83
  379. package/test/quality-offer.test.js +162 -162
  380. package/test/quality-variants.test.js +1229 -1229
  381. package/test/rate-control-in-the-output.test.js +180 -180
  382. package/test/read-survives-withdrawal.test.js +164 -164
  383. package/test/read-window.test.js +365 -365
  384. package/test/resume-warm.test.js +39 -39
  385. package/test/run-graph-drift.test.js +37 -37
  386. package/test/run-inputs.test.js +80 -80
  387. package/test/run-intervals.test.js +100 -100
  388. package/test/run-position-follows-published-grid.test.js +89 -89
  389. package/test/run-progress.test.js +65 -65
  390. package/test/same-height-switch.test.js +664 -664
  391. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  392. package/test/seek-landing.test.js +109 -109
  393. package/test/seek-target-not-superseded.test.js +195 -195
  394. package/test/segment-closed-on-both-branches.test.js +96 -96
  395. package/test/segment-cut-times.test.js +63 -63
  396. package/test/segment-demand.test.js +80 -80
  397. package/test/segment-hold-supersede.test.js +91 -91
  398. package/test/segment-judged-by-its-own-header.test.js +221 -221
  399. package/test/segment-serve-wiring.test.js +475 -475
  400. package/test/segment-store-eviction.test.js +247 -247
  401. package/test/segment-store.test.js +323 -323
  402. package/test/segment-true-start.test.js +86 -86
  403. package/test/segments-are-shared.test.js +176 -176
  404. package/test/send-chunk-bytes.test.js +86 -86
  405. package/test/serving-existing-output.test.js +102 -102
  406. package/test/serving-output.test.js +102 -102
  407. package/test/shared-piece-store.test.js +310 -310
  408. package/test/sidecar-naming.test.js +142 -142
  409. package/test/source-file.test.js +133 -133
  410. package/test/source-files-route.test.js +103 -103
  411. package/test/source-registry.test.js +18 -0
  412. package/test/split-a-stretch.test.js +139 -139
  413. package/test/stale-request-after-seek.test.js +158 -158
  414. package/test/startup-readings.test.js +124 -124
  415. package/test/stream-route.test.js +182 -182
  416. package/test/subtitle-cue-framing.test.js +200 -200
  417. package/test/subtitle-cue-source.test.js +102 -102
  418. package/test/subtitle-cue-walk.test.js +370 -370
  419. package/test/subtitle-cursor.test.js +63 -63
  420. package/test/subtitle-defaults.test.js +118 -118
  421. package/test/subtitle-language.test.js +252 -252
  422. package/test/subtitle-track-numbering.test.js +386 -386
  423. package/test/supply-margin.test.js +177 -177
  424. package/test/swarm-follows-readers.test.js +174 -174
  425. package/test/swarm-reach.test.js +130 -130
  426. package/test/swarm-selection.test.js +220 -220
  427. package/test/tail-duplication.test.js +167 -167
  428. package/test/the-channel-has-both-ends.test.js +91 -91
  429. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  430. package/test/thread-pool.test.js +53 -53
  431. package/test/timeline.test.js +84 -84
  432. package/test/torrent-contents.test.js +235 -235
  433. package/test/torrent-cost.test.js +71 -71
  434. package/test/torrent-files.test.js +221 -221
  435. package/test/torrent-naming.test.js +255 -255
  436. package/test/tracks-begin-together.test.js +209 -209
  437. package/test/tunnel-renewal.test.js +167 -167
  438. package/test/two-viewers-one-picture.test.js +407 -407
  439. package/test/upload-hurry.test.js +186 -186
  440. package/test/usrsctp-state.test.js +131 -131
  441. package/test/video-facts.test.js +102 -102
  442. package/test/viewer-assignments.test.js +143 -143
  443. package/test/viewer-outputs.test.js +285 -285
  444. package/test/viewer-presence.test.js +116 -116
  445. package/test/viewer-requests-admission.test.js +89 -89
  446. package/test/viewer-states.test.js +189 -189
  447. package/test/viewer-subtitle-subscription.test.js +96 -96
  448. package/test/viewer.test.js +139 -139
  449. package/test/viewing-generations.test.js +390 -390
  450. package/test/visible-rung.test.js +37 -37
  451. package/test/wait-ledger.test.js +100 -100
  452. package/test/webrtc-shim.test.js +149 -149
  453. package/test/wedge-certainty.test.js +131 -131
  454. package/test/withdraw-piece-claim.test.js +212 -212
  455. package/test/worker-channel.test.js +158 -158
  456. package/utils/client-log-file.js +151 -151
  457. package/utils/logger.js +312 -312
  458. package/utils/parse-range.js +25 -25
  459. package/utils/perf.js +59 -59
  460. package/{services/torrent → utils}/torrent-source-key.js +38 -38
@@ -1,311 +1,311 @@
1
- /**
2
- * @file Minimal ISO Base Media File Format (ISO/IEC 14496-12) box reader/writer.
3
- *
4
- * Only what the fMP4 segment format needs: read the per-track media timescale
5
- * out of an init segment, and rewrite each fragment's
6
- * `tfdt` (TrackFragmentBaseMediaDecodeTime) so a segment states where it sits
7
- * on the media timeline. Deliberately tiny and dependency-free — it never
8
- * descends into `mdat` (the payload), so cost is proportional to the header,
9
- * not to the segment size.
10
- */
11
-
12
- /**
13
- * Container boxes whose payload is a sequence of child boxes. Anything else is
14
- * treated as a leaf, so `mdat` (the media payload) is never walked into.
15
- *
16
- * @type {ReadonlySet<string>}
17
- */
18
- const CONTAINER_BOXES = new Set(["moov", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
19
-
20
- /**
21
- * Walk the box tree, invoking `visit` for every box encountered.
22
- *
23
- * @param {Buffer} buffer
24
- * @param {(type: string, bodyStart: number, bodyEnd: number) => void} visit
25
- * `bodyStart`/`bodyEnd` delimit the box payload (header excluded).
26
- * @param {number} [start=0]
27
- * @param {number} [end=buffer.length]
28
- * @param {(type: string) => boolean} [descend] - Whether to walk into a box's
29
- * payload. The default is the container list above, which is what every
30
- * reader of a whole tree wants. A caller that needs to know WHOSE child a box
31
- * is passes `() => false` and recurses itself, because a walk that descends
32
- * for you cannot say which parent it descended from.
33
- * @returns {void}
34
- */
35
- export function walkBoxes(buffer, visit, start = 0, end = buffer.length, descend = (type) => CONTAINER_BOXES.has(type)) {
36
- let offset = start;
37
- while (offset + 8 <= end) {
38
- let size = buffer.readUInt32BE(offset);
39
- const type = buffer.toString("latin1", offset + 4, offset + 8);
40
- let headerSize = 8;
41
- if (size === 1) {
42
- // 64-bit `largesize` follows the type.
43
- if (offset + 16 > end) {
44
- return;
45
- }
46
- size = Number(buffer.readBigUInt64BE(offset + 8));
47
- headerSize = 16;
48
- } else if (size === 0) {
49
- // "to end of file"
50
- size = end - offset;
51
- }
52
- if (size < headerSize || offset + size > end) {
53
- return; // truncated or malformed — stop rather than read out of bounds
54
- }
55
- visit(type, offset + headerSize, offset + size);
56
- if (descend(type)) {
57
- walkBoxes(buffer, visit, offset + headerSize, offset + size, descend);
58
- }
59
- offset += size;
60
- }
61
- }
62
-
63
- /**
64
- * Media timescale (ticks per second) of every track in an init segment, keyed
65
- * by track id. `tfdt` values are expressed in this unit, so it is required to
66
- * convert a wall-clock position into a `baseMediaDecodeTime`.
67
- *
68
- * Read from each track's `tkhd` (track id) + `mdia/mdhd` (timescale) pair;
69
- * within a `trak` the `tkhd` always precedes the `mdhd`, so a single ordered
70
- * pass pairs them correctly.
71
- *
72
- * @param {Buffer} initSegment
73
- * @returns {Map<number, number>} trackId → timescale
74
- */
75
- export function readTrackTimescales(initSegment) {
76
- const timescales = new Map();
77
- let currentTrackId = null;
78
- walkBoxes(initSegment, (type, bodyStart) => {
79
- if (type === "tkhd") {
80
- const version = initSegment[bodyStart];
81
- // v1 widens creation/modification time to 64 bit, moving track_id by 8.
82
- const trackIdOffset = version === 1 ? bodyStart + 20 : bodyStart + 12;
83
- if (trackIdOffset + 4 <= initSegment.length) {
84
- currentTrackId = initSegment.readUInt32BE(trackIdOffset);
85
- }
86
- } else if (type === "mdhd" && currentTrackId !== null) {
87
- const version = initSegment[bodyStart];
88
- const timescaleOffset = version === 1 ? bodyStart + 20 : bodyStart + 12;
89
- if (timescaleOffset + 4 <= initSegment.length) {
90
- timescales.set(currentTrackId, initSegment.readUInt32BE(timescaleOffset));
91
- }
92
- currentTrackId = null;
93
- }
94
- });
95
- return timescales;
96
- }
97
-
98
- /**
99
- * Rewrite every fragment's `tfdt` so the segment declares that it starts at
100
- * `startSeconds` on the media timeline.
101
- *
102
- * WHY THIS IS NEEDED — ffmpeg's HLS/fMP4 output writes `tfdt = 0` in every
103
- * seek-restart run and records the run's start offset in an `elst` (edit list)
104
- * inside that run's init segment instead. That is self-consistent only while
105
- * the init and the segments come from the SAME run. We serve one init for the
106
- * whole session (the player fetches `#EXT-X-MAP` once and never re-fetches it),
107
- * so a post-seek segment read against the cached init loses its offset entirely
108
- * and appears to start at ~0 — the player finds nothing at the position it
109
- * seeked to, discards the segment and re-requests it, forever. Verified in the
110
- * field 2026-08-01: segments 402/403 re-fetched in a loop for over two minutes
111
- * at full link speed with the buffer stuck at 0 s, while the transcode itself
112
- * was healthy. No ffmpeg muxer/flag combination avoids this — HLS and DASH
113
- * muxers, `-copyts`, `-output_ts_offset`, `-itsoffset`, `-avoid_negative_ts`,
114
- * `-movflags -use_edts/+dash/+frag_discont/+global_sidx` were all measured and
115
- * all produce `tfdt = 0`.
116
- *
117
- * Stamping the true value restores what CMAF (ISO/IEC 23000-19) requires of an
118
- * independently-addressable segment anyway: it carries its own position, so it
119
- * is valid against any init for the same tracks.
120
- *
121
- * A SEGMENT MAY HOLD SEVERAL FRAGMENTS PER TRACK, so the segment's start is
122
- * applied as a SHIFT, not as a value written into every `tfdt`. The muxer that
123
- * takes explicit cut times uses `frag_keyframe`, which opens a fragment at each
124
- * keyframe, while a cut point is only every few keyframes — measured on the
125
- * field host: a 6 s piece carried three fragments per track, at 0, 2 and 4 s of
126
- * its own clock. Writing the segment's start into all three made them claim the
127
- * same decode time; the player rejected the segment and re-fetched it forever
128
- * (field 2026-08-04: segments 1 and 2 alternating for minutes, each served in
129
- * tens of milliseconds, transcode healthy at 12x). Each track's first fragment
130
- * therefore defines the base and the rest keep their distance from it. With one
131
- * fragment per track — what the `hls` muxer produces — a shift and a write are
132
- * the same thing, so both paths are served by this.
133
- *
134
- * Mutates a copy; the caller's buffer is untouched.
135
- *
136
- * @param {Buffer} segment
137
- * @param {number} startSeconds - Position of this segment on the 0-based output timeline.
138
- * @param {Map<number, number>} trackTimescales - From {@link readTrackTimescales}.
139
- * @returns {Buffer} The segment with corrected `tfdt` values.
140
- */
141
- export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
142
- if (!Number.isFinite(startSeconds) || startSeconds < 0 || trackTimescales.size === 0) {
143
- return segment;
144
- }
145
- const stamped = Buffer.from(segment);
146
- // `tfhd` carries the track id and always precedes the `tfdt` inside the same
147
- // `traf`, so an ordered pass pairs each `tfdt` with its track's timescale.
148
- let currentTrackId = null;
149
- /** @type {Map<number, number>} trackId → decode time of that track's first fragment. */
150
- const fragmentBase = new Map();
151
- walkBoxes(stamped, (type, bodyStart, bodyEnd) => {
152
- if (type === "tfhd") {
153
- if (bodyStart + 8 <= bodyEnd) {
154
- currentTrackId = stamped.readUInt32BE(bodyStart + 4);
155
- }
156
- return;
157
- }
158
- if (type !== "tfdt" || currentTrackId === null) {
159
- return;
160
- }
161
- const timescale = trackTimescales.get(currentTrackId);
162
- if (!timescale) {
163
- return;
164
- }
165
- const version = stamped[bodyStart];
166
- if (version === 1 ? bodyStart + 12 > bodyEnd : bodyStart + 8 > bodyEnd) {
167
- return;
168
- }
169
- const existing =
170
- version === 1
171
- ? Number(stamped.readBigUInt64BE(bodyStart + 4))
172
- : stamped.readUInt32BE(bodyStart + 4);
173
- if (!fragmentBase.has(currentTrackId)) {
174
- fragmentBase.set(currentTrackId, existing);
175
- }
176
- // Distance from the track's first fragment in this segment. Never negative:
177
- // decode times only move forward, and a malformed one must not drag a later
178
- // fragment behind the segment's start.
179
- const withinSegment = Math.max(0, existing - (fragmentBase.get(currentTrackId) ?? 0));
180
- const value = Math.round(startSeconds * timescale) + withinSegment;
181
- if (version === 1) {
182
- stamped.writeBigUInt64BE(BigInt(value), bodyStart + 4);
183
- } else if (value <= 0xffffffff) {
184
- // A 32-bit field cannot express beyond ~2^32 ticks; leave it rather than
185
- // write a wrapped value (the player would land somewhere arbitrary).
186
- stamped.writeUInt32BE(value, bodyStart + 4);
187
- }
188
- });
189
- return stamped;
190
- }
191
-
192
- /**
193
- * Where a self-contained piece really begins, in seconds, or null.
194
- *
195
- * The `segment` muxer writes each piece with its own `moov`, and puts the
196
- * piece's position on the source timeline into an EMPTY EDIT at the head of the
197
- * track's edit list: an entry whose `media_time` is -1 and whose duration, in
198
- * the movie timescale, is the offset. That is the piece's own account of where
199
- * it sits, and it is the only honest one available.
200
- *
201
- * It matters because the alternative — the time the playlist ASSIGNED to that
202
- * segment — can be wrong. The playlist is built from the container's keyframe
203
- * index, and an index can list times that are not keyframes: measured
204
- * 2026-08-06 on a Matroska file whose index claimed one at 157.99 s while the
205
- * real keyframes were at 153.82 and 164.247. The cut therefore produced a piece
206
- * starting at 153.82, and stamping it with the playlist's 157.99 told the
207
- * player that picture belonged four seconds later than it did — while the
208
- * subtitles, extracted straight from the source, kept the true times. The
209
- * result was a steady 4.17 s desync between speech and text.
210
- *
211
- * @param {Buffer} piece
212
- * @returns {number | null} Seconds, or null when the piece carries no edit list.
213
- */
214
- export function readSelfContainedStartSeconds(piece) {
215
- let movieTimescale = 0;
216
- let startSeconds = null;
217
- walkBoxes(piece, (type, bodyStart, bodyEnd) => {
218
- if (type === "mvhd" && movieTimescale === 0) {
219
- const version = piece[bodyStart];
220
- const offset = version === 1 ? bodyStart + 20 : bodyStart + 12;
221
- if (offset + 4 <= piece.length) {
222
- movieTimescale = piece.readUInt32BE(offset);
223
- }
224
- return;
225
- }
226
- if (type !== "elst" || startSeconds !== null || movieTimescale === 0) {
227
- return;
228
- }
229
- const version = piece[bodyStart];
230
- const entryStart = bodyStart + 8;
231
- if (version === 1) {
232
- if (entryStart + 16 > bodyEnd) {
233
- return;
234
- }
235
- const duration = Number(piece.readBigUInt64BE(entryStart));
236
- const mediaTime = piece.readBigInt64BE(entryStart + 8);
237
- if (mediaTime === -1n) {
238
- startSeconds = duration / movieTimescale;
239
- }
240
- return;
241
- }
242
- if (entryStart + 8 > bodyEnd) {
243
- return;
244
- }
245
- const duration = piece.readUInt32BE(entryStart);
246
- const mediaTime = piece.readInt32BE(entryStart + 4);
247
- if (mediaTime === -1) {
248
- startSeconds = duration / movieTimescale;
249
- }
250
- });
251
- return startSeconds;
252
- }
253
-
254
- /**
255
- * Sample entry types that describe a picture. Anything else in an `stsd` is a
256
- * soundtrack or a text track, whose "size" means nothing here.
257
- *
258
- * @type {ReadonlySet<string>}
259
- */
260
- const VISUAL_SAMPLE_ENTRIES = new Set(["avc1", "avc3", "hvc1", "hev1", "hvc2", "av01", "vp08", "vp09", "mp4v"]);
261
-
262
- /**
263
- * The picture size an init segment describes, in pixels, or null when it
264
- * describes no picture.
265
- *
266
- * Read from the visual sample entry rather than taken from our own record of
267
- * what the encoder was told, because those two disagreeing IS the fault this
268
- * exists to name: the init segment is fetched once, by `#EXT-X-MAP`, and then
269
- * every fragment of the session is decoded against it. A run that encodes
270
- * another size produces fragments the decoder cannot read — measured
271
- * 2026-08-21, a browser went on reporting 1280x720 for three and a half
272
- * minutes after the encoder had left for 960x540, over a band of macroblock
273
- * garbage.
274
- *
275
- * The layout is ISO/IEC 14496-12 `SampleEntry` (6 reserved bytes + 2 bytes of
276
- * data_reference_index) followed by `VisualSampleEntry`'s 16 bytes of
277
- * pre_defined/reserved, then width and height as 16-bit integers.
278
- *
279
- * @param {Buffer} initSegment
280
- * @returns {{ width: number, height: number } | null}
281
- */
282
- export function readVideoSampleSize(initSegment) {
283
- if (!initSegment || initSegment.length === 0) {
284
- return null;
285
- }
286
- /** @type {{ width: number, height: number } | null} */
287
- let found = null;
288
- walkBoxes(initSegment, (type, bodyStart, bodyEnd) => {
289
- if (type !== "stsd" || found !== null) {
290
- return;
291
- }
292
- // Full box: version + flags, then the entry count.
293
- let offset = bodyStart + 8;
294
- while (offset + 8 <= bodyEnd && found === null) {
295
- const size = initSegment.readUInt32BE(offset);
296
- const entryType = initSegment.toString("latin1", offset + 4, offset + 8);
297
- if (size < 8 || offset + size > bodyEnd) {
298
- return; // malformed — say nothing rather than read out of bounds
299
- }
300
- if (VISUAL_SAMPLE_ENTRIES.has(entryType) && offset + 36 <= bodyEnd) {
301
- const width = initSegment.readUInt16BE(offset + 32);
302
- const height = initSegment.readUInt16BE(offset + 34);
303
- if (width > 0 && height > 0) {
304
- found = { width, height };
305
- }
306
- }
307
- offset += size;
308
- }
309
- });
310
- return found;
311
- }
1
+ /**
2
+ * @file Minimal ISO Base Media File Format (ISO/IEC 14496-12) box reader/writer.
3
+ *
4
+ * Only what the fMP4 segment format needs: read the per-track media timescale
5
+ * out of an init segment, and rewrite each fragment's
6
+ * `tfdt` (TrackFragmentBaseMediaDecodeTime) so a segment states where it sits
7
+ * on the media timeline. Deliberately tiny and dependency-free — it never
8
+ * descends into `mdat` (the payload), so cost is proportional to the header,
9
+ * not to the segment size.
10
+ */
11
+
12
+ /**
13
+ * Container boxes whose payload is a sequence of child boxes. Anything else is
14
+ * treated as a leaf, so `mdat` (the media payload) is never walked into.
15
+ *
16
+ * @type {ReadonlySet<string>}
17
+ */
18
+ const CONTAINER_BOXES = new Set(["moov", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
19
+
20
+ /**
21
+ * Walk the box tree, invoking `visit` for every box encountered.
22
+ *
23
+ * @param {Buffer} buffer
24
+ * @param {(type: string, bodyStart: number, bodyEnd: number) => void} visit
25
+ * `bodyStart`/`bodyEnd` delimit the box payload (header excluded).
26
+ * @param {number} [start=0]
27
+ * @param {number} [end=buffer.length]
28
+ * @param {(type: string) => boolean} [descend] - Whether to walk into a box's
29
+ * payload. The default is the container list above, which is what every
30
+ * reader of a whole tree wants. A caller that needs to know WHOSE child a box
31
+ * is passes `() => false` and recurses itself, because a walk that descends
32
+ * for you cannot say which parent it descended from.
33
+ * @returns {void}
34
+ */
35
+ export function walkBoxes(buffer, visit, start = 0, end = buffer.length, descend = (type) => CONTAINER_BOXES.has(type)) {
36
+ let offset = start;
37
+ while (offset + 8 <= end) {
38
+ let size = buffer.readUInt32BE(offset);
39
+ const type = buffer.toString("latin1", offset + 4, offset + 8);
40
+ let headerSize = 8;
41
+ if (size === 1) {
42
+ // 64-bit `largesize` follows the type.
43
+ if (offset + 16 > end) {
44
+ return;
45
+ }
46
+ size = Number(buffer.readBigUInt64BE(offset + 8));
47
+ headerSize = 16;
48
+ } else if (size === 0) {
49
+ // "to end of file"
50
+ size = end - offset;
51
+ }
52
+ if (size < headerSize || offset + size > end) {
53
+ return; // truncated or malformed — stop rather than read out of bounds
54
+ }
55
+ visit(type, offset + headerSize, offset + size);
56
+ if (descend(type)) {
57
+ walkBoxes(buffer, visit, offset + headerSize, offset + size, descend);
58
+ }
59
+ offset += size;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Media timescale (ticks per second) of every track in an init segment, keyed
65
+ * by track id. `tfdt` values are expressed in this unit, so it is required to
66
+ * convert a wall-clock position into a `baseMediaDecodeTime`.
67
+ *
68
+ * Read from each track's `tkhd` (track id) + `mdia/mdhd` (timescale) pair;
69
+ * within a `trak` the `tkhd` always precedes the `mdhd`, so a single ordered
70
+ * pass pairs them correctly.
71
+ *
72
+ * @param {Buffer} initSegment
73
+ * @returns {Map<number, number>} trackId → timescale
74
+ */
75
+ export function readTrackTimescales(initSegment) {
76
+ const timescales = new Map();
77
+ let currentTrackId = null;
78
+ walkBoxes(initSegment, (type, bodyStart) => {
79
+ if (type === "tkhd") {
80
+ const version = initSegment[bodyStart];
81
+ // v1 widens creation/modification time to 64 bit, moving track_id by 8.
82
+ const trackIdOffset = version === 1 ? bodyStart + 20 : bodyStart + 12;
83
+ if (trackIdOffset + 4 <= initSegment.length) {
84
+ currentTrackId = initSegment.readUInt32BE(trackIdOffset);
85
+ }
86
+ } else if (type === "mdhd" && currentTrackId !== null) {
87
+ const version = initSegment[bodyStart];
88
+ const timescaleOffset = version === 1 ? bodyStart + 20 : bodyStart + 12;
89
+ if (timescaleOffset + 4 <= initSegment.length) {
90
+ timescales.set(currentTrackId, initSegment.readUInt32BE(timescaleOffset));
91
+ }
92
+ currentTrackId = null;
93
+ }
94
+ });
95
+ return timescales;
96
+ }
97
+
98
+ /**
99
+ * Rewrite every fragment's `tfdt` so the segment declares that it starts at
100
+ * `startSeconds` on the media timeline.
101
+ *
102
+ * WHY THIS IS NEEDED — ffmpeg's HLS/fMP4 output writes `tfdt = 0` in every
103
+ * seek-restart run and records the run's start offset in an `elst` (edit list)
104
+ * inside that run's init segment instead. That is self-consistent only while
105
+ * the init and the segments come from the SAME run. We serve one init for the
106
+ * whole session (the player fetches `#EXT-X-MAP` once and never re-fetches it),
107
+ * so a post-seek segment read against the cached init loses its offset entirely
108
+ * and appears to start at ~0 — the player finds nothing at the position it
109
+ * seeked to, discards the segment and re-requests it, forever. Verified in the
110
+ * field 2026-08-01: segments 402/403 re-fetched in a loop for over two minutes
111
+ * at full link speed with the buffer stuck at 0 s, while the transcode itself
112
+ * was healthy. No ffmpeg muxer/flag combination avoids this — HLS and DASH
113
+ * muxers, `-copyts`, `-output_ts_offset`, `-itsoffset`, `-avoid_negative_ts`,
114
+ * `-movflags -use_edts/+dash/+frag_discont/+global_sidx` were all measured and
115
+ * all produce `tfdt = 0`.
116
+ *
117
+ * Stamping the true value restores what CMAF (ISO/IEC 23000-19) requires of an
118
+ * independently-addressable segment anyway: it carries its own position, so it
119
+ * is valid against any init for the same tracks.
120
+ *
121
+ * A SEGMENT MAY HOLD SEVERAL FRAGMENTS PER TRACK, so the segment's start is
122
+ * applied as a SHIFT, not as a value written into every `tfdt`. The muxer that
123
+ * takes explicit cut times uses `frag_keyframe`, which opens a fragment at each
124
+ * keyframe, while a cut point is only every few keyframes — measured on the
125
+ * field host: a 6 s piece carried three fragments per track, at 0, 2 and 4 s of
126
+ * its own clock. Writing the segment's start into all three made them claim the
127
+ * same decode time; the player rejected the segment and re-fetched it forever
128
+ * (field 2026-08-04: segments 1 and 2 alternating for minutes, each served in
129
+ * tens of milliseconds, transcode healthy at 12x). Each track's first fragment
130
+ * therefore defines the base and the rest keep their distance from it. With one
131
+ * fragment per track — what the `hls` muxer produces — a shift and a write are
132
+ * the same thing, so both paths are served by this.
133
+ *
134
+ * Mutates a copy; the caller's buffer is untouched.
135
+ *
136
+ * @param {Buffer} segment
137
+ * @param {number} startSeconds - Position of this segment on the 0-based output timeline.
138
+ * @param {Map<number, number>} trackTimescales - From {@link readTrackTimescales}.
139
+ * @returns {Buffer} The segment with corrected `tfdt` values.
140
+ */
141
+ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
142
+ if (!Number.isFinite(startSeconds) || startSeconds < 0 || trackTimescales.size === 0) {
143
+ return segment;
144
+ }
145
+ const stamped = Buffer.from(segment);
146
+ // `tfhd` carries the track id and always precedes the `tfdt` inside the same
147
+ // `traf`, so an ordered pass pairs each `tfdt` with its track's timescale.
148
+ let currentTrackId = null;
149
+ /** @type {Map<number, number>} trackId → decode time of that track's first fragment. */
150
+ const fragmentBase = new Map();
151
+ walkBoxes(stamped, (type, bodyStart, bodyEnd) => {
152
+ if (type === "tfhd") {
153
+ if (bodyStart + 8 <= bodyEnd) {
154
+ currentTrackId = stamped.readUInt32BE(bodyStart + 4);
155
+ }
156
+ return;
157
+ }
158
+ if (type !== "tfdt" || currentTrackId === null) {
159
+ return;
160
+ }
161
+ const timescale = trackTimescales.get(currentTrackId);
162
+ if (!timescale) {
163
+ return;
164
+ }
165
+ const version = stamped[bodyStart];
166
+ if (version === 1 ? bodyStart + 12 > bodyEnd : bodyStart + 8 > bodyEnd) {
167
+ return;
168
+ }
169
+ const existing =
170
+ version === 1
171
+ ? Number(stamped.readBigUInt64BE(bodyStart + 4))
172
+ : stamped.readUInt32BE(bodyStart + 4);
173
+ if (!fragmentBase.has(currentTrackId)) {
174
+ fragmentBase.set(currentTrackId, existing);
175
+ }
176
+ // Distance from the track's first fragment in this segment. Never negative:
177
+ // decode times only move forward, and a malformed one must not drag a later
178
+ // fragment behind the segment's start.
179
+ const withinSegment = Math.max(0, existing - (fragmentBase.get(currentTrackId) ?? 0));
180
+ const value = Math.round(startSeconds * timescale) + withinSegment;
181
+ if (version === 1) {
182
+ stamped.writeBigUInt64BE(BigInt(value), bodyStart + 4);
183
+ } else if (value <= 0xffffffff) {
184
+ // A 32-bit field cannot express beyond ~2^32 ticks; leave it rather than
185
+ // write a wrapped value (the player would land somewhere arbitrary).
186
+ stamped.writeUInt32BE(value, bodyStart + 4);
187
+ }
188
+ });
189
+ return stamped;
190
+ }
191
+
192
+ /**
193
+ * Where a self-contained piece really begins, in seconds, or null.
194
+ *
195
+ * The `segment` muxer writes each piece with its own `moov`, and puts the
196
+ * piece's position on the source timeline into an EMPTY EDIT at the head of the
197
+ * track's edit list: an entry whose `media_time` is -1 and whose duration, in
198
+ * the movie timescale, is the offset. That is the piece's own account of where
199
+ * it sits, and it is the only honest one available.
200
+ *
201
+ * It matters because the alternative — the time the playlist ASSIGNED to that
202
+ * segment — can be wrong. The playlist is built from the container's keyframe
203
+ * index, and an index can list times that are not keyframes: measured
204
+ * 2026-08-06 on a Matroska file whose index claimed one at 157.99 s while the
205
+ * real keyframes were at 153.82 and 164.247. The cut therefore produced a piece
206
+ * starting at 153.82, and stamping it with the playlist's 157.99 told the
207
+ * player that picture belonged four seconds later than it did — while the
208
+ * subtitles, extracted straight from the source, kept the true times. The
209
+ * result was a steady 4.17 s desync between speech and text.
210
+ *
211
+ * @param {Buffer} piece
212
+ * @returns {number | null} Seconds, or null when the piece carries no edit list.
213
+ */
214
+ export function readSelfContainedStartSeconds(piece) {
215
+ let movieTimescale = 0;
216
+ let startSeconds = null;
217
+ walkBoxes(piece, (type, bodyStart, bodyEnd) => {
218
+ if (type === "mvhd" && movieTimescale === 0) {
219
+ const version = piece[bodyStart];
220
+ const offset = version === 1 ? bodyStart + 20 : bodyStart + 12;
221
+ if (offset + 4 <= piece.length) {
222
+ movieTimescale = piece.readUInt32BE(offset);
223
+ }
224
+ return;
225
+ }
226
+ if (type !== "elst" || startSeconds !== null || movieTimescale === 0) {
227
+ return;
228
+ }
229
+ const version = piece[bodyStart];
230
+ const entryStart = bodyStart + 8;
231
+ if (version === 1) {
232
+ if (entryStart + 16 > bodyEnd) {
233
+ return;
234
+ }
235
+ const duration = Number(piece.readBigUInt64BE(entryStart));
236
+ const mediaTime = piece.readBigInt64BE(entryStart + 8);
237
+ if (mediaTime === -1n) {
238
+ startSeconds = duration / movieTimescale;
239
+ }
240
+ return;
241
+ }
242
+ if (entryStart + 8 > bodyEnd) {
243
+ return;
244
+ }
245
+ const duration = piece.readUInt32BE(entryStart);
246
+ const mediaTime = piece.readInt32BE(entryStart + 4);
247
+ if (mediaTime === -1) {
248
+ startSeconds = duration / movieTimescale;
249
+ }
250
+ });
251
+ return startSeconds;
252
+ }
253
+
254
+ /**
255
+ * Sample entry types that describe a picture. Anything else in an `stsd` is a
256
+ * soundtrack or a text track, whose "size" means nothing here.
257
+ *
258
+ * @type {ReadonlySet<string>}
259
+ */
260
+ const VISUAL_SAMPLE_ENTRIES = new Set(["avc1", "avc3", "hvc1", "hev1", "hvc2", "av01", "vp08", "vp09", "mp4v"]);
261
+
262
+ /**
263
+ * The picture size an init segment describes, in pixels, or null when it
264
+ * describes no picture.
265
+ *
266
+ * Read from the visual sample entry rather than taken from our own record of
267
+ * what the encoder was told, because those two disagreeing IS the fault this
268
+ * exists to name: the init segment is fetched once, by `#EXT-X-MAP`, and then
269
+ * every fragment of the session is decoded against it. A run that encodes
270
+ * another size produces fragments the decoder cannot read — measured
271
+ * 2026-08-21, a browser went on reporting 1280x720 for three and a half
272
+ * minutes after the encoder had left for 960x540, over a band of macroblock
273
+ * garbage.
274
+ *
275
+ * The layout is ISO/IEC 14496-12 `SampleEntry` (6 reserved bytes + 2 bytes of
276
+ * data_reference_index) followed by `VisualSampleEntry`'s 16 bytes of
277
+ * pre_defined/reserved, then width and height as 16-bit integers.
278
+ *
279
+ * @param {Buffer} initSegment
280
+ * @returns {{ width: number, height: number } | null}
281
+ */
282
+ export function readVideoSampleSize(initSegment) {
283
+ if (!initSegment || initSegment.length === 0) {
284
+ return null;
285
+ }
286
+ /** @type {{ width: number, height: number } | null} */
287
+ let found = null;
288
+ walkBoxes(initSegment, (type, bodyStart, bodyEnd) => {
289
+ if (type !== "stsd" || found !== null) {
290
+ return;
291
+ }
292
+ // Full box: version + flags, then the entry count.
293
+ let offset = bodyStart + 8;
294
+ while (offset + 8 <= bodyEnd && found === null) {
295
+ const size = initSegment.readUInt32BE(offset);
296
+ const entryType = initSegment.toString("latin1", offset + 4, offset + 8);
297
+ if (size < 8 || offset + size > bodyEnd) {
298
+ return; // malformed — say nothing rather than read out of bounds
299
+ }
300
+ if (VISUAL_SAMPLE_ENTRIES.has(entryType) && offset + 36 <= bodyEnd) {
301
+ const width = initSegment.readUInt16BE(offset + 32);
302
+ const height = initSegment.readUInt16BE(offset + 34);
303
+ if (width > 0 && height > 0) {
304
+ found = { width, height };
305
+ }
306
+ }
307
+ offset += size;
308
+ }
309
+ });
310
+ return found;
311
+ }