@torrent-tv/proxy 2.86.0 → 2.87.1

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 (385) hide show
  1. package/CHANGELOG.md +2142 -1915
  2. package/CLAUDE.md +322 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/browser-proxy-contract.md +94 -0
  7. package/docs/container-architecture.md +218 -213
  8. package/docs/download-architecture.md +7 -7
  9. package/docs/encode-architecture.md +287 -11
  10. package/docs/encode-run-state.md +2 -2
  11. package/docs/logs.md +97 -97
  12. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  13. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  14. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  15. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  18. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  19. package/openspec/changes/cold-start/design.md +1 -1
  20. package/openspec/changes/disk-cap/proposal.md +1 -1
  21. package/openspec/changes/proxy-observability/proposal.md +2 -2
  22. package/openspec/changes/track-selection/proposal.md +1 -1
  23. package/openspec/changes/transcode-quality/proposal.md +1 -1
  24. package/package.json +2 -2
  25. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  26. package/research/double-spawn-2026-09-10.md +171 -171
  27. package/routes/api/playback-plan/post.js +2 -2
  28. package/routes/api/sources/files/get.js +1 -1
  29. package/routes/api/sources/stats/get.js +1 -1
  30. package/routes/api/sources/warm/post.js +10 -3
  31. package/routes/api/subtitles/get.js +230 -230
  32. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  33. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  34. package/routes/api/transcode-sessions/post.js +160 -139
  35. package/routes/api/transcode-sessions/progress/get.js +3 -3
  36. package/routes/api/transcode-sessions/release/post.js +3 -3
  37. package/routes/api/transcode-sessions/seek/post.js +12 -3
  38. package/routes/stream/get.js +2 -2
  39. package/routes/transcode/audio-file/get.js +15 -6
  40. package/routes/transcode/audio-warm/get.js +9 -6
  41. package/routes/transcode/session-file/get.js +52 -268
  42. package/routes/transcode/variant-file/get.js +46 -7
  43. package/routes/transcode/variant-warm/get.js +19 -6
  44. package/scripts/render-run-graph.js +1 -1
  45. package/server.js +720 -465
  46. package/services/encode/CoverageMap.js +428 -428
  47. package/services/encode/CushionReport.js +345 -0
  48. package/services/encode/EncodeAdmission.js +332 -0
  49. package/services/{orchestrators → encode}/EncodeOrchestrator.js +307 -29
  50. package/services/encode/EncodeRun.js +16 -9
  51. package/services/encode/EncodeRuns.js +1229 -0
  52. package/services/encode/Encoder.js +18 -2
  53. package/services/encode/EncoderSelection.js +38 -0
  54. package/services/encode/LocalObservations.js +359 -0
  55. package/services/encode/NvencEncoder.js +5 -0
  56. package/services/encode/OutputOpening.js +755 -0
  57. package/services/encode/OutputTimes.js +579 -0
  58. package/services/encode/Renditions.js +2162 -0
  59. package/services/encode/RunProgress.js +84 -0
  60. package/services/encode/SoftwareEncoder.js +12 -7
  61. package/services/encode/args.js +402 -186
  62. package/services/encode/calibration.js +361 -0
  63. package/services/encode/contention.js +128 -128
  64. package/services/encode/encode-run-state.js +16 -20
  65. package/services/encode/fingerprint.js +322 -0
  66. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  67. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  68. package/services/encode/output/EncodedOutput.js +57 -0
  69. package/services/encode/output/Output.js +131 -0
  70. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  71. package/services/encode/output/OutputSpec.js +390 -0
  72. package/services/{output → encode/output}/Timeline.js +12 -37
  73. package/services/encode/output/index.js +20 -0
  74. package/services/encode/output/ladder.js +32 -0
  75. package/services/{output → encode/output}/playlists.js +3 -0
  76. package/services/encode/output-key-format.js +21 -0
  77. package/services/encode/quality/EncodeCost.js +1083 -0
  78. package/services/encode/quality/HostLoad.js +525 -0
  79. package/services/encode/quality/HostTimings.js +249 -0
  80. package/services/encode/quality/OutputQualityState.js +28 -0
  81. package/services/encode/quality/QualityController.js +935 -0
  82. package/services/encode/quality/QualityOffer.js +416 -0
  83. package/services/encode/quality/drain-threat.js +47 -0
  84. package/services/encode/quality/link-budget.js +254 -0
  85. package/services/encode/quality/output-format.js +266 -0
  86. package/services/encode/quality/serving-output.js +216 -0
  87. package/services/encode/quality/visible-rung.js +57 -0
  88. package/services/encode/run-command.js +23 -20
  89. package/services/encode/run-inputs.js +42 -0
  90. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  91. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  92. package/services/encode/segment-formats/init-compat.js +324 -0
  93. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  94. package/services/encode/throughput.js +97 -0
  95. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  96. package/services/media/KeyframeTables.js +316 -0
  97. package/services/{source → media}/SourceFile.js +7 -55
  98. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  99. package/services/media/SubtitleOrchestrator.js +167 -0
  100. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  101. package/services/{container → media/container}/AviContainer.js +266 -266
  102. package/services/{container → media/container}/Container.js +596 -601
  103. package/services/media/container/ContainerFactory.js +56 -0
  104. package/services/media/container/KeyframeTable.js +272 -0
  105. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  106. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  107. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  108. package/services/media/keyframe-probe.js +157 -0
  109. package/services/media/media-info-probe.js +93 -0
  110. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  111. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  112. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  113. package/services/server/OutputLifecycle.js +418 -0
  114. package/services/server/SegmentServing.js +1146 -0
  115. package/services/server/ViewerRequests.js +522 -0
  116. package/services/server/controllers/PlaybackController.js +36 -0
  117. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  118. package/services/server/http-responses.js +60 -0
  119. package/services/server/transcode-session-files.js +325 -0
  120. package/services/server/wire-outputs.js +983 -0
  121. package/services/storage/Diagnostics.js +202 -0
  122. package/services/storage/MachineBudget.js +235 -0
  123. package/services/{piece-store → storage}/allowance.js +107 -107
  124. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  125. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  126. package/services/{disk → storage}/free.js +33 -0
  127. package/services/storage/machine-memory.js +56 -0
  128. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  129. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  130. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  131. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  132. package/services/storage/wire.js +175 -0
  133. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  134. package/services/{download → torrent/download}/registry.js +2 -2
  135. package/services/torrent/files.js +1 -1
  136. package/services/torrent/piece-store-of.js +29 -0
  137. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  138. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  139. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  140. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  141. package/services/torrent/worker/held-bytes.js +155 -0
  142. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  143. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  144. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  145. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  146. package/services/torrent/worker/resume-warm.js +107 -0
  147. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  148. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  149. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  150. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  151. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  152. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  153. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  154. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  155. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  156. package/services/viewer/Assignments.js +340 -0
  157. package/services/{priority → viewer}/PriorityMap.js +390 -390
  158. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  159. package/services/viewer/Viewer.js +657 -483
  160. package/services/viewer/Viewers.js +368 -257
  161. package/services/viewer/active-output.js +37 -54
  162. package/services/viewer/audio-start.js +4 -3
  163. package/services/viewer/buffer-trend.js +81 -0
  164. package/services/viewer/choices.js +647 -0
  165. package/services/viewer/positions.js +126 -120
  166. package/services/viewer/report-intake.js +46 -45
  167. package/test/asset-paths.test.js +28 -0
  168. package/test/audio-inventory.test.js +3 -3
  169. package/test/audio-track-bitrate.test.js +40 -0
  170. package/test/auto-quality-step.test.js +707 -515
  171. package/test/available-share.test.js +1 -1
  172. package/test/background-fill.test.js +1 -1
  173. package/test/client-log-file.test.js +110 -110
  174. package/test/completed-files.test.js +1 -1
  175. package/test/concurrent-cost.test.js +13 -3
  176. package/test/container-media-info.test.js +3 -3
  177. package/test/core-dumps.test.js +1 -1
  178. package/test/coverage-follows-the-disk.test.js +46 -9
  179. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  180. package/test/cut-times-timeline.test.js +2 -2
  181. package/test/cuts-follow-published-grid.test.js +9 -8
  182. package/test/data-channel-frame.test.js +1 -1
  183. package/test/dead-torrent-handle.test.js +1 -1
  184. package/test/declared-rates.test.js +2 -2
  185. package/test/declared-tracks.test.js +2 -2
  186. package/test/decode-cost-fit.test.js +1 -1
  187. package/test/decode-cost.test.js +34 -25
  188. package/test/delivery-probe.test.js +1 -1
  189. package/test/demand-register.test.js +4 -4
  190. package/test/dht-bootstrap.test.js +1 -1
  191. package/test/drain-threat.test.js +65 -0
  192. package/test/duplicate-add.test.js +1 -1
  193. package/test/encode-admission.test.js +146 -0
  194. package/test/encode-cost.test.js +199 -10
  195. package/test/encode-orchestrator.test.js +39 -1
  196. package/test/encode-plan-viewers.test.js +2 -2
  197. package/test/encode-run.test.js +2 -2
  198. package/test/encode-slope.test.js +1 -1
  199. package/test/encoded-output.test.js +39 -0
  200. package/test/encoder-readings.test.js +1 -1
  201. package/test/every-claimant-is-told-its-share.test.js +152 -0
  202. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  203. package/test/failed-start-breaker.test.js +81 -30
  204. package/test/fastest-wires.test.js +1 -1
  205. package/test/ffmpeg-stream-counts.test.js +1 -1
  206. package/test/file-edges.test.js +3 -3
  207. package/test/fmp4-self-contained.test.js +1 -1
  208. package/test/fmp4-timestamps.test.js +1 -1
  209. package/test/fmp4-track-completeness.test.js +1 -1
  210. package/test/format-keyed-outputs.test.js +78 -0
  211. package/test/health-metrics.test.js +3 -2
  212. package/test/held-request-width.test.js +22 -23
  213. package/test/helpers/encode-run.js +136 -136
  214. package/test/helpers/held-file.js +29 -0
  215. package/test/helpers/manager.js +57 -0
  216. package/test/helpers/output-spec.js +63 -0
  217. package/test/host-load.test.js +1 -1
  218. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  219. package/test/init-compat.test.js +219 -0
  220. package/test/input-lost-quiets-the-plan.test.js +1 -1
  221. package/test/input-unavailable.test.js +1 -1
  222. package/test/keeping-period.test.js +4 -4
  223. package/test/keyframe-index-accuracy.test.js +97 -45
  224. package/test/keyframe-table-budget.test.js +28 -33
  225. package/test/keyframes-belong-to-the-file.test.js +174 -57
  226. package/test/learned-median.test.js +1 -1
  227. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  228. package/test/limit-row-by-area.test.js +60 -0
  229. package/test/link-budget.test.js +111 -0
  230. package/test/link-probe.test.js +56 -56
  231. package/test/local-encoding-data.test.js +273 -0
  232. package/test/look-ahead-coverage.test.js +1 -1
  233. package/test/manager-surface.test.js +294 -0
  234. package/test/matroska-blocks.test.js +0 -0
  235. package/test/matroska-cues-track.test.js +1 -1
  236. package/test/memory-budget.test.js +2 -2
  237. package/test/mp4-composition-times.test.js +0 -0
  238. package/test/mp4-subtitles.test.js +1 -1
  239. package/test/one-authority.test.js +45 -41
  240. package/test/one-budget.test.js +149 -0
  241. package/test/one-output-one-session.test.js +204 -0
  242. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  243. package/test/one-serving-output-per-height.test.js +458 -0
  244. package/test/orchestrator-wired.test.js +46 -45
  245. package/test/output-catalog.test.js +39 -0
  246. package/test/output-shape.test.js +4 -51
  247. package/test/output-spec.test.js +251 -157
  248. package/test/output-speed.test.js +17 -15
  249. package/test/outputs-being-prepared.test.js +40 -0
  250. package/test/packet-witness-ring.test.js +2 -2
  251. package/test/packet-witness.test.js +1 -1
  252. package/test/piece-disk-store.test.js +2 -2
  253. package/test/piece-from-whole-file.test.js +1 -1
  254. package/test/piece-lru.test.js +1 -1
  255. package/test/piece-reader.test.js +2 -2
  256. package/test/piece-store-eviction.test.js +1 -1
  257. package/test/piece-store-never-refuses.test.js +1 -1
  258. package/test/piece-store-reservations.test.js +1 -1
  259. package/test/piece-store-slow-disk.test.js +1 -1
  260. package/test/piece-tail.test.js +1 -1
  261. package/test/plan-host-timings.test.js +1 -1
  262. package/test/plan-is-steady.test.js +225 -225
  263. package/test/priority-map-download.test.js +4 -4
  264. package/test/priority-map-emptied.test.js +204 -205
  265. package/test/priority-map-per-output.test.js +215 -209
  266. package/test/priority-map.test.js +1 -1
  267. package/test/probe-wedge-certainty.test.js +1 -1
  268. package/test/produced-copy-choice.test.js +15 -60
  269. package/test/quality-offer.test.js +162 -0
  270. package/test/quality-variants.test.js +1229 -1167
  271. package/test/rate-control-in-the-output.test.js +180 -0
  272. package/test/read-survives-withdrawal.test.js +2 -2
  273. package/test/read-window.test.js +10 -3
  274. package/test/resume-warm.test.js +1 -1
  275. package/test/run-inputs.test.js +80 -0
  276. package/test/run-position-follows-published-grid.test.js +13 -11
  277. package/test/run-progress.test.js +65 -0
  278. package/test/same-height-switch.test.js +664 -0
  279. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  280. package/test/seek-landing.test.js +13 -13
  281. package/test/seek-target-not-superseded.test.js +28 -29
  282. package/test/segment-closed-on-both-branches.test.js +96 -0
  283. package/test/segment-cut-times.test.js +1 -1
  284. package/test/segment-hold-supersede.test.js +28 -35
  285. package/test/segment-judged-by-its-own-header.test.js +221 -0
  286. package/test/segment-serve-wiring.test.js +130 -39
  287. package/test/segment-store-eviction.test.js +22 -7
  288. package/test/segment-store.test.js +98 -14
  289. package/test/segment-true-start.test.js +1 -1
  290. package/test/segments-are-shared.test.js +17 -16
  291. package/test/send-chunk-bytes.test.js +1 -1
  292. package/test/serving-existing-output.test.js +102 -0
  293. package/test/serving-output.test.js +102 -0
  294. package/test/shared-piece-store.test.js +1 -1
  295. package/test/source-file.test.js +1 -1
  296. package/test/stale-request-after-seek.test.js +17 -21
  297. package/test/startup-readings.test.js +10 -5
  298. package/test/subtitle-cue-framing.test.js +5 -5
  299. package/test/subtitle-cue-source.test.js +36 -38
  300. package/test/subtitle-cue-walk.test.js +11 -10
  301. package/test/subtitle-defaults.test.js +1 -1
  302. package/test/subtitle-language.test.js +4 -4
  303. package/test/subtitle-track-numbering.test.js +5 -4
  304. package/test/supply-margin.test.js +1 -1
  305. package/test/swarm-follows-readers.test.js +3 -3
  306. package/test/swarm-reach.test.js +1 -1
  307. package/test/swarm-selection.test.js +3 -3
  308. package/test/tail-duplication.test.js +1 -1
  309. package/test/the-channel-has-both-ends.test.js +91 -0
  310. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  311. package/test/thread-pool.test.js +3 -3
  312. package/test/timeline.test.js +1 -12
  313. package/test/torrent-cost.test.js +1 -1
  314. package/test/tracks-begin-together.test.js +38 -24
  315. package/test/tunnel-renewal.test.js +1 -1
  316. package/test/two-viewers-one-picture.test.js +120 -87
  317. package/test/upload-hurry.test.js +3 -3
  318. package/test/usrsctp-state.test.js +1 -1
  319. package/test/video-facts.test.js +1 -1
  320. package/test/viewer-assignments.test.js +143 -0
  321. package/test/viewer-outputs.test.js +60 -52
  322. package/test/viewer-presence.test.js +22 -25
  323. package/test/viewer-requests-admission.test.js +89 -0
  324. package/test/viewer-states.test.js +189 -202
  325. package/test/viewer-subtitle-subscription.test.js +1 -1
  326. package/test/viewer.test.js +139 -136
  327. package/test/viewing-generations.test.js +390 -0
  328. package/test/visible-rung.test.js +37 -0
  329. package/test/wait-ledger.test.js +1 -1
  330. package/test/webrtc-shim.test.js +3 -3
  331. package/test/wedge-certainty.test.js +2 -2
  332. package/test/withdraw-piece-claim.test.js +2 -2
  333. package/test/worker-channel.test.js +1 -31
  334. package/utils/client-log-file.js +151 -151
  335. package/scripts/read-container-index.mjs +0 -108
  336. package/services/container/ContainerFactory.js +0 -122
  337. package/services/controllers/PlaybackController.js +0 -33
  338. package/services/disk/DiskSpace.js +0 -150
  339. package/services/disk/wire.js +0 -61
  340. package/services/hls-session-manager.js +0 -9608
  341. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  342. package/services/output/Output.js +0 -94
  343. package/services/output/OutputSpec.js +0 -202
  344. package/services/output/index.js +0 -1
  345. package/services/output/ladder.js +0 -26
  346. package/services/produced-index.js +0 -222
  347. package/services/quality/EncodeCost.js +0 -555
  348. package/services/torrent-worker/container-tracks.js +0 -414
  349. package/services/viewer/link-readings.js +0 -56
  350. package/test/produced-index.test.js +0 -142
  351. /package/{services → bin}/thread-pool.js +0 -0
  352. /package/services/{available-share.js → encode/available-share.js} +0 -0
  353. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  354. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  355. /package/services/{host-load.js → encode/host-load.js} +0 -0
  356. /package/services/{output → encode/output}/cut-grid.js +0 -0
  357. /package/services/{output → encode/output}/rates.js +0 -0
  358. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  359. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  360. /package/services/{container → media/container}/ebml-reader.js +0 -0
  361. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  362. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  365. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  366. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  367. /package/services/{tracks → media/tracks}/index.js +0 -0
  368. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  369. /package/services/{disk → storage}/keep.js +0 -0
  370. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  371. /package/services/{disk → storage}/returns.js +0 -0
  372. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  373. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  374. /package/services/{demand → torrent/demand}/Window.js +0 -0
  375. /package/services/{demand → torrent/demand}/index.js +0 -0
  376. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  377. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  378. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  379. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  380. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  381. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  382. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  383. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  384. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  385. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -488,45 +488,41 @@ export function declaredContainment() {
488
488
  }
489
489
 
490
490
  /**
491
- * Every run this session has going, earliest first.
491
+ * Every run in one output's registered collection, earliest first.
492
492
  *
493
- * A session holds as many as the machine affords, because one output can be
494
- * watched from more than one place: a viewer who jumps back gets a run of their
495
- * own rather than dragging the picture away from a viewer watching ahead.
496
- *
497
- * @param {{ runs?: Set<object> }} session
493
+ * @param {object[] | Set<object>} runs
498
494
  * @returns {object[]}
499
495
  */
500
- export function runsOf(session) {
501
- const runs = session?.runs instanceof Set ? [...session.runs] : [];
502
- return runs.sort((left, right) => left.from - right.from);
496
+ export function runsOf(runs) {
497
+ const listed = Array.isArray(runs) ? runs : runs instanceof Set ? [...runs] : [];
498
+ return [...listed].sort((left, right) => left.from - right.from);
503
499
  }
504
500
 
505
501
  /**
506
- * The runs of this session that still have a process.
502
+ * The runs in one output's registered collection that still have a process.
507
503
  *
508
- * @param {{ runs?: Set<object> }} session
504
+ * @param {object[] | Set<object>} runs
509
505
  * @returns {object[]}
510
506
  */
511
- export function liveRunsOf(session) {
512
- return runsOf(session).filter((run) => run.isAlive);
507
+ export function liveRunsOf(runs) {
508
+ return runsOf(runs).filter((run) => run.isAlive);
513
509
  }
514
510
 
515
511
  /**
516
- * What this session's encoding is doing, as one state.
512
+ * What one output's registered encoders are doing, as one state.
517
513
  *
518
- * The most advanced state among its live runs: a session with anything
519
- * producing IS producing, whatever else it has going. A session that has never
514
+ * The most advanced state among its live runs: an output with anything
515
+ * producing IS producing, whatever else it has going. An output that has never
520
516
  * started a run, or whose runs have all ended, is at the table's own initial
521
517
  * state — which is what "nothing is encoding" means.
522
518
  *
523
- * @param {{ runs?: Set<object> }} session
519
+ * @param {object[] | Set<object>} runs
524
520
  * @returns {string}
525
521
  */
526
- export function runStateOf(session) {
522
+ export function runStateOf(runs) {
527
523
  let answer = INITIAL_RUN_STATE;
528
524
  let rank = -1;
529
- for (const run of runsOf(session)) {
525
+ for (const run of runsOf(runs)) {
530
526
  const at = RUN_STATE_ORDER.indexOf(run.state);
531
527
  if (at > rank) {
532
528
  rank = at;
@@ -540,7 +536,7 @@ export function runStateOf(session) {
540
536
  * How advanced a run state is, for reducing several runs to one answer.
541
537
  *
542
538
  * Producing outranks starting, and both outrank a run that has ended: what a
543
- * caller asking "what is this session's encoding doing" wants to know is
539
+ * caller asking "what is this output's encoding doing" wants to know is
544
540
  * whether anything is being made, not what the quietest of them is up to.
545
541
  */
546
542
  const RUN_STATE_ORDER = [
@@ -0,0 +1,322 @@
1
+ /**
2
+ * @file Which configuration a measurement of encoding was taken on (roadmap
3
+ * item 97, step 14).
4
+ *
5
+ * A figure about encoding is a figure about ONE configuration: this ffmpeg
6
+ * build, this libx264, this processor with this many threads, or this device
7
+ * with this driver. The same film encoded on another build or another driver
8
+ * is not the same measurement, and a figure kept across such a change would be
9
+ * applied to something it was never taken on.
10
+ *
11
+ * So every figure this proxy keeps between runs is filed under the key of the
12
+ * configuration it describes, and a figure whose key no longer matches is not
13
+ * used — not converted, not scaled, simply not matched.
14
+ *
15
+ * The key is per ENCODER, not per host. A host whose graphics driver changes
16
+ * keeps what it knows about its software encoder: that encoder has not
17
+ * changed, so nothing it was measured doing has changed either.
18
+ *
19
+ * WHAT IS READ, and where. None of it is chosen:
20
+ *
21
+ * 1. the ffmpeg build — the first line of `ffmpeg -version`;
22
+ * 2. the libx264 build — the version string x264 writes into every stream it
23
+ * produces (`x264 - core N rNNNN hash`), read from one encoded frame;
24
+ * 3. the processor model and the thread count the encoder is given
25
+ * (`CPU_THREADS`, which is the core count);
26
+ * 4. for a device-backed encoder, the device model and its driver: NVIDIA's
27
+ * own tool where it exists, and otherwise what the kernel publishes under
28
+ * `/sys` for the render node or the video device.
29
+ *
30
+ * A part that cannot be read is recorded as `unknown`. That is still a key:
31
+ * two runs that both could not read it are told apart by everything else, and
32
+ * a run that later can read it has a different key and starts afresh.
33
+ */
34
+
35
+ import { spawn } from "node:child_process";
36
+ import { readdirSync, readFileSync, readlinkSync } from "node:fs";
37
+ import os from "node:os";
38
+ import path from "node:path";
39
+ import { CPU_THREADS } from "./args.js";
40
+
41
+ /** What an unreadable part of a fingerprint is written as. */
42
+ export const UNKNOWN = "unknown";
43
+
44
+ /**
45
+ * The ffmpeg build, from the first line `ffmpeg -version` prints.
46
+ *
47
+ * @param {string} text - Standard output of `ffmpeg -version`.
48
+ * @returns {string}
49
+ */
50
+ export function parseFfmpegVersion(text) {
51
+ const match = /^ffmpeg version (\S+)/m.exec(String(text ?? ""));
52
+ return match ? match[1] : UNKNOWN;
53
+ }
54
+
55
+ /**
56
+ * The libx264 build, from the text x264 writes into the stream it produces.
57
+ *
58
+ * @param {Buffer | string} bytes - An encoded H.264 stream.
59
+ * @returns {string}
60
+ */
61
+ export function parseX264Version(bytes) {
62
+ const text = Buffer.isBuffer(bytes) ? bytes.toString("latin1") : String(bytes ?? "");
63
+ const match = /x264 - core (\d+)(?: (r\d+ [0-9a-f]+))?/.exec(text);
64
+ if (!match) {
65
+ return UNKNOWN;
66
+ }
67
+ return match[2] ? `core ${match[1]} ${match[2]}` : `core ${match[1]}`;
68
+ }
69
+
70
+ /**
71
+ * NVIDIA's own answer about the device and its driver.
72
+ *
73
+ * @param {string} text - `nvidia-smi --query-gpu=name,driver_version --format=csv,noheader`.
74
+ * @returns {{ model: string, driver: string }}
75
+ */
76
+ export function parseNvidiaSmi(text) {
77
+ const line = String(text ?? "").split(/\r?\n/).find((row) => row.includes(","));
78
+ if (!line) {
79
+ return { model: UNKNOWN, driver: UNKNOWN };
80
+ }
81
+ const [model, driver] = line.split(",").map((value) => value.trim());
82
+ return { model: model || UNKNOWN, driver: driver || UNKNOWN };
83
+ }
84
+
85
+ /** ffmpeg's name for each encoder kind, as the kinds state it (`encode/*Encoder.js`). */
86
+ const KIND_BY_NAME = Object.freeze({
87
+ libx264: "software",
88
+ h264_nvenc: "nvenc",
89
+ h264_qsv: "qsv",
90
+ h264_vaapi: "vaapi",
91
+ h264_v4l2m2m: "v4l2m2m"
92
+ });
93
+
94
+ /**
95
+ * Which kind an encoder of this name is, as an output's key names it.
96
+ *
97
+ * @param {string} name
98
+ * @returns {string}
99
+ */
100
+ export function kindOfEncoderName(name) {
101
+ return KIND_BY_NAME[name] ?? UNKNOWN;
102
+ }
103
+
104
+ /**
105
+ * The part of a fingerprint that decides whether a figure about THIS encoder
106
+ * still applies. Stable text, so it can be compared and stored.
107
+ *
108
+ * @param {HostFingerprint} fingerprint
109
+ * @param {{ name: string, kind: string }} encoder
110
+ * @returns {string}
111
+ */
112
+ export function configurationKeyOf(fingerprint, encoder) {
113
+ const common = [`ffmpeg=${fingerprint?.ffmpeg ?? UNKNOWN}`, `encoder=${encoder?.name ?? UNKNOWN}`];
114
+ if (encoder?.kind === "software") {
115
+ return [
116
+ ...common,
117
+ `x264=${fingerprint?.x264 ?? UNKNOWN}`,
118
+ `cpu=${fingerprint?.cpu ?? UNKNOWN}`,
119
+ `threads=${fingerprint?.threads ?? UNKNOWN}`
120
+ ].join(";");
121
+ }
122
+ const device = fingerprint?.devices?.[encoder?.kind] ?? null;
123
+ return [
124
+ ...common,
125
+ `device=${device?.model ?? UNKNOWN}`,
126
+ `driver=${device?.driver ?? UNKNOWN}`
127
+ ].join(";");
128
+ }
129
+
130
+ /**
131
+ * @typedef {object} DeviceFingerprint
132
+ * @property {string} model
133
+ * @property {string} driver
134
+ *
135
+ * @typedef {object} HostFingerprint
136
+ * @property {string} ffmpeg
137
+ * @property {string} x264
138
+ * @property {string} cpu
139
+ * @property {number} threads
140
+ * @property {Record<string, DeviceFingerprint>} devices - By encoder kind.
141
+ */
142
+
143
+ /**
144
+ * Run a program and hand back what it wrote to standard output.
145
+ *
146
+ * @param {string} command
147
+ * @param {string[]} args
148
+ * @param {number} timeoutMs
149
+ * @returns {Promise<Buffer | null>} Null when it could not be run or failed.
150
+ */
151
+ function capture(command, args, timeoutMs) {
152
+ return new Promise((resolve) => {
153
+ /** @type {Buffer[]} */
154
+ const chunks = [];
155
+ let settled = false;
156
+ let child;
157
+ const finish = (value) => {
158
+ if (settled) {
159
+ return;
160
+ }
161
+ settled = true;
162
+ clearTimeout(timer);
163
+ resolve(value);
164
+ };
165
+ const timer = setTimeout(() => {
166
+ try {
167
+ child?.kill("SIGKILL");
168
+ } catch {
169
+ // already gone
170
+ }
171
+ finish(null);
172
+ }, timeoutMs);
173
+ try {
174
+ child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
175
+ } catch {
176
+ finish(null);
177
+ return;
178
+ }
179
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
180
+ child.on("error", () => finish(null));
181
+ child.on("close", (code) => finish(code === 0 ? Buffer.concat(chunks) : null));
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Read a small text file, or `unknown`.
187
+ *
188
+ * @param {string} filePath
189
+ * @returns {string}
190
+ */
191
+ function readText(filePath) {
192
+ try {
193
+ const text = readFileSync(filePath, "utf8").trim();
194
+ return text.length > 0 ? text : UNKNOWN;
195
+ } catch {
196
+ return UNKNOWN;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The name of the kernel driver bound to a device under `/sys`.
202
+ *
203
+ * @param {string} sysDevice - e.g. `/sys/class/drm/renderD128/device`.
204
+ * @returns {string}
205
+ */
206
+ function driverOf(sysDevice) {
207
+ try {
208
+ return path.basename(readlinkSync(path.join(sysDevice, "driver")));
209
+ } catch {
210
+ return UNKNOWN;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * The version of a kernel driver. A driver built into the kernel publishes no
216
+ * version of its own, and then the kernel release IS its version.
217
+ *
218
+ * @param {string} driver
219
+ * @returns {string}
220
+ */
221
+ function driverVersionOf(driver) {
222
+ if (driver === UNKNOWN) {
223
+ return UNKNOWN;
224
+ }
225
+ const own = readText(`/sys/module/${driver}/version`);
226
+ return own !== UNKNOWN ? `${driver} ${own}` : `${driver} (kernel ${os.release()})`;
227
+ }
228
+
229
+ /**
230
+ * A render node's device: the PCI vendor and device ids, and its driver.
231
+ *
232
+ * @param {string | null} renderNode - e.g. `/dev/dri/renderD128`.
233
+ * @returns {DeviceFingerprint}
234
+ */
235
+ function renderNodeDevice(renderNode) {
236
+ if (!renderNode) {
237
+ return { model: UNKNOWN, driver: UNKNOWN };
238
+ }
239
+ const sysDevice = `/sys/class/drm/${path.basename(renderNode)}/device`;
240
+ const vendor = readText(path.join(sysDevice, "vendor"));
241
+ const device = readText(path.join(sysDevice, "device"));
242
+ const model = vendor === UNKNOWN && device === UNKNOWN ? UNKNOWN : `${vendor}:${device}`;
243
+ return { model, driver: driverVersionOf(driverOf(sysDevice)) };
244
+ }
245
+
246
+ /**
247
+ * The memory-to-memory video devices, by the names the kernel gives them.
248
+ *
249
+ * @returns {DeviceFingerprint}
250
+ */
251
+ function videoDevices() {
252
+ let names = [];
253
+ try {
254
+ names = readdirSync("/sys/class/video4linux")
255
+ .sort()
256
+ .map((entry) => readText(`/sys/class/video4linux/${entry}/name`))
257
+ .filter((name) => name !== UNKNOWN);
258
+ } catch {
259
+ names = [];
260
+ }
261
+ let driver = UNKNOWN;
262
+ try {
263
+ const first = readdirSync("/sys/class/video4linux").sort()[0];
264
+ driver = first ? driverVersionOf(driverOf(`/sys/class/video4linux/${first}/device`)) : UNKNOWN;
265
+ } catch {
266
+ driver = UNKNOWN;
267
+ }
268
+ return { model: names.length > 0 ? [...new Set(names)].join("+") : UNKNOWN, driver };
269
+ }
270
+
271
+ /**
272
+ * NVIDIA's device and driver, from its own tool and otherwise from the file
273
+ * its kernel module publishes.
274
+ *
275
+ * @returns {Promise<DeviceFingerprint>}
276
+ */
277
+ async function nvidiaDevice() {
278
+ const smi = await capture("nvidia-smi", ["--query-gpu=name,driver_version", "--format=csv,noheader"], 5000);
279
+ if (smi) {
280
+ return parseNvidiaSmi(smi.toString("utf8"));
281
+ }
282
+ const version = readText("/proc/driver/nvidia/version");
283
+ const driver = /Kernel Module\s+(\S+)/.exec(version)?.[1] ?? UNKNOWN;
284
+ return { model: UNKNOWN, driver };
285
+ }
286
+
287
+ /**
288
+ * Read this host's fingerprint. Always resolves: an unreadable part is
289
+ * `unknown`, never a refusal to start.
290
+ *
291
+ * @param {{ ffmpegBin: string, encoder?: { kind: string, device: string | null } | null }} params
292
+ * @returns {Promise<HostFingerprint>}
293
+ */
294
+ export async function readHostFingerprint({ ffmpegBin, encoder = null }) {
295
+ const versionText = await capture(ffmpegBin, ["-hide_banner", "-version"], 5000);
296
+ // `-hide_banner` does not hide the version line itself: it is the answer.
297
+ const ffmpeg = parseFfmpegVersion(versionText ? versionText.toString("utf8") : "");
298
+ // One frame through libx264, written to standard output: the stream carries
299
+ // x264's own statement of its build.
300
+ const oneFrame = await capture(ffmpegBin, [
301
+ "-hide_banner", "-loglevel", "error",
302
+ "-f", "lavfi", "-i", "testsrc2=s=64x64:r=1:d=1",
303
+ "-frames:v", "1", "-c:v", "libx264", "-f", "h264", "-"
304
+ ], 10000);
305
+ const x264 = oneFrame ? parseX264Version(oneFrame) : UNKNOWN;
306
+ /** @type {Record<string, DeviceFingerprint>} */
307
+ const devices = {};
308
+ if (encoder?.kind === "nvenc") {
309
+ devices.nvenc = await nvidiaDevice();
310
+ } else if (encoder?.kind === "vaapi" || encoder?.kind === "qsv") {
311
+ devices[encoder.kind] = renderNodeDevice(encoder.device);
312
+ } else if (encoder?.kind === "v4l2m2m") {
313
+ devices.v4l2m2m = videoDevices();
314
+ }
315
+ return {
316
+ ffmpeg,
317
+ x264,
318
+ cpu: os.cpus()[0]?.model?.trim() || UNKNOWN,
319
+ threads: CPU_THREADS,
320
+ devices
321
+ };
322
+ }
@@ -26,16 +26,18 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
28
  import { fitDecodeCost } from "./decode-cost-fit.js";
29
- import { penaltiesFrom } from "./encode/contention.js";
29
+ import { penaltiesFrom } from "./contention.js";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import {
32
32
  parseFfmpegBitrateKbps,
33
33
  parseFfmpegDurationSeconds,
34
34
  parseFfmpegVideoDimensions,
35
35
  parseFfmpegVideoFps
36
- } from "./ffmpeg-banner.js";
36
+ } from "../media/ffmpeg-banner.js";
37
37
 
38
- import { keyFrameArgs, TRANSCODE_FPS } from "./encode/args.js";
38
+ import { keyFrameArgs, TRANSCODE_FPS } from "./args.js";
39
+ import { throughputAt } from "./throughput.js";
40
+ import { LADDER_HEIGHTS } from "./output/ladder.js";
39
41
  // The five kinds, one class each. Detection and benchmarking stay in this file;
40
42
  // how a kind is driven belongs to the kind.
41
43
  import {
@@ -44,20 +46,37 @@ import {
44
46
  SoftwareEncoder,
45
47
  V4l2m2mEncoder,
46
48
  VaapiEncoder
47
- } from "./encode/index.js";
49
+ } from "./index.js";
48
50
  // Re-exported so every caller goes on importing these figures from here:
49
51
  // the same calculation, moved to sit beside the encoder kinds built from it.
50
52
  export {
51
53
  chooseOutputFps,
52
54
  maxrateKbpsFor,
53
- nominalKbpsForHeight,
54
- nominalKbpsForMaxrate,
55
+ limitRowFor,
56
+ nominalKbpsFor,
55
57
  TRANSCODE_FPS
56
- } from "./encode/args.js";
58
+ } from "./args.js";
57
59
 
58
60
  const BENCHMARK_REF_W = 640;
59
61
  const BENCHMARK_REF_H = 360;
60
62
  const BENCHMARK_DURATION_SEC = 3;
63
+ /**
64
+ * How many raw frames a throughput reading at this size is taken over.
65
+ *
66
+ * The reference size keeps the three seconds it has always had. A larger frame
67
+ * gets as many frames as the same number of bytes buys, but never fewer than
68
+ * one second of film: the frames are looped, and a loop shorter than the
69
+ * encoder's reach — its reference frames and its B-frames — would let it find
70
+ * an identical frame to predict from and read far faster than any film.
71
+ *
72
+ * @param {{ width: number, height: number }} frame
73
+ * @returns {number}
74
+ */
75
+ export function rawFrameCountFor(frame) {
76
+ const referenceBytes = BENCHMARK_REF_W * BENCHMARK_REF_H * 1.5 * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
77
+ const frameBytes = frame.width * frame.height * 1.5;
78
+ return Math.max(TRANSCODE_FPS, Math.floor(referenceBytes / frameBytes));
79
+ }
61
80
  /**
62
81
  * The narrowest window a slope may be taken over. Measured 2026-08-15: at a
63
82
  * fifth of a second the readings were noisy enough to put `faster` and
@@ -143,7 +162,7 @@ function v4l2m2mDescriptor() {
143
162
  * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
144
163
  * @property {string|null} device
145
164
  * @property {string[]} inputArgs
146
- * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null, nominalKbps?: number | null }) => string[]} buildVideoArgs
165
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null, rateControl?: { maxrateKbps: number, bufsizeKbps: number, level: string | null } | null }) => string[]} buildVideoArgs
147
166
  */
148
167
 
149
168
  /**
@@ -154,7 +173,7 @@ function v4l2m2mDescriptor() {
154
173
  * @param {number} [timeoutMs=12000]
155
174
  * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
156
175
  */
157
- function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
176
+ export function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
158
177
  return new Promise((resolve) => {
159
178
  let stdout = "";
160
179
  let stderr = "";
@@ -275,7 +294,7 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
275
294
  "-hls_time", String(segmentDurationSec),
276
295
  "-hls_list_size", "0",
277
296
  "-hls_flags", "independent_segments",
278
- // fMP4 (CMAF) — matches the runtime pipeline (hls-session-manager).
297
+ // fMP4 (CMAF) — matches the runtime pipeline (`encode/run-command.js`).
279
298
  "-hls_segment_type", "fmp4",
280
299
  "-hls_fmp4_init_filename", "init.mp4",
281
300
  "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
@@ -294,7 +313,7 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
294
313
  * @param {string} outDir
295
314
  * @returns {Promise<boolean>}
296
315
  */
297
- async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
316
+ export async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
298
317
  let files;
299
318
  try {
300
319
  files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
@@ -464,8 +483,8 @@ const CALIBRATION_SETS = {
464
483
  "cal-hevc10-480-lo.mp4"
465
484
  ]
466
485
  };
467
- const CALIBRATION_CLIPS = CALIBRATION_SETS.h264;
468
- const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
486
+ export const CALIBRATION_REFERENCE_CLIP = "cal-h264-1080-hi.mp4";
487
+ export const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "calibration");
469
488
  // How wide the measured window must be before the slope is trusted, and how
470
489
  // long to wait for it at most.
471
490
  //
@@ -1226,13 +1245,29 @@ export function canSustainOutput({
1226
1245
  outputPixelsPerSec,
1227
1246
  observedDecodeCostSec = null,
1228
1247
  concurrentCostSec = 0,
1229
- requiredSpeed = null
1248
+ requiredSpeed = null,
1249
+ // The frame this output is encoded at. Given, the encoder's figure is the
1250
+ // one measured AT that size (`throughput.js`), and a size nothing was
1251
+ // measured at is not sustainable: this host has not shown it can do it.
1252
+ frame = null
1230
1253
  }) {
1231
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1232
- // Nothing measured on this host: the budget cannot refuse what it cannot
1233
- // price, and refusing everything would leave a viewer with no rung at all.
1254
+ if (!Array.isArray(benchmark)) {
1255
+ // Not calibrated at all: a wiring made without the startup calibration,
1256
+ // which the proxy itself never is when it re-encodes (`server.js`). There is
1257
+ // nothing to judge by, and nothing is judged.
1234
1258
  return { speed: null, sustainable: true };
1235
1259
  }
1260
+ if (benchmark.length === 0) {
1261
+ // CALIBRATED, and no mode of this encoder qualified (roadmap item 97, step
1262
+ // 14). Unknown capacity is not capacity: nothing has shown this machine can
1263
+ // hold a re-encode at all. The copied picture needs no encoder and is not
1264
+ // asked here.
1265
+ return { speed: null, sustainable: false };
1266
+ }
1267
+ const encodePixelsPerSec = cheapestPresetPixelsPerSec(benchmark, frame);
1268
+ if (encodePixelsPerSec === null) {
1269
+ return { speed: null, sustainable: false };
1270
+ }
1236
1271
  const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1237
1272
  ? observedDecodeCostSec
1238
1273
  : null;
@@ -1244,7 +1279,7 @@ export function canSustainOutput({
1244
1279
  }
1245
1280
  const alone = predictedRealtimeSpeed({
1246
1281
  decodeModel,
1247
- encodePixelsPerSec: cheapestPresetPixelsPerSec(benchmark),
1282
+ encodePixelsPerSec,
1248
1283
  outputPixelsPerSec,
1249
1284
  source,
1250
1285
  observedDecodeCostSec: observed
@@ -1278,7 +1313,7 @@ export function canSustainOutput({
1278
1313
  * swarm, so it survives its own supply only if what it gains between
1279
1314
  * interruptions covers what one interruption costs. That is measured per file
1280
1315
  * and per swarm by the reader — `1 + worst wait / median interval`, in
1281
- * `supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1316
+ * `torrent/supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1282
1317
  * against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
1283
1318
  * swarm no encoder could have kept up with.
1284
1319
  *
@@ -1430,7 +1465,7 @@ export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC)
1430
1465
  return slope <= ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED ? slope : null;
1431
1466
  }
1432
1467
 
1433
- function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath) {
1468
+ export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
1434
1469
  return new Promise((resolve) => {
1435
1470
  const args = [
1436
1471
  "-hide_banner", "-loglevel", "error", "-nostats",
@@ -1440,7 +1475,7 @@ function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath) {
1440
1475
  ...(typeof encoder?.benchmarkInputArgs === "function" ? encoder.benchmarkInputArgs() : []),
1441
1476
  "-stream_loop", "-1",
1442
1477
  "-f", "rawvideo", "-pix_fmt", "yuv420p",
1443
- "-s", `${BENCHMARK_REF_W}x${BENCHMARK_REF_H}`, "-r", String(TRANSCODE_FPS),
1478
+ "-s", `${frame.width}x${frame.height}`, "-r", String(TRANSCODE_FPS),
1444
1479
  "-i", rawFramesPath,
1445
1480
  // THE ENCODER SAYS HOW TO MEASURE ITSELF. It was libx264 written here, so
1446
1481
  // a host with NVENC, QSV, VAAPI or V4L2M2M measured its encoder not at all
@@ -1523,7 +1558,7 @@ function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath) {
1523
1558
  * @param {{ info: (m: string) => void, warn: (m: string) => void }} log
1524
1559
  * @returns {Promise<string | null>} Path to the raw frames, or null.
1525
1560
  */
1526
- async function decodeToRawFrames(ffmpegBin, log) {
1561
+ export async function decodeToRawFrames(ffmpegBin, log, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
1527
1562
  // A benchmark may leave a host unmeasured; it may never stop it from
1528
1563
  // starting. Before this the temp directory was made outside any guard, so a
1529
1564
  // read-only or missing TMPDIR rejected the promise that starts the proxy.
@@ -1541,13 +1576,13 @@ async function decodeToRawFrames(ffmpegBin, log) {
1541
1576
  const args = [
1542
1577
  "-hide_banner", "-loglevel", "error",
1543
1578
  "-stream_loop", "-1",
1544
- "-i", path.join(CALIBRATION_DIR, CALIBRATION_CLIPS[0]),
1545
- "-t", String(BENCHMARK_DURATION_SEC),
1546
- "-vf", `scale=${BENCHMARK_REF_W}:${BENCHMARK_REF_H},fps=${TRANSCODE_FPS}`,
1579
+ "-i", path.join(CALIBRATION_DIR, CALIBRATION_REFERENCE_CLIP),
1580
+ "-frames:v", String(rawFrameCountFor(frame)),
1581
+ "-vf", `scale=${frame.width}:${frame.height},fps=${TRANSCODE_FPS}`,
1547
1582
  "-an", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-y", rawPath
1548
1583
  ];
1549
1584
  const { code } = await runFfmpeg(ffmpegBin, args, 30000);
1550
- const expectedBytes = BENCHMARK_REF_W * BENCHMARK_REF_H * 1.5 * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
1585
+ const expectedBytes = frame.width * frame.height * 1.5 * rawFrameCountFor(frame);
1551
1586
  let written = 0;
1552
1587
  try {
1553
1588
  written = statSync(rawPath).size;
@@ -1594,11 +1629,12 @@ async function decodeToRawFrames(ffmpegBin, log) {
1594
1629
  * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark
1595
1630
  * @returns {number}
1596
1631
  */
1597
- function cheapestPresetPixelsPerSec(benchmark) {
1598
- return benchmark[benchmark.length - 1]?.pixelsPerSec ?? 0;
1632
+ function cheapestPresetPixelsPerSec(benchmark, frame = null) {
1633
+ const cheapest = benchmark[benchmark.length - 1];
1634
+ return cheapest ? throughputAt(cheapest, frame) : null;
1599
1635
  }
1600
1636
 
1601
- export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1637
+ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}, frame = null) {
1602
1638
  if (!Array.isArray(benchmark) || benchmark.length === 0) {
1603
1639
  return "ultrafast";
1604
1640
  }
@@ -1612,9 +1648,15 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1612
1648
  // always ascend with the list: on a busy machine on 2026-08-15 `faster` read
1613
1649
  // below `fast` twice.
1614
1650
  for (const entry of benchmark) {
1651
+ // At the size this output is encoded at: a preset not measured there is
1652
+ // not a mode this host has shown it can hold there.
1653
+ const encodePixelsPerSec = throughputAt(entry, frame);
1654
+ if (encodePixelsPerSec === null) {
1655
+ continue;
1656
+ }
1615
1657
  const speed = predictedRealtimeSpeed({
1616
1658
  decodeModel: cost.decodeModel ?? null,
1617
- encodePixelsPerSec: entry.pixelsPerSec,
1659
+ encodePixelsPerSec,
1618
1660
  outputPixelsPerSec: pixelsPerSecNeeded,
1619
1661
  source: cost.source ?? null,
1620
1662
  observedDecodeCostSec: observed
@@ -1643,18 +1685,12 @@ function isDecodePriced(cost) {
1643
1685
  return Boolean(cost?.decodeModel) && Boolean(cost?.source);
1644
1686
  }
1645
1687
 
1646
- // Resolution-ladder heights (output height rungs), high→low. The ladder is
1647
- // derived per-stream from the ceiling (the client-requested, source-capped
1648
- // output box): only rungs at or below the ceiling height are used, so the
1649
- // budget never upscales past what the client asked for. Standard heights keep
1650
- // the downscaled output at familiar resolutions.
1651
- const RESOLUTION_LADDER_HEIGHTS = [2160, 1440, 1080, 720, 540, 480, 360, 240];
1652
-
1653
1688
  /**
1654
1689
  * Build the resolution ladder for a ceiling box. Returns candidate output
1655
1690
  * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
1656
- * each even-sized. The ceiling itself is always the top rung; ladder heights
1657
- * at or above it are skipped (never upscale). Deduped by height.
1691
+ * each even-sized. The ceiling itself is always the top rung; the ladder's
1692
+ * standard heights (`LADDER_HEIGHTS`, the same list the quality menu offers) at
1693
+ * or above it are skipped (never upscale). Deduped by height.
1658
1694
  *
1659
1695
  * @param {number} ceilingWidth
1660
1696
  * @param {number} ceilingHeight
@@ -1672,7 +1708,7 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1672
1708
  };
1673
1709
  /** @type {Array<{ width: number, height: number }>} */
1674
1710
  const rungs = [{ width: cw, height: ch }];
1675
- for (const h of RESOLUTION_LADDER_HEIGHTS) {
1711
+ for (const h of LADDER_HEIGHTS) {
1676
1712
  if (h >= ch) {
1677
1713
  continue; // at/above the ceiling — the ceiling rung already covers it
1678
1714
  }
@@ -1714,10 +1750,14 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
1714
1750
  if (ladder.length === 0) {
1715
1751
  return null;
1716
1752
  }
1717
- const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
1718
1753
  const bar = barFor(cost);
1719
1754
  let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
1720
1755
  for (let i = 0; i < ladder.length; i += 1) {
1756
+ // The cheapest preset's throughput AT THIS RUNG's size.
1757
+ const fastest = cheapestPresetPixelsPerSec(benchmark, ladder[i]);
1758
+ if (fastest === null) {
1759
+ continue;
1760
+ }
1721
1761
  const speed = predictedRealtimeSpeed({
1722
1762
  decodeModel: cost.decodeModel ?? null,
1723
1763
  encodePixelsPerSec: fastest,
@@ -1730,7 +1770,7 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
1730
1770
  }
1731
1771
  }
1732
1772
  const chosen = ladder[chosenIndex];
1733
- const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost);
1773
+ const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost, chosen);
1734
1774
  return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1735
1775
  }
1736
1776