@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,1894 +1,1894 @@
1
- /**
2
- * @file Hardware-accelerated H.264 encoder auto-detection.
3
- *
4
- * Probes the ffmpeg build and the host for a usable hardware H.264 encoder
5
- * (NVENC / QSV / VAAPI / V4L2 M2M), verifying each candidate with a real
6
- * test-encode before selecting it. Falls back to software libx264 when no
7
- * hardware encoder is present or working.
8
- *
9
- * Deployment-agnostic: relies only on ffmpeg, the filesystem and
10
- * `process.platform`; makes no assumptions about Home Assistant or any
11
- * specific host. A garbled or unsupported hardware path simply fails its
12
- * test-encode and is skipped, so the worst case is software encoding.
13
- *
14
- * A descriptor exposes:
15
- * - `name` human-readable encoder id (e.g. "h264_vaapi")
16
- * - `kind` "software" | "vaapi" | "qsv" | "nvenc" | "v4l2m2m"
17
- * - `device` device node path or null
18
- * - `inputArgs` ffmpeg args inserted before `-i` (decode/hwaccel setup)
19
- * - `buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec })`
20
- * ffmpeg video filter + encoder args inserted after `-map`s
21
- */
22
-
23
- import { spawn } from "node:child_process";
24
- import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
25
- import { mkdtemp, readFile, rm } from "node:fs/promises";
26
- import os from "node:os";
27
- import path from "node:path";
28
- import { fitDecodeCost } from "./decode-cost-fit.js";
29
- import { penaltiesFrom } from "./contention.js";
30
- import { fileURLToPath } from "node:url";
31
- import {
32
- parseFfmpegBitrateKbps,
33
- parseFfmpegDurationSeconds,
34
- parseFfmpegVideoDimensions,
35
- parseFfmpegVideoFps
36
- } from "../media/ffmpeg-banner.js";
37
-
38
- import { keyFrameArgs, TRANSCODE_FPS } from "./args.js";
39
- import { throughputAt } from "./throughput.js";
40
- import { LADDER_HEIGHTS } from "./output/ladder.js";
41
- // The five kinds, one class each. Detection and benchmarking stay in this file;
42
- // how a kind is driven belongs to the kind.
43
- import {
44
- NvencEncoder,
45
- QsvEncoder,
46
- SoftwareEncoder,
47
- V4l2m2mEncoder,
48
- VaapiEncoder
49
- } from "./index.js";
50
- // Re-exported so every caller goes on importing these figures from here:
51
- // the same calculation, moved to sit beside the encoder kinds built from it.
52
- export {
53
- chooseOutputFps,
54
- maxrateKbpsFor,
55
- limitRowFor,
56
- nominalKbpsFor,
57
- TRANSCODE_FPS
58
- } from "./args.js";
59
-
60
- const BENCHMARK_REF_W = 640;
61
- const BENCHMARK_REF_H = 360;
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
- }
80
- /**
81
- * The narrowest window a slope may be taken over. Measured 2026-08-15: at a
82
- * fifth of a second the readings were noisy enough to put `faster` and
83
- * `veryfast` BELOW `fast`, which libx264 cannot do — and `pickSoftwarePreset`
84
- * walks the list assuming it ascends. Half a second was still noisy enough for that
85
- * (measured again: veryfast below faster, twice), so a full second it is —
86
- * about six seconds of startup for a ladder the whole budget then rests on.
87
- */
88
- const ENCODE_BENCHMARK_WINDOW_SEC = 1;
89
- /** The narrowest window that may be used when a run ends early. */
90
- const ENCODE_BENCHMARK_MIN_WINDOW_SEC = 0.2;
91
- /** Above this a reading is a fault, not a fast machine. */
92
- const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
93
- /**
94
- * A preset that has not reported twice in this long is hung, not slow: reports
95
- * arrive twice a second whatever the encoding speed.
96
- */
97
- const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
98
- /**
99
- * How many times the calibration clip is joined to itself to measure copying.
100
- *
101
- * Not a figure about the machine: it is how much film the reading needs to have
102
- * in front of it. A copy runs at hundreds of times realtime, and the slope is
103
- * taken over a window of one second, so the input has to hold more film than the
104
- * fastest plausible host gets through in that second. Forty laps of a five-second
105
- * clip is 200 s of film, which covers the ceiling `slopeOf` will accept.
106
- */
107
- /** Progress reports arrive line by line. */
108
- const NEWLINE = String.fromCharCode(10);
109
- // Producing one second of video per second of clock. Not a margin and not a
110
- // choice — the definition of keeping up, and the bar when nothing better is
111
- // known about the supply this step will meet.
112
- const REALTIME = 1;
113
- // The bar where decoding CANNOT be priced — no calibration fit, or a source the
114
- // probe said too little about. This one is not measured and cannot be: the
115
- // prediction it guards counts encoding only, which on the field host was
116
- // several times optimistic, and there is no reading on such a host to correct
117
- // it with. It is left at the figure it has had since before decoding was
118
- // priced, because lowering it to realtime would make the least-measured hosts
119
- // the most permissive. Where decoding IS priced, nothing chosen remains.
120
- const UNPRICED_DECODE_BAR = 1.8;
121
-
122
-
123
- // The five kinds live in `encode/`, one class each, and these keep the names
124
- // every caller already uses. A kind states its own arguments and its own
125
- // ladder of speed settings; detection and benchmarking stay here.
126
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
127
- export function softwareDescriptor() {
128
- return new SoftwareEncoder();
129
- }
130
-
131
- /**
132
- * @param {string} device
133
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
134
- */
135
- function vaapiDescriptor(device) {
136
- return new VaapiEncoder(device);
137
- }
138
-
139
- /**
140
- * @param {string} device
141
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
142
- */
143
- function qsvDescriptor(device) {
144
- return new QsvEncoder(device);
145
- }
146
-
147
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
148
- function nvencDescriptor() {
149
- return new NvencEncoder();
150
- }
151
-
152
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
153
- function v4l2m2mDescriptor() {
154
- return new V4l2m2mEncoder();
155
- }
156
-
157
-
158
-
159
- /**
160
- * @typedef {Object} VideoEncoderDescriptor
161
- * @property {string} name
162
- * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
163
- * @property {string|null} device
164
- * @property {string[]} inputArgs
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
166
- */
167
-
168
- /**
169
- * Run ffmpeg and resolve with its exit code and captured output.
170
- *
171
- * @param {string} ffmpegBin
172
- * @param {string[]} args
173
- * @param {number} [timeoutMs=12000]
174
- * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
175
- */
176
- export function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
177
- return new Promise((resolve) => {
178
- let stdout = "";
179
- let stderr = "";
180
- let settled = false;
181
- let child;
182
- const finish = (code) => {
183
- if (settled) {
184
- return;
185
- }
186
- settled = true;
187
- resolve({ code, stdout, stderr });
188
- };
189
- try {
190
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
191
- } catch {
192
- finish(-1);
193
- return;
194
- }
195
- const timer = setTimeout(() => {
196
- try {
197
- child.kill("SIGKILL");
198
- } catch {
199
- // already gone
200
- }
201
- finish(-1);
202
- }, timeoutMs);
203
- child.stdout.on("data", (chunk) => {
204
- stdout += String(chunk);
205
- });
206
- child.stderr.on("data", (d) => {
207
- stderr += String(d);
208
- });
209
- child.on("error", () => {
210
- clearTimeout(timer);
211
- finish(-1);
212
- });
213
- child.on("exit", (code) => {
214
- clearTimeout(timer);
215
- finish(code ?? -1);
216
- });
217
- });
218
- }
219
-
220
- /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
221
- function listRenderNodes() {
222
- try {
223
- return readdirSync("/dev/dri")
224
- .filter((n) => n.startsWith("renderD"))
225
- .map((n) => `/dev/dri/${n}`)
226
- .sort();
227
- } catch {
228
- return [];
229
- }
230
- }
231
-
232
- /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
233
- function hasNvidiaDevice() {
234
- try {
235
- return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
236
- } catch {
237
- return false;
238
- }
239
- }
240
-
241
- /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
242
- function hasV4l2Device() {
243
- try {
244
- return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
245
- } catch {
246
- return false;
247
- }
248
- }
249
-
250
- /**
251
- * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
252
- * (testsrc2 — far more representative than a static black frame) through the
253
- * candidate encoder into real HLS segments in `outDir`, with keyframes forced
254
- * on segment boundaries. Verifying the resulting segments (see
255
- * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
256
- * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
257
- *
258
- * @param {VideoEncoderDescriptor} descriptor
259
- * @param {number} segmentDurationSec
260
- * @param {string} outDir
261
- * @returns {string[]}
262
- */
263
- function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
264
- const durationSec = Math.max(8, segmentDurationSec * 3);
265
- const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
266
- const kf = keyFrameArgs(segmentDurationSec);
267
-
268
- /** @type {string[]} */
269
- let pre = ["-hide_banner", "-loglevel", "error"];
270
- /** @type {string[]} */
271
- let encode;
272
- switch (descriptor.kind) {
273
- case "vaapi":
274
- pre = [...pre, "-vaapi_device", String(descriptor.device)];
275
- encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
276
- break;
277
- case "qsv":
278
- pre = [...pre, "-qsv_device", String(descriptor.device)];
279
- encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
280
- break;
281
- case "nvenc":
282
- encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
283
- break;
284
- case "v4l2m2m":
285
- encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
286
- break;
287
- default:
288
- encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
289
- break;
290
- }
291
-
292
- const hlsOut = [
293
- "-f", "hls",
294
- "-hls_time", String(segmentDurationSec),
295
- "-hls_list_size", "0",
296
- "-hls_flags", "independent_segments",
297
- // fMP4 (CMAF) — matches the runtime pipeline (`encode/run-command.js`).
298
- "-hls_segment_type", "fmp4",
299
- "-hls_fmp4_init_filename", "init.mp4",
300
- "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
301
- path.join(outDir, "index.m3u8")
302
- ];
303
- return [...pre, ...source, ...encode, ...hlsOut];
304
- }
305
-
306
- /**
307
- * Verify the HLS segments produced by the test encode are valid: at least two
308
- * segments exist, and each decodes standalone without errors. A segment that
309
- * does not begin with a keyframe (broken/corrupted output) emits decode errors
310
- * when read on its own, which fails this check.
311
- *
312
- * @param {string} ffmpegBin
313
- * @param {string} outDir
314
- * @returns {Promise<boolean>}
315
- */
316
- export async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
317
- let files;
318
- try {
319
- files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
320
- } catch {
321
- return false;
322
- }
323
- if (files.length < 2) {
324
- return false;
325
- }
326
- // fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
327
- // Decode the whole playlist (ffmpeg's own, which references init.mp4 via
328
- // #EXT-X-MAP), so every segment is exercised together with the init. Any
329
- // corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
330
- // no-picture access unit) surfaces as a decode error here.
331
- const result = await runFfmpeg(
332
- ffmpegBin,
333
- ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
334
- 12000
335
- );
336
- return result.code === 0 && result.stderr.trim().length === 0;
337
- }
338
-
339
- /**
340
- * Detect the best usable H.264 encoder. Always resolves (falls back to
341
- * software libx264). Each hardware candidate is verified with a real
342
- * test-encode before being selected.
343
- *
344
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
345
- * @returns {Promise<VideoEncoderDescriptor>}
346
- */
347
- export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
348
- const log = logger ?? { info: () => {}, warn: () => {} };
349
- const software = softwareDescriptor();
350
-
351
- const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
352
- if (code !== 0) {
353
- log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
354
- return software;
355
- }
356
- const has = (name) => stdout.includes(name);
357
-
358
- /** @type {VideoEncoderDescriptor[]} */
359
- const candidates = [];
360
- const renderNodes = listRenderNodes();
361
- if (has("h264_nvenc") && hasNvidiaDevice()) {
362
- candidates.push(nvencDescriptor());
363
- }
364
- if (has("h264_qsv") && renderNodes.length > 0) {
365
- candidates.push(qsvDescriptor(renderNodes[0]));
366
- }
367
- if (has("h264_vaapi") && renderNodes.length > 0) {
368
- candidates.push(vaapiDescriptor(renderNodes[0]));
369
- }
370
- // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
371
- // strict keyframe-alignment test below, because some V4L2 M2M builds silently
372
- // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
373
- // host falls back to software libx264.
374
- if (has("h264_v4l2m2m") && hasV4l2Device()) {
375
- candidates.push(v4l2m2mDescriptor());
376
- }
377
-
378
- for (const candidate of candidates) {
379
- const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
380
- let ok = false;
381
- try {
382
- const encoded = await runFfmpeg(
383
- ffmpegBin,
384
- buildEncoderTestArgs(candidate, segmentDurationSec, dir),
385
- 25000
386
- );
387
- if (encoded.code === 0) {
388
- ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
389
- }
390
- } finally {
391
- try {
392
- rmSync(dir, { recursive: true, force: true });
393
- } catch {
394
- // best effort
395
- }
396
- }
397
- if (ok) {
398
- log.info(
399
- `hwaccel: using hardware encoder ${candidate.name}` +
400
- `${candidate.device ? ` (${candidate.device})` : ""}`
401
- );
402
- return candidate;
403
- }
404
- log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
405
- }
406
-
407
- log.info("hwaccel: no working hardware encoder; using software libx264");
408
- return software;
409
- }
410
-
411
- /**
412
- * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
413
- * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
414
- * when either is missing, HDR sources are re-encoded without tone mapping
415
- * (washed-out but playable). Always resolves.
416
- *
417
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
418
- * @returns {Promise<boolean>}
419
- */
420
- export async function detectTonemapSupport({ ffmpegBin, logger }) {
421
- const log = logger ?? { info: () => {}, warn: () => {} };
422
- const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
423
- if (code !== 0) {
424
- log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
425
- return false;
426
- }
427
- // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
428
- const hasZscale = /\bzscale\b/.test(stdout);
429
- const hasTonemap = /\btonemap\b/.test(stdout);
430
- const supported = hasZscale && hasTonemap;
431
- log.info(
432
- `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
433
- `(zscale=${hasZscale} tonemap=${hasTonemap})`
434
- );
435
- return supported;
436
- }
437
-
438
-
439
- // The clips the decode cost is fitted from. They ship with the package
440
- // (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
441
- // — real, grainy live action, because a generated `testsrc2` clip decodes 158 %
442
- // away from a real film where these are 11 % away (measured 2026-08-14).
443
- //
444
- // Three sizes at two bitrates each, with the axes varied INDEPENDENTLY. The set
445
- // this replaced was three clips for three unknowns, two of them at the same
446
- // size: an exact system, which cannot fail visibly. On 2026-08-17 it returned
447
- // `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s` — the bitrate term and
448
- // the constant exactly zero — and the prediction on top of it was 1.8-2.2x
449
- // optimistic. Six points leave three spare, so the fit has a residual, and a
450
- // term the data does not determine can be refused instead of published as a
451
- // zero that looks measured. See `assets/calibration/NOTICE.md`.
452
- //
453
- // One set PER CODEC FAMILY, because a family is what the model describes. The
454
- // fit used to be H.264 only, while a video that has to be RE-ENCODED is by
455
- // definition one the browser could not play — which is to say HEVC, 10-bit or
456
- // AV1 — and those decode dearer per pixel on the same box. Pricing them with
457
- // H.264 constants is the one case the model is always asked about and was never
458
- // measured on.
459
- //
460
- // A family that has no set of its own is priced with H.264's, which is what
461
- // happened to every family before this; the line says so rather than implying
462
- // it. AV1 has no set yet: the survey of 2026-07-10 found it rare where HEVC was
463
- // 18 % of releases, so it waits for the same treatment.
464
- const CALIBRATION_SETS = {
465
- h264: [
466
- "cal-h264-1080-hi.mp4",
467
- "cal-h264-1080-lo.mp4",
468
- "cal-h264-720-hi.mp4",
469
- "cal-h264-720-lo.mp4",
470
- "cal-h264-480-hi.mp4",
471
- "cal-h264-480-lo.mp4"
472
- ],
473
- hevc: [
474
- "cal-hevc-1080-hi.mp4",
475
- "cal-hevc-1080-lo.mp4",
476
- "cal-hevc-480-hi.mp4",
477
- "cal-hevc-480-lo.mp4"
478
- ],
479
- hevc10: [
480
- "cal-hevc10-1080-hi.mp4",
481
- "cal-hevc10-1080-lo.mp4",
482
- "cal-hevc10-480-hi.mp4",
483
- "cal-hevc10-480-lo.mp4"
484
- ]
485
- };
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");
488
- // How wide the measured window must be before the slope is trusted, and how
489
- // long to wait for it at most.
490
- //
491
- // Half a second, and it is the TIMING noise that sets it rather than the amount
492
- // of video: the slope is output time against wall time, both read from the same
493
- // two progress lines, and the jitter in stamping one is milliseconds — so half
494
- // a second of window is a fraction of a percent of error on any host. What used
495
- // to make a longer window necessary was the clip restarting inside it, and that
496
- // is gone: the stream is continuous now. Measured 2026-08-22 against the
497
- // continuous-pass truth on a desktop: -3.0 % and +0.6 % at half a second, with
498
- // the readings spread 2-6 %, against -25 % and -33 % for the loop it replaces.
499
- // Half a second also costs the startup about 0.6 s per clip less, which matters
500
- // because every clip of every codec family is paid for before any viewer
501
- // exists.
502
- const DECODE_WINDOW_MIN_SEC = 0.5;
503
- const DECODE_WINDOW_MAX_MS = 8000;
504
-
505
- /**
506
- * Read what a calibration clip IS from the decode run's own output: the
507
- * dimensions, the frame rate and the bitrate ffmpeg reports for it. Read rather
508
- * than declared, so replacing a clip cannot silently invalidate the fit.
509
- *
510
- * @param {string} stderr
511
- * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, durationSeconds: number } | null}
512
- */
513
- function parseClipCharacteristics(stderr) {
514
- // The same readers the session manager uses on the same banner — one parser
515
- // per fact, so a second copy cannot drift from the first.
516
- const { width, height } = parseFfmpegVideoDimensions(stderr);
517
- const rate = parseFfmpegVideoFps(stderr);
518
- const seconds = parseFfmpegDurationSeconds(stderr);
519
- const kbps = parseFfmpegBitrateKbps(stderr);
520
- if (!(width > 0) || !(height > 0) || !(rate > 0) || !(seconds > 0) || !(kbps > 0)) {
521
- return null;
522
- }
523
- return {
524
- megapixelsPerSecond: (width * height * rate) / 1e6,
525
- megabitsPerSecond: kbps / 1000,
526
- durationSeconds: seconds
527
- };
528
- }
529
-
530
- /**
531
- * Measure what DECODING costs on this host, as seconds of work per second of
532
- * video, and solve it into three host constants:
533
- *
534
- * decodeCost = a × Mpixel/s + b × Mbit/s + c
535
- *
536
- * Why it exists: the preset benchmark below measures ENCODING only, and a
537
- * re-encode pays for both halves. Measured 2026-08-14 on the addon host, that
538
- * omission made the budget offer a 240p rung it then ran at 0.39-0.95× — the
539
- * benchmark said the host cleared the bar 2.5× over. With the decode term the
540
- * same file predicts within 4.8 %; without it the error on that rung was 209 %.
541
- *
542
- * The constants are properties of the HOST, so this runs once at startup (about
543
- * 5 s on a CM4) and any source is then priced from figures the probe already
544
- * has — nothing is added to a session's cold start.
545
- *
546
- * They are also properties of the CODEC, and the clips are H.264: HEVC, AV1 and
547
- * 10-bit decode dearer per pixel on the same machine, and a source that has to
548
- * be re-encoded is by definition one this browser could not play, which is
549
- * usually not H.264. So the fit is optimistic exactly there. Closing that needs
550
- * clips in those codecs, and is its own roadmap item.
551
- *
552
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
553
- * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
554
- */
555
- /**
556
- * What a second job costs on this host, measured rather than assumed.
557
- *
558
- * The budget adds seconds of work per second of content — this encode, plus
559
- * that decode, plus what is already committed — and the addon host contradicted
560
- * that directly on 2026-08-18: decoding ran at 2.10-2.25x alone, 0.79-0.90x
561
- * with one encoder beside it and 0.56-0.64x with two. The same work costs 2.6×
562
- * more for having company. Heat is not the cause (the hot idle machine was the
563
- * fastest reading of all); four cores sharing one path to memory is.
564
- *
565
- * So it is measured the way everything else here is: the same clip decoded
566
- * alone, then decoded again while an encoder of the same clip runs beside it.
567
- * The ratio is the penalty. The encoder is stopped as soon as the reading is
568
- * taken, and the whole thing costs one decode plus one short encode.
569
- *
570
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string, upTo?: number }} options
571
- * @returns {Promise<Map<number, number> | null>} Penalties by how many other
572
- * jobs were running, or null when the readings could not be taken.
573
- */
574
- export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, upTo = 2 }) {
575
- const log = logger ?? { info: () => {}, warn: () => {} };
576
- // The cheapest clip in the set: this measures the MACHINE's behaviour under
577
- // company, not the clip's own cost, so the smallest one says it soonest.
578
- const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
579
- const startedAt = Date.now();
580
- // Lifted once and decoded three times from the same bytes. Going through
581
- // `measureDecodeSlope` lifted it again for every reading — three process
582
- // starts on a path that is awaited before the proxy's tunnel opens, for a
583
- // remux whose result had not changed.
584
- const streams = await extractFamilyStreams(ffmpegBin, [clip], "h264");
585
- const stream = streams?.[0];
586
- if (!stream) {
587
- log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
588
- return null;
589
- }
590
- const alone = await decodePipedStream(ffmpegBin, stream, log);
591
- if (!alone?.speed) {
592
- log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
593
- return null;
594
- }
595
- /** @type {Array<{ others: number, speed: number }>} */
596
- const beside = [];
597
- /** @type {import("node:child_process").ChildProcess[]} */
598
- const load = [];
599
- try {
600
- for (let others = 1; others <= Math.max(1, upTo); others += 1) {
601
- load.push(
602
- spawn(
603
- ffmpegBin,
604
- [
605
- "-hide_banner", "-loglevel", "error", "-nostats",
606
- "-stream_loop", "-1", "-i", clip,
607
- "-an", "-c:v", "libx264", "-preset", "fast", "-f", "null", "-"
608
- ],
609
- { stdio: ["ignore", "ignore", "ignore"], windowsHide: true }
610
- )
611
- );
612
- // Let the encoder reach its own speed before reading anything: an encode
613
- // measured in its first moments is measuring the process starting.
614
- await new Promise((resolve) => {
615
- setTimeout(resolve, 2_000);
616
- });
617
- const withCompany = await decodePipedStream(ffmpegBin, stream, log);
618
- if (withCompany?.speed) {
619
- beside.push({ others, speed: withCompany.speed });
620
- }
621
- }
622
- } finally {
623
- for (const child of load) {
624
- try {
625
- child.kill("SIGKILL");
626
- } catch {
627
- // Already gone: the reading is what mattered, and nothing else uses it.
628
- }
629
- }
630
- }
631
- const penalties = penaltiesFrom(alone.speed, beside);
632
- if (!penalties) {
633
- log.warn("hwaccel: contention readings said nothing; costs will be added as though jobs were independent");
634
- return null;
635
- }
636
- log.info(
637
- `hwaccel: a second job costs ${[...penalties.entries()]
638
- .map(([others, penalty]) => `${penalty.toFixed(2)}x beside ${others}`)
639
- .join(", ")} ` +
640
- `(decode alone ${alone.speed.toFixed(2)}x, ` +
641
- `${beside.map((reading) => `${reading.speed.toFixed(2)}x beside ${reading.others}`).join(", ")}, ` +
642
- `measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
643
- );
644
- return penalties;
645
- }
646
-
647
- export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
648
- const log = logger ?? { info: () => {}, warn: () => {} };
649
- const startedAllAt = Date.now();
650
- /** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
651
- const families = {};
652
- for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
653
- const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
654
- if (fitted) {
655
- families[family] = fitted;
656
- }
657
- }
658
- if (!families.h264) {
659
- // H.264 is the family every other one falls back to, so without it there is
660
- // no model at all rather than a partial one. Which families DID fit is said
661
- // anyway: on a fast host the H.264 clips decode at 20-80x and the readings
662
- // stop being ordered — measured 2026-08-20 on a desktop, 1080p at 9.35
663
- // Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is
664
- // not a thing a decoder does — so a failure here is a measurement problem
665
- // and not a missing file, and the line has to let those be told apart.
666
- log.warn(
667
- "hwaccel: decode cost unknown — the H.264 clips did not fit" +
668
- (Object.keys(families).length > 0
669
- ? `, though ${Object.keys(families).join(" and ")} did`
670
- : "")
671
- );
672
- return null;
673
- }
674
- const missing = Object.keys(CALIBRATION_SETS).filter((family) => !families[family]);
675
- log.info(
676
- `hwaccel: decode cost measured for ${Object.keys(families).join(", ")}` +
677
- (missing.length > 0 ? `; ${missing.join(" and ")} priced as H.264` : "") +
678
- ` (in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
679
- );
680
- return { families, ...families.h264 };
681
- }
682
-
683
- /**
684
- * Fit one codec family's decode cost from its own clips.
685
- *
686
- * @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
687
- * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
688
- */
689
- async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
690
- const startedAllAt = Date.now();
691
- /** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
692
- const samples = [];
693
- // Every clip of the family is lifted out of its container FIRST, in one
694
- // ffmpeg run. See `extractFamilyStreams` for why one run rather than one per
695
- // clip, and why before the measurements rather than beside them.
696
- const streams = await extractFamilyStreams(
697
- ffmpegBin,
698
- clips.map((clip) => path.join(clipsDir, clip)),
699
- family
700
- );
701
- if (!streams) {
702
- log.warn(
703
- `hwaccel: ${family} cannot be lifted out of its container — no Annex-B filter is mapped for it, ` +
704
- `so its clips were never measured`
705
- );
706
- return null;
707
- }
708
- for (const [index, clip] of clips.entries()) {
709
- const stream = streams[index];
710
- const measured = stream ? await decodePipedStream(ffmpegBin, stream, log) : null;
711
- if (!measured?.speed) {
712
- log.warn(
713
- `hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
714
- (measured?.error ? ` — ${measured.error}` : " — the clip could not be lifted out of its container")
715
- );
716
- return null;
717
- }
718
- const cost = 1 / measured.speed;
719
- samples.push({
720
- megapixelsPerSecond: measured.megapixelsPerSecond,
721
- megabitsPerSecond: measured.megabitsPerSecond,
722
- costSecondsPerSecond: cost
723
- });
724
- log.info(
725
- `hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
726
- `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
727
- `(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
728
- );
729
- }
730
- const fitted = fitDecodeCost(samples);
731
- if (!fitted) {
732
- log.warn(`hwaccel: ${family} decode cost could not be fitted to these measurements`);
733
- return null;
734
- }
735
- log.info(
736
- `hwaccel: ${family} decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
737
- `+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape} from ${fitted.samples} clips, ` +
738
- `typical disagreement ${fitted.residualRms.toFixed(4)} s/s` +
739
- // Named rather than implied: a zero in the line above means "not
740
- // measured" for a dropped term and "measured to be nothing" otherwise,
741
- // and those are different claims.
742
- (fitted.dropped.length > 0 ? `, ${fitted.dropped.join(" and ")} not determined by these clips` : "") +
743
- `, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
744
- );
745
- return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
746
- }
747
-
748
- /**
749
- * The bitstream filter and demuxer that turn a clip's video track into a
750
- * continuous elementary stream, by codec family.
751
- *
752
- * H.264 and HEVC in MP4 keep their parameter sets in the container's `avcC` /
753
- * `hvcC` and their access units length-prefixed; Annex-B carries them inline,
754
- * with start codes, which is what makes plain byte concatenation a valid
755
- * stream. That is the property this whole measurement rests on.
756
- */
757
- const ANNEX_B_BY_FAMILY = {
758
- h264: { filter: "h264_mp4toannexb", demuxer: "h264" },
759
- hevc: { filter: "hevc_mp4toannexb", demuxer: "hevc" },
760
- hevc10: { filter: "hevc_mp4toannexb", demuxer: "hevc" }
761
- };
762
-
763
- /**
764
- * The last complaint in an ffmpeg stderr, for a line that has to say why.
765
- *
766
- * @param {string} stderr
767
- * @returns {string}
768
- */
769
- function lastErrorLine(stderr) {
770
- const lines = String(stderr ?? "")
771
- .split(/\r?\n/)
772
- .map((line) => line.trim())
773
- .filter((line) => line.length > 0);
774
- return lines[lines.length - 1] ?? "";
775
- }
776
-
777
- /**
778
- * How long the lift may take before it is abandoned. It is a remux of a few
779
- * megabytes, so this is not a budget — it is the difference between a startup
780
- * that reports a failure and one that never finishes. Every other ffmpeg run in
781
- * this file has such a bound; this one did not, and it is awaited before the
782
- * proxy's tunnel opens.
783
- */
784
- const EXTRACT_TIMEOUT_MS = 20_000;
785
-
786
- /**
787
- * Lift a whole family's clips out of their containers, as Annex-B elementary
788
- * streams, in ONE ffmpeg run.
789
- *
790
- * No re-encoding — the frames are copied — so the work itself is trivial and
791
- * the cost is almost entirely the process. Doing one process per clip added
792
- * 11 s to the startup here (fourteen clips at about 0.83 s each), and running
793
- * them concurrently did not help: six at once took 4.75 s against 0.89 s for
794
- * one, so the machine serialises them. One run with many inputs and many
795
- * outputs costs one process.
796
- *
797
- * The outputs go to temporary files because several outputs cannot share one
798
- * pipe; they are read into memory and deleted immediately, and nothing about
799
- * this measurement is kept between runs.
800
- *
801
- * Before the measurements, never beside them: a remux running next to a decode
802
- * is a second job on the machine, and this benchmark exists to find out what
803
- * ONE job costs here.
804
- *
805
- * @param {string} ffmpegBin
806
- * @param {string[]} clipPaths
807
- * @param {string} family
808
- * @returns {Promise<Array<{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number } | null> | null>}
809
- * One entry per clip, in order; null when the family cannot be lifted at all.
810
- */
811
- async function extractFamilyStreams(ffmpegBin, clipPaths, family) {
812
- const shape = ANNEX_B_BY_FAMILY[family];
813
- // A family with no mapping is a hard failure, not a silent fallback to
814
- // H.264's filter. AV1 has no Annex-B form at all (its packaging is OBU), and
815
- // MPEG-2 and VC-1 have no `*_mp4toannexb` filter — so the three families the
816
- // roadmap plans next cannot come through here, and finding that out as
817
- // "the clip failed" would send the reader after the clip.
818
- if (!shape) {
819
- return null;
820
- }
821
- const workDir = await mkdtemp(path.join(os.tmpdir(), "ttv-calibration-"));
822
- const outputs = clipPaths.map((_, index) => path.join(workDir, `stream-${index}.${shape.demuxer}`));
823
- /** @type {string[]} */
824
- const args = ["-hide_banner", "-loglevel", "info", "-nostats", "-y"];
825
- for (const clipPath of clipPaths) {
826
- args.push("-i", clipPath);
827
- }
828
- for (const [index, output] of outputs.entries()) {
829
- args.push("-map", `${index}:v:0`, "-c:v", "copy", "-bsf:v", shape.filter, "-f", shape.demuxer, output);
830
- }
831
- const stderr = await runCapturingStderr(ffmpegBin, args, EXTRACT_TIMEOUT_MS);
832
- try {
833
- if (stderr === null) {
834
- return null;
835
- }
836
- // One banner block per input, in the order they were given. Read rather
837
- // than declared, so replacing a clip cannot silently invalidate the fit
838
- // that rests on it.
839
- const blocks = splitInputBlocks(stderr, clipPaths.length);
840
- return await Promise.all(clipPaths.map(async (_, index) => {
841
- const block = blocks[index];
842
- if (!block) {
843
- return null;
844
- }
845
- const clipInfo = parseClipCharacteristics(block);
846
- const fps = parseFfmpegVideoFps(block);
847
- if (!clipInfo || !(fps > 0)) {
848
- return null;
849
- }
850
- let bytes;
851
- try {
852
- bytes = await readFile(outputs[index]);
853
- } catch {
854
- return null;
855
- }
856
- if (bytes.length === 0) {
857
- return null;
858
- }
859
- return {
860
- bytes,
861
- demuxer: shape.demuxer,
862
- megapixelsPerSecond: clipInfo.megapixelsPerSecond,
863
- megabitsPerSecond: clipInfo.megabitsPerSecond,
864
- fps
865
- };
866
- }));
867
- } finally {
868
- await rm(workDir, { recursive: true, force: true }).catch(() => {});
869
- }
870
- }
871
-
872
- /**
873
- * The part of an ffmpeg banner describing each input, in order.
874
- *
875
- * ffmpeg prints one `Input #N, …` block per input and then the stream mapping;
876
- * the parsers here read a single input's facts, so they are given a single
877
- * input's text rather than the whole banner.
878
- *
879
- * @param {string} stderr
880
- * @param {number} count
881
- * @returns {string[]}
882
- */
883
- function splitInputBlocks(stderr, count) {
884
- /** @type {string[]} */
885
- const blocks = [];
886
- for (let index = 0; index < count; index += 1) {
887
- const from = stderr.indexOf(`Input #${index},`);
888
- if (from < 0) {
889
- blocks.push("");
890
- continue;
891
- }
892
- const nextInput = stderr.indexOf(`Input #${index + 1},`, from);
893
- const mapping = stderr.indexOf("Stream mapping:", from);
894
- const ends = [nextInput, mapping].filter((at) => at > from);
895
- blocks.push(stderr.slice(from, ends.length > 0 ? Math.min(...ends) : stderr.length));
896
- }
897
- return blocks;
898
- }
899
-
900
- /**
901
- * Run ffmpeg to completion and return its stderr, or null when it failed or
902
- * outlasted its bound.
903
- *
904
- * @param {string} ffmpegBin
905
- * @param {string[]} args
906
- * @param {number} timeoutMs
907
- * @returns {Promise<string | null>}
908
- */
909
- function runCapturingStderr(ffmpegBin, args, timeoutMs) {
910
- return new Promise((resolve) => {
911
- let stderr = "";
912
- let settled = false;
913
- let child;
914
- const settle = (value) => {
915
- if (settled) {
916
- return;
917
- }
918
- settled = true;
919
- clearTimeout(timer);
920
- try {
921
- child?.kill("SIGKILL");
922
- } catch {
923
- // already gone
924
- }
925
- resolve(value);
926
- };
927
- const timer = setTimeout(() => settle(null), timeoutMs);
928
- try {
929
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
930
- } catch {
931
- settle(null);
932
- return;
933
- }
934
- child.stderr.on("data", (chunk) => {
935
- stderr += String(chunk);
936
- });
937
- child.on("error", () => settle(null));
938
- child.on("close", (code) => settle(code === 0 ? stderr : null));
939
- });
940
- }
941
-
942
- /**
943
- * Measure how fast this host DECODES a clip, from ffmpeg's own report of how
944
- * much video it has processed.
945
- *
946
- * Two things are deliberately outside the measurement.
947
- *
948
- * **The process starting.** Wall-clock around the process cannot answer this:
949
- * starting ffmpeg costs about a second, and on a quick machine a five-second
950
- * clip decodes in a tenth of that, so the measurement would be of the program
951
- * starting. Progress lines arrive AFTER it has started, and the slope between
952
- * two of them — video processed against time taken — contains no part of the
953
- * startup by construction.
954
- *
955
- * **The clip restarting.** This used to loop the clip with `-stream_loop -1`,
956
- * and a loop is not free: measured 2026-08-22 on a desktop, a restart costs
957
- * 0.03 s on the 480p clip and 0.12 s on the 1080p one — the decoder tearing
958
- * down and re-allocating its frame buffers, which is why the price rises with
959
- * the picture. A five-second clip decoded at 55x restarts eleven times a
960
- * second, so that cost DOMINATED the reading: the same clips measured 53.7x
961
- * looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse,
962
- * the bias is not shared — it depends on the clip's own resolution and on how
963
- * fast the host is — so it does not cancel out of the fit, it tilts it. That is
964
- * the fast-host failure recorded on 2026-08-20, where 1080p read cheaper than
965
- * 720p, which is not a thing a decoder does.
966
- *
967
- * So the clip is fed to the decoder as ONE stream instead. An Annex-B
968
- * elementary stream carries its parameter sets inline, so writing the same
969
- * bytes again is simply more stream — the decoder never re-initialises, and
970
- * there is no restart inside the window to measure. Verified against the
971
- * continuous-pass truth on the same host: -0.2 % and -5.5 %, against -25 % and
972
- * -33 % for the loop. Nothing is written to disk and the process is killed as
973
- * soon as the window is wide enough.
974
- *
975
- * Exported because the property that broke here is checkable and was not being
976
- * checked: a bigger picture must cost more than a smaller one of the same
977
- * bitrate, and under the loop it did not.
978
- *
979
- * @param {string} ffmpegBin
980
- * @param {string} clipPath
981
- * @param {string} [family="h264"]
982
- * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
983
- */
984
- export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
985
- const streams = await extractFamilyStreams(ffmpegBin, [clipPath], family);
986
- const stream = streams?.[0];
987
- if (!stream) {
988
- return null;
989
- }
990
- const measured = await decodePipedStream(ffmpegBin, stream);
991
- return measured?.speed ? measured : null;
992
- }
993
-
994
- /**
995
- * Decode an elementary stream fed from memory, and report the slope.
996
- *
997
- * @param {string} ffmpegBin
998
- * @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
999
- * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
1000
- */
1001
- function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }) {
1002
- return new Promise((resolve) => {
1003
- const args = [
1004
- "-hide_banner", "-loglevel", "error", "-nostats",
1005
- // A raw stream states no frame rate, so the one the container declared is
1006
- // given back to it. It decides how output time advances, and therefore
1007
- // what "seconds of video per second of clock" means.
1008
- "-f", stream.demuxer, "-framerate", String(stream.fps), "-i", "pipe:0",
1009
- "-an", "-f", "null", "-",
1010
- "-progress", "pipe:1"
1011
- ];
1012
- /** @type {Array<{ wallSec: number, outSec: number }>} */
1013
- const samples = [];
1014
- let stdout = "";
1015
- // Kept because this path depends on three things the old one did not: the
1016
- // raw demuxer accepting the frame rate, the bitstream filter having
1017
- // produced something parsable, and the fed concatenation being decodable.
1018
- // Without it the only trace of any of those failing is "said nothing".
1019
- let stderr = "";
1020
- let settled = false;
1021
- let child;
1022
- const startedAt = Date.now();
1023
- // Whether the FEED, not the decoder, could be what this reading measures
1024
- // (item 4(d2)). `child.stdin.write()` returning false was tried as the
1025
- // signal — never once true would mean this process was never ahead of the
1026
- // pipe — and measured false on every reading taken while writing this,
1027
- // including clips this same host decodes at 15-80x with room to spare, so
1028
- // it does not discriminate: `write()`'s return value tracks Node's own
1029
- // internal watermark against the size of what was just handed to it, not
1030
- // real drain state, and answered "no slack" identically whether the pipe
1031
- // or the decoder was the true limit. Rather than publish a verdict that
1032
- // reads the same in both cases, only the byte count is kept, for the
1033
- // MB/s figure below — a number to read, not a boolean to trust.
1034
- let bytesWritten = 0;
1035
- const finish = () => {
1036
- if (settled) {
1037
- return;
1038
- }
1039
- settled = true;
1040
- clearTimeout(timer);
1041
- try {
1042
- child?.stdin?.destroy();
1043
- } catch {
1044
- // already gone
1045
- }
1046
- try {
1047
- child?.kill("SIGKILL");
1048
- } catch {
1049
- // already gone
1050
- }
1051
- // The first sample still carries the startup — it reports whatever was
1052
- // processed while the process was coming up. Everything is measured from
1053
- // the second onwards.
1054
- const first = samples[1];
1055
- const last = samples[samples.length - 1];
1056
- if (!first || !last) {
1057
- resolve({ error: lastErrorLine(stderr) || "the decoder reported no progress" });
1058
- return;
1059
- }
1060
- const windowSec = last.wallSec - first.wallSec;
1061
- const producedSec = last.outSec - first.outSec;
1062
- if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
1063
- resolve({ error: lastErrorLine(stderr) || `the window was ${windowSec.toFixed(2)}s of ${producedSec.toFixed(2)}s produced` });
1064
- return;
1065
- }
1066
- const speed = producedSec / windowSec;
1067
- // Diagnostic only — logged, not acted on. Both figures are taken over the
1068
- // SAME window the speed itself is: bytesWritten is snapshotted alongside
1069
- // every progress sample, so this compares like against like rather than
1070
- // the achieved rate over the whole run (which starts before the first
1071
- // kept sample and reads systematically low against the window's own
1072
- // rate for no reason but that mismatch — measured while building this).
1073
- const windowBytes = last.bytesWritten - first.bytesWritten;
1074
- const achievedMBps = windowSec > 0 ? windowBytes / windowSec / 1e6 : 0;
1075
- const requiredMBps = ((stream.megabitsPerSecond * 1e6) / 8) * speed / 1e6;
1076
- // "Far apart" is stated, not left to the reader to eyeball: outside a
1077
- // factor of 1.5 either way is bigger than the write-timing slop this
1078
- // comparison carries on a healthy reading.
1079
- const farApart = requiredMBps > 0 && (achievedMBps / requiredMBps < 1 / 1.5 || achievedMBps / requiredMBps > 1.5);
1080
- log.info(
1081
- `hwaccel: decode pipe fed ${achievedMBps.toFixed(1)} MB/s, ${requiredMBps.toFixed(1)} MB/s ` +
1082
- `needed for ${speed.toFixed(2)}x` +
1083
- (farApart ? " — far enough apart to be worth a second look" : "")
1084
- );
1085
- resolve({
1086
- speed,
1087
- windowSec,
1088
- megapixelsPerSecond: stream.megapixelsPerSecond,
1089
- megabitsPerSecond: stream.megabitsPerSecond,
1090
- pipeThroughputMBps: achievedMBps
1091
- });
1092
- };
1093
- const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
1094
- try {
1095
- child = spawn(ffmpegBin, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1096
- } catch (error) {
1097
- clearTimeout(timer);
1098
- settled = true;
1099
- resolve({ error: error instanceof Error ? error.message : String(error) });
1100
- return;
1101
- }
1102
- child.stderr.on("data", (chunk) => {
1103
- stderr += String(chunk);
1104
- });
1105
- // Keep the decoder fed. `write` returning false means the pipe is full, and
1106
- // the next copy goes on the `drain` — so the decoder is never starved and
1107
- // this process never buffers more than the pipe holds.
1108
- const writeOnce = () => {
1109
- const accepted = child.stdin.write(stream.bytes);
1110
- bytesWritten += stream.bytes.length;
1111
- return accepted;
1112
- };
1113
- const feed = () => {
1114
- while (!settled && child.stdin.writable && writeOnce()) {
1115
- // Written straight through; go round again.
1116
- }
1117
- };
1118
- child.stdin.on("drain", feed);
1119
- // The kill closes the pipe under the writer; that is the intended end.
1120
- child.stdin.on("error", () => {});
1121
- child.stdout.on("data", (chunk) => {
1122
- stdout += String(chunk);
1123
- let newline = stdout.indexOf("\n");
1124
- while (newline >= 0) {
1125
- const line = stdout.slice(0, newline).trim();
1126
- stdout = stdout.slice(newline + 1);
1127
- if (line.startsWith("out_time_ms=")) {
1128
- const microseconds = Number(line.slice("out_time_ms=".length));
1129
- if (Number.isFinite(microseconds)) {
1130
- samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6, bytesWritten });
1131
- }
1132
- }
1133
- newline = stdout.indexOf("\n");
1134
- }
1135
- if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
1136
- finish();
1137
- }
1138
- });
1139
- child.on("error", (error) => {
1140
- if (settled) {
1141
- return;
1142
- }
1143
- clearTimeout(timer);
1144
- settled = true;
1145
- try {
1146
- child?.stdin?.destroy();
1147
- } catch {
1148
- // already gone
1149
- }
1150
- try {
1151
- child?.kill("SIGKILL");
1152
- } catch {
1153
- // already gone
1154
- }
1155
- resolve({ error: error instanceof Error ? error.message : String(error) });
1156
- });
1157
- child.on("close", finish);
1158
- feed();
1159
- });
1160
- }
1161
-
1162
-
1163
- /**
1164
- * How many times realtime this host can DECODE a source of these
1165
- * characteristics, from the startup fit. `null` when the fit is unavailable or
1166
- * the source figures are not known.
1167
- *
1168
- * @param {{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null} model
1169
- * @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
1170
- * @returns {number | null}
1171
- */
1172
- export function decodeSpeedFor(model, source) {
1173
- if (!model) {
1174
- return null;
1175
- }
1176
- const pixels = Number(source?.megapixelsPerSecond);
1177
- const bits = Number(source?.megabitsPerSecond);
1178
- if (!Number.isFinite(pixels) || pixels <= 0 || !Number.isFinite(bits) || bits < 0) {
1179
- return null;
1180
- }
1181
- const cost = model.pixelTerm * pixels + model.bitrateTerm * bits + model.constantTerm;
1182
- if (!(cost > 0)) {
1183
- return null;
1184
- }
1185
- return 1 / cost;
1186
- }
1187
-
1188
- /**
1189
- * How many times realtime a re-encode of this source at this output pixel rate
1190
- * would run: decoding and encoding share the machine, so their costs add and
1191
- * their speeds combine as
1192
- *
1193
- * 1 / (1/decodeSpeed + 1/encodeSpeed)
1194
- *
1195
- * Checked 2026-08-14 on the rung that broke playback: 1/(1/2.31 + 1/5.99) =
1196
- * 1.67× against 1.48× measured. With no decode fit this falls back to the
1197
- * encode speed alone — which is what the budget did before, and which
1198
- * overestimated that rung five to eleven times.
1199
- *
1200
- * @param {{ decodeModel: { pixelTerm: number, bitrateTerm: number, constantTerm: number } | null, encodePixelsPerSec: number, outputPixelsPerSec: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1201
- * @returns {number | null}
1202
- */
1203
- export function predictedRealtimeSpeed({
1204
- decodeModel,
1205
- encodePixelsPerSec,
1206
- outputPixelsPerSec,
1207
- source,
1208
- observedDecodeCostSec = null
1209
- }) {
1210
- if (!Number.isFinite(encodePixelsPerSec) || encodePixelsPerSec <= 0) {
1211
- return null;
1212
- }
1213
- if (!Number.isFinite(outputPixelsPerSec) || outputPixelsPerSec <= 0) {
1214
- return null;
1215
- }
1216
- const encodeSpeed = encodePixelsPerSec / outputPixelsPerSec;
1217
- // What this very file has been seen to cost, when it has been: the clips are
1218
- // H.264 and a source that has to be re-encoded usually is not, so a figure
1219
- // taken from the encoder actually running on THIS source beats any model of
1220
- // a stand-in. It arrives seconds into playback and replaces the estimate.
1221
- const decodeSpeed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1222
- ? 1 / observedDecodeCostSec
1223
- : (source ? decodeSpeedFor(decodeModel, source) : null);
1224
- if (decodeSpeed === null) {
1225
- return encodeSpeed;
1226
- }
1227
- return 1 / (1 / decodeSpeed + 1 / encodeSpeed);
1228
- }
1229
-
1230
- /**
1231
- * Whether this host can hold realtime, with the margin, while re-encoding this
1232
- * source to this output pixel rate — and the predicted speed either way, so a
1233
- * refusal can say what it refused on.
1234
- *
1235
- * The encoder figure is the FASTEST benchmarked preset: it is the best this
1236
- * host can do, so a rung it cannot hold cannot be held at any quality setting.
1237
- *
1238
- * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number, requiredSpeed?: number | null }} params
1239
- * @returns {{ speed: number | null, sustainable: boolean }}
1240
- */
1241
- export function canSustainOutput({
1242
- benchmark,
1243
- decodeModel = null,
1244
- source = null,
1245
- outputPixelsPerSec,
1246
- observedDecodeCostSec = null,
1247
- concurrentCostSec = 0,
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
1253
- }) {
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.
1258
- return { speed: null, sustainable: true };
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
- }
1271
- const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1272
- ? observedDecodeCostSec
1273
- : null;
1274
- if (observed === null && !isDecodePriced({ decodeModel, source })) {
1275
- // An encoder-only figure was several times too optimistic on the rung this
1276
- // check exists for, so it is not fit to refuse anything. Without the decode
1277
- // term the ladder is offered whole, exactly as it was before.
1278
- return { speed: null, sustainable: true };
1279
- }
1280
- const alone = predictedRealtimeSpeed({
1281
- decodeModel,
1282
- encodePixelsPerSec,
1283
- outputPixelsPerSec,
1284
- source,
1285
- observedDecodeCostSec: observed
1286
- });
1287
- // What ELSE will be running while this rung is. A rung is never the only
1288
- // thing on the machine: the picture it accompanies is being copied or
1289
- // encoded, an audio track may have its own encoder, and a warm-up is two
1290
- // encoders by design. Measured on the addon host, a copy alone takes about an
1291
- // eighth of the machine per second of video, and the field case of
1292
- // 2026-08-15 adds up exactly: 0.125 for the copy plus ~1.05 for the rung is
1293
- // more than the one second per second the machine has, which is what was
1294
- // observed.
1295
- //
1296
- // Zero when nothing else is known to be running, or when nothing has been
1297
- // measured yet — then this is a LOWER bound on the cost and the check is as
1298
- // permissive as it was before.
1299
- const speed = alone === null || !(concurrentCostSec > 0)
1300
- ? alone
1301
- : 1 / (1 / alone + concurrentCostSec);
1302
- if (speed === null) {
1303
- return { speed: null, sustainable: true };
1304
- }
1305
- return { speed, sustainable: speed >= speedBar(requiredSpeed) };
1306
- }
1307
-
1308
- /**
1309
- * The speed a step has to reach to be worth offering.
1310
- *
1311
- * Realtime is not enough on its own: a step that produces exactly one second
1312
- * per second never recovers the seconds lost while its reader waits for the
1313
- * swarm, so it survives its own supply only if what it gains between
1314
- * interruptions covers what one interruption costs. That is measured per file
1315
- * and per swarm by the reader — `1 + worst wait / median interval`, in
1316
- * `torrent/supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1317
- * against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
1318
- * swarm no encoder could have kept up with.
1319
- *
1320
- * Where that figure does not exist yet — fewer than two interruptions measured
1321
- * — the bar is realtime. It is the one thing that can be said without
1322
- * measuring the swarm, and the offer is restated as soon as the reader has
1323
- * something to say.
1324
- *
1325
- * @param {number | null | undefined} requiredSpeed - What this file's own
1326
- * interruptions demand, when they have been measured.
1327
- * @returns {number}
1328
- */
1329
- export function speedBar(requiredSpeed) {
1330
- return Number.isFinite(requiredSpeed) && requiredSpeed > REALTIME ? requiredSpeed : REALTIME;
1331
- }
1332
-
1333
- /**
1334
- * The bar for a cost description — the supply's demand where decoding is
1335
- * priced, and never below the unpriced-decode bar where it is not.
1336
- *
1337
- * @param {{ decodeModel?: object | null, source?: object | null, observedDecodeCostSec?: number | null, requiredSpeed?: number | null }} cost
1338
- * @returns {number}
1339
- */
1340
- function barFor(cost) {
1341
- const measured = speedBar(cost?.requiredSpeed);
1342
- return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
1343
- }
1344
-
1345
- /**
1346
- * Benchmark software libx264 presets on this host. Encodes a short synthetic
1347
- * clip at a fixed reference resolution with each preset and measures encoder
1348
- * throughput in pixels/second. The session manager uses this to pick, per
1349
- * stream, the highest-quality preset that still encodes the actual
1350
- * (source-capped) resolution faster than realtime.
1351
- *
1352
- * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
1353
- * omitted from the result.
1354
- *
1355
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
1356
- * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
1357
- */
1358
- export async function benchmarkSoftwarePresets({ ffmpegBin, logger, encoder = null }) {
1359
- const log = logger ?? { info: () => {}, warn: () => {} };
1360
-
1361
- // REAL footage, decoded ONCE into raw frames, and the presets are then timed
1362
- // on those frames.
1363
- //
1364
- // Two reasons, both measured. The pattern this replaced (`testsrc2`) has flat
1365
- // areas and no grain and encodes 1.23x cheaper than film on the same machine
1366
- // and preset — an error that always points at offering a rung the host cannot
1367
- // hold. And feeding a compressed clip to each preset instead would put
1368
- // decoding and scaling inside the measurement: subtracting them afterwards
1369
- // compares a wall clock that includes process startup against a decode figure
1370
- // measured to exclude it, while inside one ffmpeg the two halves overlap. On
1371
- // the fastest preset — the one every ladder decision reads as the ceiling —
1372
- // that subtraction is most of the number being measured, so a small error in
1373
- // it becomes a large error in the answer.
1374
- //
1375
- // Raw frames remove all of it: no decoder, no scaler, nothing to subtract,
1376
- // and no dependence on the decode model. The cost is 25 MB of memory in a
1377
- // pipe for a few seconds.
1378
- const rawFramesPath = await decodeToRawFrames(ffmpegBin, log);
1379
- if (rawFramesPath === null) {
1380
- // Said once more, in the words that matter to whoever reads the log next:
1381
- // with no benchmark, `#sustainableHeights` filters nothing and every rung
1382
- // is offered, which is the failure of 2026-08-14 in full.
1383
- log.warn("hwaccel: the quality ladder is UNFILTERED on this host — nothing measured the encoder");
1384
- return [];
1385
- }
1386
- /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
1387
- const results = [];
1388
- try {
1389
- // THE CHOSEN ENCODER'S OWN LADDER, whatever kind it is. NVENC walks p1…p7,
1390
- // QSV veryfast…veryslow, VAAPI its quality levels. A kind with no ladder is
1391
- // measured once, which is still a reading where there was none at all.
1392
- const ladder = encoder?.speedLadder;
1393
- const rungs = Array.isArray(ladder?.values) && ladder.values.length > 0 ? ladder.values : [null];
1394
- for (const rung of rungs) {
1395
- const speed = await measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath);
1396
- if (speed === null) {
1397
- log.warn(`hwaccel: the benchmark of "${rung ?? encoder?.name}" produced no usable reading; skipping`);
1398
- continue;
1399
- }
1400
- const pixelsPerSec = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * speed;
1401
- results.push({ preset: rung ?? encoder?.name, pixelsPerSec });
1402
- log.info(
1403
- `hwaccel: ${encoder?.name} "${rung ?? "as it comes"}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
1404
- `(${speed.toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H}, real footage)`
1405
- );
1406
- }
1407
- } finally {
1408
- // The encoder was killed a moment ago and on Windows the handle outlives
1409
- // the signal, so removal is retried and its failure is not worth a session:
1410
- // this is a temp directory the operating system will clear anyway.
1411
- try {
1412
- rmSync(path.dirname(rawFramesPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1413
- } catch (error) {
1414
- log.warn(`hwaccel: could not remove the benchmark's raw frames: ${error instanceof Error ? error.message : String(error)}`);
1415
- }
1416
- }
1417
- return results;
1418
- }
1419
-
1420
- /**
1421
- * How fast one preset encodes, from ffmpeg's own reports of how much video it
1422
- * has written — not from the clock around the process.
1423
- *
1424
- * Timing whole runs measures the run STARTING. Measured 2026-08-15 on a desktop
1425
- * that spawns ffmpeg in ~0.4 s: three seconds of raw frames encoded that way
1426
- * put `fast` and `ultrafast` within 1.24x of each other, when libx264's own
1427
- * presets differ by several times — the constant had swallowed the difference.
1428
- * The slope between two progress reports contains no part of the startup.
1429
- *
1430
- * The frames are written repeatedly so there is runway to measure over,
1431
- * whatever the preset's speed.
1432
- *
1433
- * @param {string} ffmpegBin
1434
- * @param {string} preset
1435
- * @param {string} rawFramesPath
1436
- * @returns {Promise<number | null>} Video seconds encoded per second of clock.
1437
- */
1438
- /**
1439
- * Video seconds produced per second of clock, from ffmpeg's own reports.
1440
- *
1441
- * Startup is excluded by taking a DIFFERENCE: it lands in the wall clock of
1442
- * every report equally, so it cancels between two of them. (The decode
1443
- * benchmark drops its first report instead, because there the first one is
1444
- * emitted at out_time zero; here reports with no time yet are discarded before
1445
- * they arrive, so the first kept one is already running.)
1446
- *
1447
- * @param {Array<{ wallSec: number, outSec: number }>} samples
1448
- * @param {number} [minimumWindowSec=ENCODE_BENCHMARK_WINDOW_SEC]
1449
- * @returns {number | null}
1450
- */
1451
- export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC) {
1452
- const first = samples[0];
1453
- const last = samples[samples.length - 1];
1454
- if (!first || !last || first === last) {
1455
- return null;
1456
- }
1457
- const took = last.wallSec - first.wallSec;
1458
- const produced = last.outSec - first.outSec;
1459
- if (!(took >= minimumWindowSec) || !(produced > 0)) {
1460
- return null;
1461
- }
1462
- const slope = produced / took;
1463
- // Nothing encodes a thousand times realtime. A figure above that is a
1464
- // measurement fault, and letting it through opens the whole ladder.
1465
- return slope <= ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED ? slope : null;
1466
- }
1467
-
1468
- export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
1469
- return new Promise((resolve) => {
1470
- const args = [
1471
- "-hide_banner", "-loglevel", "error", "-nostats",
1472
- // What a device-backed encoder needs before the input — the device. Not
1473
- // its decoding setup: this is fed raw frames and there is nothing to
1474
- // decode, and `-hwaccel vaapi` over rawvideo fails to open.
1475
- ...(typeof encoder?.benchmarkInputArgs === "function" ? encoder.benchmarkInputArgs() : []),
1476
- "-stream_loop", "-1",
1477
- "-f", "rawvideo", "-pix_fmt", "yuv420p",
1478
- "-s", `${frame.width}x${frame.height}`, "-r", String(TRANSCODE_FPS),
1479
- "-i", rawFramesPath,
1480
- // THE ENCODER SAYS HOW TO MEASURE ITSELF. It was libx264 written here, so
1481
- // a host with NVENC, QSV, VAAPI or V4L2M2M measured its encoder not at all
1482
- // and the quality offer, which is arithmetic over pixels per second, had
1483
- // no pixels per second to work with.
1484
- ...encoder.benchmarkArgs(rung),
1485
- "-f", "null", "-",
1486
- "-progress", "pipe:1"
1487
- ];
1488
- /** @type {Array<{ wallSec: number, outSec: number }>} */
1489
- const samples = [];
1490
- let settled = false;
1491
- let buffered = "";
1492
- let child;
1493
- const startedAt = Date.now();
1494
- const finish = (value) => {
1495
- if (settled) {
1496
- return;
1497
- }
1498
- settled = true;
1499
- clearTimeout(timer);
1500
- try {
1501
- child?.kill("SIGKILL");
1502
- } catch {
1503
- // already gone
1504
- }
1505
- resolve(value);
1506
- };
1507
- const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1508
- try {
1509
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1510
- } catch {
1511
- finish(null);
1512
- return;
1513
- }
1514
- // The frames come from a FILE, read on repeat by ffmpeg itself. Fed through
1515
- // a pipe instead, the fastest presets measured the pipe: `ultrafast` on a
1516
- // desktop wants raw frames at hundreds of megabytes a second, which no
1517
- // writer here can supply, and the reading then describes the feeding rather
1518
- // than the encoder.
1519
- child.stdout.on("data", (chunk) => {
1520
- buffered += String(chunk);
1521
- let newline = buffered.indexOf(NEWLINE);
1522
- while (newline >= 0) {
1523
- const line = buffered.slice(0, newline).trim();
1524
- buffered = buffered.slice(newline + 1);
1525
- if (line.startsWith("out_time_ms=")) {
1526
- const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1527
- // `N/A` is not the only way ffmpeg says "no position yet": some builds
1528
- // print the smallest signed 64-bit integer, which IS finite and would
1529
- // be taken for a position nine trillion seconds before the start.
1530
- if (Number.isFinite(outSec) && outSec >= 0) {
1531
- samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1532
- }
1533
- }
1534
- newline = buffered.indexOf(NEWLINE);
1535
- }
1536
- const slope = slopeOf(samples);
1537
- if (slope !== null) {
1538
- finish(slope);
1539
- }
1540
- });
1541
- child.on("error", () => finish(null));
1542
- // A preset that finished before the window was wide enough is measured from
1543
- // whatever it did report, provided two reports exist at all.
1544
- // A preset that finished before the wide window was covered is still
1545
- // measured — but never over a window of nothing. Two reports a millisecond
1546
- // apart would divide a frame of video by that millisecond and call the host
1547
- // twenty times faster than it is, and one such reading becomes the figure
1548
- // every ladder decision is taken from.
1549
- child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1550
- });
1551
- }
1552
-
1553
- /**
1554
- * The benchmark's footage as raw frames: the calibration clip, looped to the
1555
- * benchmark's length and scaled to its size, decoded once.
1556
- *
1557
- * @param {string} ffmpegBin
1558
- * @param {{ info: (m: string) => void, warn: (m: string) => void }} log
1559
- * @returns {Promise<string | null>} Path to the raw frames, or null.
1560
- */
1561
- export async function decodeToRawFrames(ffmpegBin, log, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
1562
- // A benchmark may leave a host unmeasured; it may never stop it from
1563
- // starting. Before this the temp directory was made outside any guard, so a
1564
- // read-only or missing TMPDIR rejected the promise that starts the proxy.
1565
- let directory;
1566
- try {
1567
- directory = mkdtempSync(path.join(os.tmpdir(), "torrent-tv-bench-"));
1568
- } catch (error) {
1569
- log.warn(
1570
- `hwaccel: no writable temp directory for the preset benchmark (${error instanceof Error ? error.message : String(error)}); ` +
1571
- "presets unmeasured, so no quality rung will be refused on this host"
1572
- );
1573
- return null;
1574
- }
1575
- const rawPath = path.join(directory, "frames.yuv");
1576
- const args = [
1577
- "-hide_banner", "-loglevel", "error",
1578
- "-stream_loop", "-1",
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}`,
1582
- "-an", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-y", rawPath
1583
- ];
1584
- const { code } = await runFfmpeg(ffmpegBin, args, 30000);
1585
- const expectedBytes = frame.width * frame.height * 1.5 * rawFrameCountFor(frame);
1586
- let written = 0;
1587
- try {
1588
- written = statSync(rawPath).size;
1589
- } catch {
1590
- written = 0;
1591
- }
1592
- if (code !== 0 || written < expectedBytes * 0.9) {
1593
- log.warn(
1594
- "hwaccel: could not decode the calibration clip for the preset benchmark " +
1595
- `(${written} of ~${Math.round(expectedBytes)} bytes); presets unmeasured, ` +
1596
- "so no quality rung will be refused on this host"
1597
- );
1598
- try {
1599
- rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1600
- } catch {
1601
- // A temp directory the operating system will clear; not worth a start-up.
1602
- }
1603
- return null;
1604
- }
1605
- return rawPath;
1606
- }
1607
-
1608
- /**
1609
- * Pick the highest-quality (slowest) benchmarked preset that can encode
1610
- * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
1611
- * benchmarked preset, or `"ultrafast"` when no benchmark is available.
1612
- *
1613
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1614
- * @param {number} pixelsPerSecNeeded
1615
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1616
- * @returns {string}
1617
- */
1618
- /**
1619
- * What this host can do at its CHEAPEST preset — the ceiling of the ladder.
1620
- *
1621
- * Deliberately not the largest reading in the array. The list is in quality
1622
- * order, so its last measured entry is the cheapest preset; taking the maximum
1623
- * instead would let one noisy reading of an expensive preset raise the bar that
1624
- * decides which rungs are offered, and a rung offered on noise is a rung the
1625
- * host cannot hold. For choosing a preset the direction of that error is
1626
- * harmless; for deciding what to offer it is not, so the two use different
1627
- * statistics on purpose.
1628
- *
1629
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark
1630
- * @returns {number}
1631
- */
1632
- function cheapestPresetPixelsPerSec(benchmark, frame = null) {
1633
- const cheapest = benchmark[benchmark.length - 1];
1634
- return cheapest ? throughputAt(cheapest, frame) : null;
1635
- }
1636
-
1637
- export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}, frame = null) {
1638
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1639
- return "ultrafast";
1640
- }
1641
- const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1642
- ? cost.observedDecodeCostSec
1643
- : null;
1644
- const bar = barFor(cost);
1645
- // The FIRST entry that clears the bar wins — the list is in quality order, so
1646
- // that is the best picture this host can hold. Every entry is examined rather
1647
- // than the walk stopping at the first miss, because the measurements do not
1648
- // always ascend with the list: on a busy machine on 2026-08-15 `faster` read
1649
- // below `fast` twice.
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
- }
1657
- const speed = predictedRealtimeSpeed({
1658
- decodeModel: cost.decodeModel ?? null,
1659
- encodePixelsPerSec,
1660
- outputPixelsPerSec: pixelsPerSecNeeded,
1661
- source: cost.source ?? null,
1662
- observedDecodeCostSec: observed
1663
- });
1664
- if (speed !== null && speed >= bar) {
1665
- return entry.preset;
1666
- }
1667
- }
1668
- // Nothing clears the bar: the cheapest preset, which is the last in quality
1669
- // order. Returning whichever preset measured fastest would hand an expensive
1670
- // one to a host that has just been shown to hold no rung at all.
1671
- return benchmark[benchmark.length - 1].preset;
1672
- }
1673
-
1674
- /**
1675
- * Whether a cost description can actually price decoding — a fit AND a source
1676
- * to apply it to. Without both, every prediction is encoder-only.
1677
- *
1678
- * @param {{ decodeModel?: object | null, source?: object | null }} cost
1679
- * @returns {boolean}
1680
- */
1681
- function isDecodePriced(cost) {
1682
- if (Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0) {
1683
- return true; // measured on the source itself, which needs no fit to stand on
1684
- }
1685
- return Boolean(cost?.decodeModel) && Boolean(cost?.source);
1686
- }
1687
-
1688
- /**
1689
- * Build the resolution ladder for a ceiling box. Returns candidate output
1690
- * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
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.
1694
- *
1695
- * @param {number} ceilingWidth
1696
- * @param {number} ceilingHeight
1697
- * @returns {Array<{ width: number, height: number }>} high→low
1698
- */
1699
- export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1700
- const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
1701
- const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
1702
- if (!cw || !ch) {
1703
- return [];
1704
- }
1705
- const even = (v) => {
1706
- const r = Math.round(v);
1707
- return Math.max(2, r - (r % 2));
1708
- };
1709
- /** @type {Array<{ width: number, height: number }>} */
1710
- const rungs = [{ width: cw, height: ch }];
1711
- for (const h of LADDER_HEIGHTS) {
1712
- if (h >= ch) {
1713
- continue; // at/above the ceiling — the ceiling rung already covers it
1714
- }
1715
- rungs.push({ width: even(cw * (h / ch)), height: h });
1716
- }
1717
- const seen = new Set();
1718
- return rungs.filter((rung) => {
1719
- if (seen.has(rung.height)) {
1720
- return false;
1721
- }
1722
- seen.add(rung.height);
1723
- return true;
1724
- });
1725
- }
1726
-
1727
- /**
1728
- * Choose the software encode settings (resolution + preset) that fit the
1729
- * realtime budget on this host. From the resolution ladder (ceiling downward),
1730
- * pick the HIGHEST rung whose encode throughput — predicted from the startup
1731
- * benchmark's fastest preset — clears the speed this file's own supply
1732
- * demands (`speedBar`). Then, at that resolution, pick the highest-quality
1733
- * preset that still clears it. When even the lowest rung cannot clear it, use the lowest rung with
1734
- * the fastest preset (best effort — a smaller picture beats sub-realtime
1735
- * playback at full size). Returns null when no benchmark or ceiling is
1736
- * available (the caller keeps the ceiling resolution and the default preset).
1737
- *
1738
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1739
- * @param {{ width: number, height: number }} ceiling
1740
- * @param {number} outputFps
1741
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1742
- * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1743
- */
1744
- export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1745
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1746
- return null;
1747
- }
1748
- const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
1749
- const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
1750
- if (ladder.length === 0) {
1751
- return null;
1752
- }
1753
- const bar = barFor(cost);
1754
- let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
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
- }
1761
- const speed = predictedRealtimeSpeed({
1762
- decodeModel: cost.decodeModel ?? null,
1763
- encodePixelsPerSec: fastest,
1764
- outputPixelsPerSec: ladder[i].width * ladder[i].height * fps,
1765
- source: cost.source ?? null
1766
- });
1767
- if (speed !== null && speed >= bar) {
1768
- chosenIndex = i;
1769
- break;
1770
- }
1771
- }
1772
- const chosen = ladder[chosenIndex];
1773
- const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost, chosen);
1774
- return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1775
- }
1776
-
1777
- /**
1778
- * How fast this machine COPIES a picture, in seconds of film per second.
1779
- *
1780
- * The startup measurements price encoding and decoding, and a copied picture
1781
- * does neither: it reads packets and writes them out again. That left one whole
1782
- * branch of what this proxy does with no figure at all, and a figure is what
1783
- * every decision in the encoding layer is made from — where to put an encoder,
1784
- * how many to run, whether anybody will be left waiting. Without it a copied
1785
- * output was planned with no speed until its own run had been running long
1786
- * enough to report one, which is exactly the moment the plan matters most.
1787
- *
1788
- * Measured the same way as the others: ffmpeg's own progress, read as a slope
1789
- * over a window, so the process starting is outside the figure.
1790
- *
1791
- * The clip is joined to itself first rather than looped with `-stream_loop`.
1792
- * Looping charges a re-initialisation per lap — measured on the addon host at
1793
- * 0.03 s for 480p and 0.12 s for 1080p — and a copy of a five-second clip laps
1794
- * many times a second, so the reading would have been mostly re-initialisation.
1795
- *
1796
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} params
1797
- * @returns {Promise<number | null>} Seconds of film per second, or null where
1798
- * the reading could not be taken. Null means unmeasured and is never a
1799
- * substitute for a number.
1800
- */
1801
- export async function benchmarkCopySpeed({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
1802
- const log = logger ?? { info: () => {}, warn: () => {} };
1803
- const startedAt = Date.now();
1804
- // The largest clip in the set. A copy moves BYTES, so what it can do is a
1805
- // statement about the biggest pictures this host will be asked to pass
1806
- // through, and the small ones are covered by the same figure.
1807
- const clip = path.join(clipsDir, "cal-h264-1080-hi.mp4");
1808
- const speed = await measureCopySlope(ffmpegBin, clip);
1809
- if (!(speed > 0)) {
1810
- log.warn("hwaccel: copying could not be measured; a copied picture will be planned from its own run instead");
1811
- return null;
1812
- }
1813
- log.info(
1814
- `hwaccel: this host copies a picture at ${speed.toFixed(0)}x realtime ` +
1815
- `(measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
1816
- );
1817
- return speed;
1818
- }
1819
-
1820
- /**
1821
- * Seconds of film per second, copying one file, read from ffmpeg's progress.
1822
- *
1823
- * @param {string} ffmpegBin
1824
- * @param {string} filePath
1825
- * @returns {Promise<number | null>}
1826
- */
1827
- function measureCopySlope(ffmpegBin, filePath) {
1828
- return new Promise((resolve) => {
1829
- const args = [
1830
- "-hide_banner", "-loglevel", "error", "-nostats",
1831
- // Played over and over, because a copy gets through a five-second clip in
1832
- // milliseconds and a slope needs a window to be taken over. Looping
1833
- // charges the demuxer being re-opened once a lap, so what comes out is a
1834
- // FLOOR on what this host can copy — the safe direction, since a plan made
1835
- // from it expects copying to be slower than it is.
1836
- "-stream_loop", "-1", "-i", filePath,
1837
- // What a copied output does: packets in, packets out, nothing decoded and
1838
- // nothing encoded. Written nowhere, so the figure is this machine's own
1839
- // handling and not the disk under a temp directory.
1840
- "-c", "copy", "-f", "null", "-",
1841
- // Progress is reported every half second by default, which over a window
1842
- // of one second is two readings. This asks for twenty.
1843
- "-stats_period", "0.05",
1844
- "-progress", "pipe:1"
1845
- ];
1846
- /** @type {Array<{ wallSec: number, outSec: number }>} */
1847
- const samples = [];
1848
- let settled = false;
1849
- let buffered = "";
1850
- let child;
1851
- const startedAt = Date.now();
1852
- const finish = (value) => {
1853
- if (settled) {
1854
- return;
1855
- }
1856
- settled = true;
1857
- clearTimeout(timer);
1858
- try {
1859
- child?.kill("SIGKILL");
1860
- } catch {
1861
- // already gone
1862
- }
1863
- resolve(value);
1864
- };
1865
- const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1866
- try {
1867
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1868
- } catch {
1869
- finish(null);
1870
- return;
1871
- }
1872
- child.stdout.on("data", (chunk) => {
1873
- buffered += String(chunk);
1874
- let newline = buffered.indexOf(NEWLINE);
1875
- while (newline >= 0) {
1876
- const line = buffered.slice(0, newline).trim();
1877
- buffered = buffered.slice(newline + 1);
1878
- if (line.startsWith("out_time_ms=")) {
1879
- const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1880
- if (Number.isFinite(outSec) && outSec >= 0) {
1881
- samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1882
- }
1883
- }
1884
- newline = buffered.indexOf(NEWLINE);
1885
- }
1886
- const slope = slopeOf(samples);
1887
- if (slope !== null) {
1888
- finish(slope);
1889
- }
1890
- });
1891
- child.on("error", () => finish(null));
1892
- child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1893
- });
1894
- }
1
+ /**
2
+ * @file Hardware-accelerated H.264 encoder auto-detection.
3
+ *
4
+ * Probes the ffmpeg build and the host for a usable hardware H.264 encoder
5
+ * (NVENC / QSV / VAAPI / V4L2 M2M), verifying each candidate with a real
6
+ * test-encode before selecting it. Falls back to software libx264 when no
7
+ * hardware encoder is present or working.
8
+ *
9
+ * Deployment-agnostic: relies only on ffmpeg, the filesystem and
10
+ * `process.platform`; makes no assumptions about Home Assistant or any
11
+ * specific host. A garbled or unsupported hardware path simply fails its
12
+ * test-encode and is skipped, so the worst case is software encoding.
13
+ *
14
+ * A descriptor exposes:
15
+ * - `name` human-readable encoder id (e.g. "h264_vaapi")
16
+ * - `kind` "software" | "vaapi" | "qsv" | "nvenc" | "v4l2m2m"
17
+ * - `device` device node path or null
18
+ * - `inputArgs` ffmpeg args inserted before `-i` (decode/hwaccel setup)
19
+ * - `buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec })`
20
+ * ffmpeg video filter + encoder args inserted after `-map`s
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
25
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { fitDecodeCost } from "./decode-cost-fit.js";
29
+ import { penaltiesFrom } from "./contention.js";
30
+ import { fileURLToPath } from "node:url";
31
+ import {
32
+ parseFfmpegBitrateKbps,
33
+ parseFfmpegDurationSeconds,
34
+ parseFfmpegVideoDimensions,
35
+ parseFfmpegVideoFps
36
+ } from "../media/ffmpeg-banner.js";
37
+
38
+ import { keyFrameArgs, TRANSCODE_FPS } from "./args.js";
39
+ import { throughputAt } from "./throughput.js";
40
+ import { LADDER_HEIGHTS } from "./output/ladder.js";
41
+ // The five kinds, one class each. Detection and benchmarking stay in this file;
42
+ // how a kind is driven belongs to the kind.
43
+ import {
44
+ NvencEncoder,
45
+ QsvEncoder,
46
+ SoftwareEncoder,
47
+ V4l2m2mEncoder,
48
+ VaapiEncoder
49
+ } from "./index.js";
50
+ // Re-exported so every caller goes on importing these figures from here:
51
+ // the same calculation, moved to sit beside the encoder kinds built from it.
52
+ export {
53
+ chooseOutputFps,
54
+ maxrateKbpsFor,
55
+ limitRowFor,
56
+ nominalKbpsFor,
57
+ TRANSCODE_FPS
58
+ } from "./args.js";
59
+
60
+ const BENCHMARK_REF_W = 640;
61
+ const BENCHMARK_REF_H = 360;
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
+ }
80
+ /**
81
+ * The narrowest window a slope may be taken over. Measured 2026-08-15: at a
82
+ * fifth of a second the readings were noisy enough to put `faster` and
83
+ * `veryfast` BELOW `fast`, which libx264 cannot do — and `pickSoftwarePreset`
84
+ * walks the list assuming it ascends. Half a second was still noisy enough for that
85
+ * (measured again: veryfast below faster, twice), so a full second it is —
86
+ * about six seconds of startup for a ladder the whole budget then rests on.
87
+ */
88
+ const ENCODE_BENCHMARK_WINDOW_SEC = 1;
89
+ /** The narrowest window that may be used when a run ends early. */
90
+ const ENCODE_BENCHMARK_MIN_WINDOW_SEC = 0.2;
91
+ /** Above this a reading is a fault, not a fast machine. */
92
+ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
93
+ /**
94
+ * A preset that has not reported twice in this long is hung, not slow: reports
95
+ * arrive twice a second whatever the encoding speed.
96
+ */
97
+ const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
98
+ /**
99
+ * How many times the calibration clip is joined to itself to measure copying.
100
+ *
101
+ * Not a figure about the machine: it is how much film the reading needs to have
102
+ * in front of it. A copy runs at hundreds of times realtime, and the slope is
103
+ * taken over a window of one second, so the input has to hold more film than the
104
+ * fastest plausible host gets through in that second. Forty laps of a five-second
105
+ * clip is 200 s of film, which covers the ceiling `slopeOf` will accept.
106
+ */
107
+ /** Progress reports arrive line by line. */
108
+ const NEWLINE = String.fromCharCode(10);
109
+ // Producing one second of video per second of clock. Not a margin and not a
110
+ // choice — the definition of keeping up, and the bar when nothing better is
111
+ // known about the supply this step will meet.
112
+ const REALTIME = 1;
113
+ // The bar where decoding CANNOT be priced — no calibration fit, or a source the
114
+ // probe said too little about. This one is not measured and cannot be: the
115
+ // prediction it guards counts encoding only, which on the field host was
116
+ // several times optimistic, and there is no reading on such a host to correct
117
+ // it with. It is left at the figure it has had since before decoding was
118
+ // priced, because lowering it to realtime would make the least-measured hosts
119
+ // the most permissive. Where decoding IS priced, nothing chosen remains.
120
+ const UNPRICED_DECODE_BAR = 1.8;
121
+
122
+
123
+ // The five kinds live in `encode/`, one class each, and these keep the names
124
+ // every caller already uses. A kind states its own arguments and its own
125
+ // ladder of speed settings; detection and benchmarking stay here.
126
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
127
+ export function softwareDescriptor() {
128
+ return new SoftwareEncoder();
129
+ }
130
+
131
+ /**
132
+ * @param {string} device
133
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
134
+ */
135
+ function vaapiDescriptor(device) {
136
+ return new VaapiEncoder(device);
137
+ }
138
+
139
+ /**
140
+ * @param {string} device
141
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
142
+ */
143
+ function qsvDescriptor(device) {
144
+ return new QsvEncoder(device);
145
+ }
146
+
147
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
148
+ function nvencDescriptor() {
149
+ return new NvencEncoder();
150
+ }
151
+
152
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
153
+ function v4l2m2mDescriptor() {
154
+ return new V4l2m2mEncoder();
155
+ }
156
+
157
+
158
+
159
+ /**
160
+ * @typedef {Object} VideoEncoderDescriptor
161
+ * @property {string} name
162
+ * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
163
+ * @property {string|null} device
164
+ * @property {string[]} inputArgs
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
166
+ */
167
+
168
+ /**
169
+ * Run ffmpeg and resolve with its exit code and captured output.
170
+ *
171
+ * @param {string} ffmpegBin
172
+ * @param {string[]} args
173
+ * @param {number} [timeoutMs=12000]
174
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
175
+ */
176
+ export function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
177
+ return new Promise((resolve) => {
178
+ let stdout = "";
179
+ let stderr = "";
180
+ let settled = false;
181
+ let child;
182
+ const finish = (code) => {
183
+ if (settled) {
184
+ return;
185
+ }
186
+ settled = true;
187
+ resolve({ code, stdout, stderr });
188
+ };
189
+ try {
190
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
191
+ } catch {
192
+ finish(-1);
193
+ return;
194
+ }
195
+ const timer = setTimeout(() => {
196
+ try {
197
+ child.kill("SIGKILL");
198
+ } catch {
199
+ // already gone
200
+ }
201
+ finish(-1);
202
+ }, timeoutMs);
203
+ child.stdout.on("data", (chunk) => {
204
+ stdout += String(chunk);
205
+ });
206
+ child.stderr.on("data", (d) => {
207
+ stderr += String(d);
208
+ });
209
+ child.on("error", () => {
210
+ clearTimeout(timer);
211
+ finish(-1);
212
+ });
213
+ child.on("exit", (code) => {
214
+ clearTimeout(timer);
215
+ finish(code ?? -1);
216
+ });
217
+ });
218
+ }
219
+
220
+ /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
221
+ function listRenderNodes() {
222
+ try {
223
+ return readdirSync("/dev/dri")
224
+ .filter((n) => n.startsWith("renderD"))
225
+ .map((n) => `/dev/dri/${n}`)
226
+ .sort();
227
+ } catch {
228
+ return [];
229
+ }
230
+ }
231
+
232
+ /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
233
+ function hasNvidiaDevice() {
234
+ try {
235
+ return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
242
+ function hasV4l2Device() {
243
+ try {
244
+ return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
252
+ * (testsrc2 — far more representative than a static black frame) through the
253
+ * candidate encoder into real HLS segments in `outDir`, with keyframes forced
254
+ * on segment boundaries. Verifying the resulting segments (see
255
+ * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
256
+ * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
257
+ *
258
+ * @param {VideoEncoderDescriptor} descriptor
259
+ * @param {number} segmentDurationSec
260
+ * @param {string} outDir
261
+ * @returns {string[]}
262
+ */
263
+ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
264
+ const durationSec = Math.max(8, segmentDurationSec * 3);
265
+ const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
266
+ const kf = keyFrameArgs(segmentDurationSec);
267
+
268
+ /** @type {string[]} */
269
+ let pre = ["-hide_banner", "-loglevel", "error"];
270
+ /** @type {string[]} */
271
+ let encode;
272
+ switch (descriptor.kind) {
273
+ case "vaapi":
274
+ pre = [...pre, "-vaapi_device", String(descriptor.device)];
275
+ encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
276
+ break;
277
+ case "qsv":
278
+ pre = [...pre, "-qsv_device", String(descriptor.device)];
279
+ encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
280
+ break;
281
+ case "nvenc":
282
+ encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
283
+ break;
284
+ case "v4l2m2m":
285
+ encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
286
+ break;
287
+ default:
288
+ encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
289
+ break;
290
+ }
291
+
292
+ const hlsOut = [
293
+ "-f", "hls",
294
+ "-hls_time", String(segmentDurationSec),
295
+ "-hls_list_size", "0",
296
+ "-hls_flags", "independent_segments",
297
+ // fMP4 (CMAF) — matches the runtime pipeline (`encode/run-command.js`).
298
+ "-hls_segment_type", "fmp4",
299
+ "-hls_fmp4_init_filename", "init.mp4",
300
+ "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
301
+ path.join(outDir, "index.m3u8")
302
+ ];
303
+ return [...pre, ...source, ...encode, ...hlsOut];
304
+ }
305
+
306
+ /**
307
+ * Verify the HLS segments produced by the test encode are valid: at least two
308
+ * segments exist, and each decodes standalone without errors. A segment that
309
+ * does not begin with a keyframe (broken/corrupted output) emits decode errors
310
+ * when read on its own, which fails this check.
311
+ *
312
+ * @param {string} ffmpegBin
313
+ * @param {string} outDir
314
+ * @returns {Promise<boolean>}
315
+ */
316
+ export async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
317
+ let files;
318
+ try {
319
+ files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
320
+ } catch {
321
+ return false;
322
+ }
323
+ if (files.length < 2) {
324
+ return false;
325
+ }
326
+ // fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
327
+ // Decode the whole playlist (ffmpeg's own, which references init.mp4 via
328
+ // #EXT-X-MAP), so every segment is exercised together with the init. Any
329
+ // corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
330
+ // no-picture access unit) surfaces as a decode error here.
331
+ const result = await runFfmpeg(
332
+ ffmpegBin,
333
+ ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
334
+ 12000
335
+ );
336
+ return result.code === 0 && result.stderr.trim().length === 0;
337
+ }
338
+
339
+ /**
340
+ * Detect the best usable H.264 encoder. Always resolves (falls back to
341
+ * software libx264). Each hardware candidate is verified with a real
342
+ * test-encode before being selected.
343
+ *
344
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
345
+ * @returns {Promise<VideoEncoderDescriptor>}
346
+ */
347
+ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
348
+ const log = logger ?? { info: () => {}, warn: () => {} };
349
+ const software = softwareDescriptor();
350
+
351
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
352
+ if (code !== 0) {
353
+ log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
354
+ return software;
355
+ }
356
+ const has = (name) => stdout.includes(name);
357
+
358
+ /** @type {VideoEncoderDescriptor[]} */
359
+ const candidates = [];
360
+ const renderNodes = listRenderNodes();
361
+ if (has("h264_nvenc") && hasNvidiaDevice()) {
362
+ candidates.push(nvencDescriptor());
363
+ }
364
+ if (has("h264_qsv") && renderNodes.length > 0) {
365
+ candidates.push(qsvDescriptor(renderNodes[0]));
366
+ }
367
+ if (has("h264_vaapi") && renderNodes.length > 0) {
368
+ candidates.push(vaapiDescriptor(renderNodes[0]));
369
+ }
370
+ // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
371
+ // strict keyframe-alignment test below, because some V4L2 M2M builds silently
372
+ // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
373
+ // host falls back to software libx264.
374
+ if (has("h264_v4l2m2m") && hasV4l2Device()) {
375
+ candidates.push(v4l2m2mDescriptor());
376
+ }
377
+
378
+ for (const candidate of candidates) {
379
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
380
+ let ok = false;
381
+ try {
382
+ const encoded = await runFfmpeg(
383
+ ffmpegBin,
384
+ buildEncoderTestArgs(candidate, segmentDurationSec, dir),
385
+ 25000
386
+ );
387
+ if (encoded.code === 0) {
388
+ ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
389
+ }
390
+ } finally {
391
+ try {
392
+ rmSync(dir, { recursive: true, force: true });
393
+ } catch {
394
+ // best effort
395
+ }
396
+ }
397
+ if (ok) {
398
+ log.info(
399
+ `hwaccel: using hardware encoder ${candidate.name}` +
400
+ `${candidate.device ? ` (${candidate.device})` : ""}`
401
+ );
402
+ return candidate;
403
+ }
404
+ log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
405
+ }
406
+
407
+ log.info("hwaccel: no working hardware encoder; using software libx264");
408
+ return software;
409
+ }
410
+
411
+ /**
412
+ * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
413
+ * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
414
+ * when either is missing, HDR sources are re-encoded without tone mapping
415
+ * (washed-out but playable). Always resolves.
416
+ *
417
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
418
+ * @returns {Promise<boolean>}
419
+ */
420
+ export async function detectTonemapSupport({ ffmpegBin, logger }) {
421
+ const log = logger ?? { info: () => {}, warn: () => {} };
422
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
423
+ if (code !== 0) {
424
+ log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
425
+ return false;
426
+ }
427
+ // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
428
+ const hasZscale = /\bzscale\b/.test(stdout);
429
+ const hasTonemap = /\btonemap\b/.test(stdout);
430
+ const supported = hasZscale && hasTonemap;
431
+ log.info(
432
+ `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
433
+ `(zscale=${hasZscale} tonemap=${hasTonemap})`
434
+ );
435
+ return supported;
436
+ }
437
+
438
+
439
+ // The clips the decode cost is fitted from. They ship with the package
440
+ // (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
441
+ // — real, grainy live action, because a generated `testsrc2` clip decodes 158 %
442
+ // away from a real film where these are 11 % away (measured 2026-08-14).
443
+ //
444
+ // Three sizes at two bitrates each, with the axes varied INDEPENDENTLY. The set
445
+ // this replaced was three clips for three unknowns, two of them at the same
446
+ // size: an exact system, which cannot fail visibly. On 2026-08-17 it returned
447
+ // `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s` — the bitrate term and
448
+ // the constant exactly zero — and the prediction on top of it was 1.8-2.2x
449
+ // optimistic. Six points leave three spare, so the fit has a residual, and a
450
+ // term the data does not determine can be refused instead of published as a
451
+ // zero that looks measured. See `assets/calibration/NOTICE.md`.
452
+ //
453
+ // One set PER CODEC FAMILY, because a family is what the model describes. The
454
+ // fit used to be H.264 only, while a video that has to be RE-ENCODED is by
455
+ // definition one the browser could not play — which is to say HEVC, 10-bit or
456
+ // AV1 — and those decode dearer per pixel on the same box. Pricing them with
457
+ // H.264 constants is the one case the model is always asked about and was never
458
+ // measured on.
459
+ //
460
+ // A family that has no set of its own is priced with H.264's, which is what
461
+ // happened to every family before this; the line says so rather than implying
462
+ // it. AV1 has no set yet: the survey of 2026-07-10 found it rare where HEVC was
463
+ // 18 % of releases, so it waits for the same treatment.
464
+ const CALIBRATION_SETS = {
465
+ h264: [
466
+ "cal-h264-1080-hi.mp4",
467
+ "cal-h264-1080-lo.mp4",
468
+ "cal-h264-720-hi.mp4",
469
+ "cal-h264-720-lo.mp4",
470
+ "cal-h264-480-hi.mp4",
471
+ "cal-h264-480-lo.mp4"
472
+ ],
473
+ hevc: [
474
+ "cal-hevc-1080-hi.mp4",
475
+ "cal-hevc-1080-lo.mp4",
476
+ "cal-hevc-480-hi.mp4",
477
+ "cal-hevc-480-lo.mp4"
478
+ ],
479
+ hevc10: [
480
+ "cal-hevc10-1080-hi.mp4",
481
+ "cal-hevc10-1080-lo.mp4",
482
+ "cal-hevc10-480-hi.mp4",
483
+ "cal-hevc10-480-lo.mp4"
484
+ ]
485
+ };
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");
488
+ // How wide the measured window must be before the slope is trusted, and how
489
+ // long to wait for it at most.
490
+ //
491
+ // Half a second, and it is the TIMING noise that sets it rather than the amount
492
+ // of video: the slope is output time against wall time, both read from the same
493
+ // two progress lines, and the jitter in stamping one is milliseconds — so half
494
+ // a second of window is a fraction of a percent of error on any host. What used
495
+ // to make a longer window necessary was the clip restarting inside it, and that
496
+ // is gone: the stream is continuous now. Measured 2026-08-22 against the
497
+ // continuous-pass truth on a desktop: -3.0 % and +0.6 % at half a second, with
498
+ // the readings spread 2-6 %, against -25 % and -33 % for the loop it replaces.
499
+ // Half a second also costs the startup about 0.6 s per clip less, which matters
500
+ // because every clip of every codec family is paid for before any viewer
501
+ // exists.
502
+ const DECODE_WINDOW_MIN_SEC = 0.5;
503
+ const DECODE_WINDOW_MAX_MS = 8000;
504
+
505
+ /**
506
+ * Read what a calibration clip IS from the decode run's own output: the
507
+ * dimensions, the frame rate and the bitrate ffmpeg reports for it. Read rather
508
+ * than declared, so replacing a clip cannot silently invalidate the fit.
509
+ *
510
+ * @param {string} stderr
511
+ * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, durationSeconds: number } | null}
512
+ */
513
+ function parseClipCharacteristics(stderr) {
514
+ // The same readers the session manager uses on the same banner — one parser
515
+ // per fact, so a second copy cannot drift from the first.
516
+ const { width, height } = parseFfmpegVideoDimensions(stderr);
517
+ const rate = parseFfmpegVideoFps(stderr);
518
+ const seconds = parseFfmpegDurationSeconds(stderr);
519
+ const kbps = parseFfmpegBitrateKbps(stderr);
520
+ if (!(width > 0) || !(height > 0) || !(rate > 0) || !(seconds > 0) || !(kbps > 0)) {
521
+ return null;
522
+ }
523
+ return {
524
+ megapixelsPerSecond: (width * height * rate) / 1e6,
525
+ megabitsPerSecond: kbps / 1000,
526
+ durationSeconds: seconds
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Measure what DECODING costs on this host, as seconds of work per second of
532
+ * video, and solve it into three host constants:
533
+ *
534
+ * decodeCost = a × Mpixel/s + b × Mbit/s + c
535
+ *
536
+ * Why it exists: the preset benchmark below measures ENCODING only, and a
537
+ * re-encode pays for both halves. Measured 2026-08-14 on the addon host, that
538
+ * omission made the budget offer a 240p rung it then ran at 0.39-0.95× — the
539
+ * benchmark said the host cleared the bar 2.5× over. With the decode term the
540
+ * same file predicts within 4.8 %; without it the error on that rung was 209 %.
541
+ *
542
+ * The constants are properties of the HOST, so this runs once at startup (about
543
+ * 5 s on a CM4) and any source is then priced from figures the probe already
544
+ * has — nothing is added to a session's cold start.
545
+ *
546
+ * They are also properties of the CODEC, and the clips are H.264: HEVC, AV1 and
547
+ * 10-bit decode dearer per pixel on the same machine, and a source that has to
548
+ * be re-encoded is by definition one this browser could not play, which is
549
+ * usually not H.264. So the fit is optimistic exactly there. Closing that needs
550
+ * clips in those codecs, and is its own roadmap item.
551
+ *
552
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
553
+ * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
554
+ */
555
+ /**
556
+ * What a second job costs on this host, measured rather than assumed.
557
+ *
558
+ * The budget adds seconds of work per second of content — this encode, plus
559
+ * that decode, plus what is already committed — and the addon host contradicted
560
+ * that directly on 2026-08-18: decoding ran at 2.10-2.25x alone, 0.79-0.90x
561
+ * with one encoder beside it and 0.56-0.64x with two. The same work costs 2.6×
562
+ * more for having company. Heat is not the cause (the hot idle machine was the
563
+ * fastest reading of all); four cores sharing one path to memory is.
564
+ *
565
+ * So it is measured the way everything else here is: the same clip decoded
566
+ * alone, then decoded again while an encoder of the same clip runs beside it.
567
+ * The ratio is the penalty. The encoder is stopped as soon as the reading is
568
+ * taken, and the whole thing costs one decode plus one short encode.
569
+ *
570
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string, upTo?: number }} options
571
+ * @returns {Promise<Map<number, number> | null>} Penalties by how many other
572
+ * jobs were running, or null when the readings could not be taken.
573
+ */
574
+ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, upTo = 2 }) {
575
+ const log = logger ?? { info: () => {}, warn: () => {} };
576
+ // The cheapest clip in the set: this measures the MACHINE's behaviour under
577
+ // company, not the clip's own cost, so the smallest one says it soonest.
578
+ const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
579
+ const startedAt = Date.now();
580
+ // Lifted once and decoded three times from the same bytes. Going through
581
+ // `measureDecodeSlope` lifted it again for every reading — three process
582
+ // starts on a path that is awaited before the proxy's tunnel opens, for a
583
+ // remux whose result had not changed.
584
+ const streams = await extractFamilyStreams(ffmpegBin, [clip], "h264");
585
+ const stream = streams?.[0];
586
+ if (!stream) {
587
+ log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
588
+ return null;
589
+ }
590
+ const alone = await decodePipedStream(ffmpegBin, stream, log);
591
+ if (!alone?.speed) {
592
+ log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
593
+ return null;
594
+ }
595
+ /** @type {Array<{ others: number, speed: number }>} */
596
+ const beside = [];
597
+ /** @type {import("node:child_process").ChildProcess[]} */
598
+ const load = [];
599
+ try {
600
+ for (let others = 1; others <= Math.max(1, upTo); others += 1) {
601
+ load.push(
602
+ spawn(
603
+ ffmpegBin,
604
+ [
605
+ "-hide_banner", "-loglevel", "error", "-nostats",
606
+ "-stream_loop", "-1", "-i", clip,
607
+ "-an", "-c:v", "libx264", "-preset", "fast", "-f", "null", "-"
608
+ ],
609
+ { stdio: ["ignore", "ignore", "ignore"], windowsHide: true }
610
+ )
611
+ );
612
+ // Let the encoder reach its own speed before reading anything: an encode
613
+ // measured in its first moments is measuring the process starting.
614
+ await new Promise((resolve) => {
615
+ setTimeout(resolve, 2_000);
616
+ });
617
+ const withCompany = await decodePipedStream(ffmpegBin, stream, log);
618
+ if (withCompany?.speed) {
619
+ beside.push({ others, speed: withCompany.speed });
620
+ }
621
+ }
622
+ } finally {
623
+ for (const child of load) {
624
+ try {
625
+ child.kill("SIGKILL");
626
+ } catch {
627
+ // Already gone: the reading is what mattered, and nothing else uses it.
628
+ }
629
+ }
630
+ }
631
+ const penalties = penaltiesFrom(alone.speed, beside);
632
+ if (!penalties) {
633
+ log.warn("hwaccel: contention readings said nothing; costs will be added as though jobs were independent");
634
+ return null;
635
+ }
636
+ log.info(
637
+ `hwaccel: a second job costs ${[...penalties.entries()]
638
+ .map(([others, penalty]) => `${penalty.toFixed(2)}x beside ${others}`)
639
+ .join(", ")} ` +
640
+ `(decode alone ${alone.speed.toFixed(2)}x, ` +
641
+ `${beside.map((reading) => `${reading.speed.toFixed(2)}x beside ${reading.others}`).join(", ")}, ` +
642
+ `measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
643
+ );
644
+ return penalties;
645
+ }
646
+
647
+ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
648
+ const log = logger ?? { info: () => {}, warn: () => {} };
649
+ const startedAllAt = Date.now();
650
+ /** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
651
+ const families = {};
652
+ for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
653
+ const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
654
+ if (fitted) {
655
+ families[family] = fitted;
656
+ }
657
+ }
658
+ if (!families.h264) {
659
+ // H.264 is the family every other one falls back to, so without it there is
660
+ // no model at all rather than a partial one. Which families DID fit is said
661
+ // anyway: on a fast host the H.264 clips decode at 20-80x and the readings
662
+ // stop being ordered — measured 2026-08-20 on a desktop, 1080p at 9.35
663
+ // Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is
664
+ // not a thing a decoder does — so a failure here is a measurement problem
665
+ // and not a missing file, and the line has to let those be told apart.
666
+ log.warn(
667
+ "hwaccel: decode cost unknown — the H.264 clips did not fit" +
668
+ (Object.keys(families).length > 0
669
+ ? `, though ${Object.keys(families).join(" and ")} did`
670
+ : "")
671
+ );
672
+ return null;
673
+ }
674
+ const missing = Object.keys(CALIBRATION_SETS).filter((family) => !families[family]);
675
+ log.info(
676
+ `hwaccel: decode cost measured for ${Object.keys(families).join(", ")}` +
677
+ (missing.length > 0 ? `; ${missing.join(" and ")} priced as H.264` : "") +
678
+ ` (in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
679
+ );
680
+ return { families, ...families.h264 };
681
+ }
682
+
683
+ /**
684
+ * Fit one codec family's decode cost from its own clips.
685
+ *
686
+ * @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
687
+ * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
688
+ */
689
+ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
690
+ const startedAllAt = Date.now();
691
+ /** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
692
+ const samples = [];
693
+ // Every clip of the family is lifted out of its container FIRST, in one
694
+ // ffmpeg run. See `extractFamilyStreams` for why one run rather than one per
695
+ // clip, and why before the measurements rather than beside them.
696
+ const streams = await extractFamilyStreams(
697
+ ffmpegBin,
698
+ clips.map((clip) => path.join(clipsDir, clip)),
699
+ family
700
+ );
701
+ if (!streams) {
702
+ log.warn(
703
+ `hwaccel: ${family} cannot be lifted out of its container — no Annex-B filter is mapped for it, ` +
704
+ `so its clips were never measured`
705
+ );
706
+ return null;
707
+ }
708
+ for (const [index, clip] of clips.entries()) {
709
+ const stream = streams[index];
710
+ const measured = stream ? await decodePipedStream(ffmpegBin, stream, log) : null;
711
+ if (!measured?.speed) {
712
+ log.warn(
713
+ `hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
714
+ (measured?.error ? ` — ${measured.error}` : " — the clip could not be lifted out of its container")
715
+ );
716
+ return null;
717
+ }
718
+ const cost = 1 / measured.speed;
719
+ samples.push({
720
+ megapixelsPerSecond: measured.megapixelsPerSecond,
721
+ megabitsPerSecond: measured.megabitsPerSecond,
722
+ costSecondsPerSecond: cost
723
+ });
724
+ log.info(
725
+ `hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
726
+ `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
727
+ `(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
728
+ );
729
+ }
730
+ const fitted = fitDecodeCost(samples);
731
+ if (!fitted) {
732
+ log.warn(`hwaccel: ${family} decode cost could not be fitted to these measurements`);
733
+ return null;
734
+ }
735
+ log.info(
736
+ `hwaccel: ${family} decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
737
+ `+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape} from ${fitted.samples} clips, ` +
738
+ `typical disagreement ${fitted.residualRms.toFixed(4)} s/s` +
739
+ // Named rather than implied: a zero in the line above means "not
740
+ // measured" for a dropped term and "measured to be nothing" otherwise,
741
+ // and those are different claims.
742
+ (fitted.dropped.length > 0 ? `, ${fitted.dropped.join(" and ")} not determined by these clips` : "") +
743
+ `, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
744
+ );
745
+ return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
746
+ }
747
+
748
+ /**
749
+ * The bitstream filter and demuxer that turn a clip's video track into a
750
+ * continuous elementary stream, by codec family.
751
+ *
752
+ * H.264 and HEVC in MP4 keep their parameter sets in the container's `avcC` /
753
+ * `hvcC` and their access units length-prefixed; Annex-B carries them inline,
754
+ * with start codes, which is what makes plain byte concatenation a valid
755
+ * stream. That is the property this whole measurement rests on.
756
+ */
757
+ const ANNEX_B_BY_FAMILY = {
758
+ h264: { filter: "h264_mp4toannexb", demuxer: "h264" },
759
+ hevc: { filter: "hevc_mp4toannexb", demuxer: "hevc" },
760
+ hevc10: { filter: "hevc_mp4toannexb", demuxer: "hevc" }
761
+ };
762
+
763
+ /**
764
+ * The last complaint in an ffmpeg stderr, for a line that has to say why.
765
+ *
766
+ * @param {string} stderr
767
+ * @returns {string}
768
+ */
769
+ function lastErrorLine(stderr) {
770
+ const lines = String(stderr ?? "")
771
+ .split(/\r?\n/)
772
+ .map((line) => line.trim())
773
+ .filter((line) => line.length > 0);
774
+ return lines[lines.length - 1] ?? "";
775
+ }
776
+
777
+ /**
778
+ * How long the lift may take before it is abandoned. It is a remux of a few
779
+ * megabytes, so this is not a budget — it is the difference between a startup
780
+ * that reports a failure and one that never finishes. Every other ffmpeg run in
781
+ * this file has such a bound; this one did not, and it is awaited before the
782
+ * proxy's tunnel opens.
783
+ */
784
+ const EXTRACT_TIMEOUT_MS = 20_000;
785
+
786
+ /**
787
+ * Lift a whole family's clips out of their containers, as Annex-B elementary
788
+ * streams, in ONE ffmpeg run.
789
+ *
790
+ * No re-encoding — the frames are copied — so the work itself is trivial and
791
+ * the cost is almost entirely the process. Doing one process per clip added
792
+ * 11 s to the startup here (fourteen clips at about 0.83 s each), and running
793
+ * them concurrently did not help: six at once took 4.75 s against 0.89 s for
794
+ * one, so the machine serialises them. One run with many inputs and many
795
+ * outputs costs one process.
796
+ *
797
+ * The outputs go to temporary files because several outputs cannot share one
798
+ * pipe; they are read into memory and deleted immediately, and nothing about
799
+ * this measurement is kept between runs.
800
+ *
801
+ * Before the measurements, never beside them: a remux running next to a decode
802
+ * is a second job on the machine, and this benchmark exists to find out what
803
+ * ONE job costs here.
804
+ *
805
+ * @param {string} ffmpegBin
806
+ * @param {string[]} clipPaths
807
+ * @param {string} family
808
+ * @returns {Promise<Array<{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number } | null> | null>}
809
+ * One entry per clip, in order; null when the family cannot be lifted at all.
810
+ */
811
+ async function extractFamilyStreams(ffmpegBin, clipPaths, family) {
812
+ const shape = ANNEX_B_BY_FAMILY[family];
813
+ // A family with no mapping is a hard failure, not a silent fallback to
814
+ // H.264's filter. AV1 has no Annex-B form at all (its packaging is OBU), and
815
+ // MPEG-2 and VC-1 have no `*_mp4toannexb` filter — so the three families the
816
+ // roadmap plans next cannot come through here, and finding that out as
817
+ // "the clip failed" would send the reader after the clip.
818
+ if (!shape) {
819
+ return null;
820
+ }
821
+ const workDir = await mkdtemp(path.join(os.tmpdir(), "ttv-calibration-"));
822
+ const outputs = clipPaths.map((_, index) => path.join(workDir, `stream-${index}.${shape.demuxer}`));
823
+ /** @type {string[]} */
824
+ const args = ["-hide_banner", "-loglevel", "info", "-nostats", "-y"];
825
+ for (const clipPath of clipPaths) {
826
+ args.push("-i", clipPath);
827
+ }
828
+ for (const [index, output] of outputs.entries()) {
829
+ args.push("-map", `${index}:v:0`, "-c:v", "copy", "-bsf:v", shape.filter, "-f", shape.demuxer, output);
830
+ }
831
+ const stderr = await runCapturingStderr(ffmpegBin, args, EXTRACT_TIMEOUT_MS);
832
+ try {
833
+ if (stderr === null) {
834
+ return null;
835
+ }
836
+ // One banner block per input, in the order they were given. Read rather
837
+ // than declared, so replacing a clip cannot silently invalidate the fit
838
+ // that rests on it.
839
+ const blocks = splitInputBlocks(stderr, clipPaths.length);
840
+ return await Promise.all(clipPaths.map(async (_, index) => {
841
+ const block = blocks[index];
842
+ if (!block) {
843
+ return null;
844
+ }
845
+ const clipInfo = parseClipCharacteristics(block);
846
+ const fps = parseFfmpegVideoFps(block);
847
+ if (!clipInfo || !(fps > 0)) {
848
+ return null;
849
+ }
850
+ let bytes;
851
+ try {
852
+ bytes = await readFile(outputs[index]);
853
+ } catch {
854
+ return null;
855
+ }
856
+ if (bytes.length === 0) {
857
+ return null;
858
+ }
859
+ return {
860
+ bytes,
861
+ demuxer: shape.demuxer,
862
+ megapixelsPerSecond: clipInfo.megapixelsPerSecond,
863
+ megabitsPerSecond: clipInfo.megabitsPerSecond,
864
+ fps
865
+ };
866
+ }));
867
+ } finally {
868
+ await rm(workDir, { recursive: true, force: true }).catch(() => {});
869
+ }
870
+ }
871
+
872
+ /**
873
+ * The part of an ffmpeg banner describing each input, in order.
874
+ *
875
+ * ffmpeg prints one `Input #N, …` block per input and then the stream mapping;
876
+ * the parsers here read a single input's facts, so they are given a single
877
+ * input's text rather than the whole banner.
878
+ *
879
+ * @param {string} stderr
880
+ * @param {number} count
881
+ * @returns {string[]}
882
+ */
883
+ function splitInputBlocks(stderr, count) {
884
+ /** @type {string[]} */
885
+ const blocks = [];
886
+ for (let index = 0; index < count; index += 1) {
887
+ const from = stderr.indexOf(`Input #${index},`);
888
+ if (from < 0) {
889
+ blocks.push("");
890
+ continue;
891
+ }
892
+ const nextInput = stderr.indexOf(`Input #${index + 1},`, from);
893
+ const mapping = stderr.indexOf("Stream mapping:", from);
894
+ const ends = [nextInput, mapping].filter((at) => at > from);
895
+ blocks.push(stderr.slice(from, ends.length > 0 ? Math.min(...ends) : stderr.length));
896
+ }
897
+ return blocks;
898
+ }
899
+
900
+ /**
901
+ * Run ffmpeg to completion and return its stderr, or null when it failed or
902
+ * outlasted its bound.
903
+ *
904
+ * @param {string} ffmpegBin
905
+ * @param {string[]} args
906
+ * @param {number} timeoutMs
907
+ * @returns {Promise<string | null>}
908
+ */
909
+ function runCapturingStderr(ffmpegBin, args, timeoutMs) {
910
+ return new Promise((resolve) => {
911
+ let stderr = "";
912
+ let settled = false;
913
+ let child;
914
+ const settle = (value) => {
915
+ if (settled) {
916
+ return;
917
+ }
918
+ settled = true;
919
+ clearTimeout(timer);
920
+ try {
921
+ child?.kill("SIGKILL");
922
+ } catch {
923
+ // already gone
924
+ }
925
+ resolve(value);
926
+ };
927
+ const timer = setTimeout(() => settle(null), timeoutMs);
928
+ try {
929
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
930
+ } catch {
931
+ settle(null);
932
+ return;
933
+ }
934
+ child.stderr.on("data", (chunk) => {
935
+ stderr += String(chunk);
936
+ });
937
+ child.on("error", () => settle(null));
938
+ child.on("close", (code) => settle(code === 0 ? stderr : null));
939
+ });
940
+ }
941
+
942
+ /**
943
+ * Measure how fast this host DECODES a clip, from ffmpeg's own report of how
944
+ * much video it has processed.
945
+ *
946
+ * Two things are deliberately outside the measurement.
947
+ *
948
+ * **The process starting.** Wall-clock around the process cannot answer this:
949
+ * starting ffmpeg costs about a second, and on a quick machine a five-second
950
+ * clip decodes in a tenth of that, so the measurement would be of the program
951
+ * starting. Progress lines arrive AFTER it has started, and the slope between
952
+ * two of them — video processed against time taken — contains no part of the
953
+ * startup by construction.
954
+ *
955
+ * **The clip restarting.** This used to loop the clip with `-stream_loop -1`,
956
+ * and a loop is not free: measured 2026-08-22 on a desktop, a restart costs
957
+ * 0.03 s on the 480p clip and 0.12 s on the 1080p one — the decoder tearing
958
+ * down and re-allocating its frame buffers, which is why the price rises with
959
+ * the picture. A five-second clip decoded at 55x restarts eleven times a
960
+ * second, so that cost DOMINATED the reading: the same clips measured 53.7x
961
+ * looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse,
962
+ * the bias is not shared — it depends on the clip's own resolution and on how
963
+ * fast the host is — so it does not cancel out of the fit, it tilts it. That is
964
+ * the fast-host failure recorded on 2026-08-20, where 1080p read cheaper than
965
+ * 720p, which is not a thing a decoder does.
966
+ *
967
+ * So the clip is fed to the decoder as ONE stream instead. An Annex-B
968
+ * elementary stream carries its parameter sets inline, so writing the same
969
+ * bytes again is simply more stream — the decoder never re-initialises, and
970
+ * there is no restart inside the window to measure. Verified against the
971
+ * continuous-pass truth on the same host: -0.2 % and -5.5 %, against -25 % and
972
+ * -33 % for the loop. Nothing is written to disk and the process is killed as
973
+ * soon as the window is wide enough.
974
+ *
975
+ * Exported because the property that broke here is checkable and was not being
976
+ * checked: a bigger picture must cost more than a smaller one of the same
977
+ * bitrate, and under the loop it did not.
978
+ *
979
+ * @param {string} ffmpegBin
980
+ * @param {string} clipPath
981
+ * @param {string} [family="h264"]
982
+ * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
983
+ */
984
+ export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
985
+ const streams = await extractFamilyStreams(ffmpegBin, [clipPath], family);
986
+ const stream = streams?.[0];
987
+ if (!stream) {
988
+ return null;
989
+ }
990
+ const measured = await decodePipedStream(ffmpegBin, stream);
991
+ return measured?.speed ? measured : null;
992
+ }
993
+
994
+ /**
995
+ * Decode an elementary stream fed from memory, and report the slope.
996
+ *
997
+ * @param {string} ffmpegBin
998
+ * @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
999
+ * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
1000
+ */
1001
+ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }) {
1002
+ return new Promise((resolve) => {
1003
+ const args = [
1004
+ "-hide_banner", "-loglevel", "error", "-nostats",
1005
+ // A raw stream states no frame rate, so the one the container declared is
1006
+ // given back to it. It decides how output time advances, and therefore
1007
+ // what "seconds of video per second of clock" means.
1008
+ "-f", stream.demuxer, "-framerate", String(stream.fps), "-i", "pipe:0",
1009
+ "-an", "-f", "null", "-",
1010
+ "-progress", "pipe:1"
1011
+ ];
1012
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
1013
+ const samples = [];
1014
+ let stdout = "";
1015
+ // Kept because this path depends on three things the old one did not: the
1016
+ // raw demuxer accepting the frame rate, the bitstream filter having
1017
+ // produced something parsable, and the fed concatenation being decodable.
1018
+ // Without it the only trace of any of those failing is "said nothing".
1019
+ let stderr = "";
1020
+ let settled = false;
1021
+ let child;
1022
+ const startedAt = Date.now();
1023
+ // Whether the FEED, not the decoder, could be what this reading measures
1024
+ // (item 4(d2)). `child.stdin.write()` returning false was tried as the
1025
+ // signal — never once true would mean this process was never ahead of the
1026
+ // pipe — and measured false on every reading taken while writing this,
1027
+ // including clips this same host decodes at 15-80x with room to spare, so
1028
+ // it does not discriminate: `write()`'s return value tracks Node's own
1029
+ // internal watermark against the size of what was just handed to it, not
1030
+ // real drain state, and answered "no slack" identically whether the pipe
1031
+ // or the decoder was the true limit. Rather than publish a verdict that
1032
+ // reads the same in both cases, only the byte count is kept, for the
1033
+ // MB/s figure below — a number to read, not a boolean to trust.
1034
+ let bytesWritten = 0;
1035
+ const finish = () => {
1036
+ if (settled) {
1037
+ return;
1038
+ }
1039
+ settled = true;
1040
+ clearTimeout(timer);
1041
+ try {
1042
+ child?.stdin?.destroy();
1043
+ } catch {
1044
+ // already gone
1045
+ }
1046
+ try {
1047
+ child?.kill("SIGKILL");
1048
+ } catch {
1049
+ // already gone
1050
+ }
1051
+ // The first sample still carries the startup — it reports whatever was
1052
+ // processed while the process was coming up. Everything is measured from
1053
+ // the second onwards.
1054
+ const first = samples[1];
1055
+ const last = samples[samples.length - 1];
1056
+ if (!first || !last) {
1057
+ resolve({ error: lastErrorLine(stderr) || "the decoder reported no progress" });
1058
+ return;
1059
+ }
1060
+ const windowSec = last.wallSec - first.wallSec;
1061
+ const producedSec = last.outSec - first.outSec;
1062
+ if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
1063
+ resolve({ error: lastErrorLine(stderr) || `the window was ${windowSec.toFixed(2)}s of ${producedSec.toFixed(2)}s produced` });
1064
+ return;
1065
+ }
1066
+ const speed = producedSec / windowSec;
1067
+ // Diagnostic only — logged, not acted on. Both figures are taken over the
1068
+ // SAME window the speed itself is: bytesWritten is snapshotted alongside
1069
+ // every progress sample, so this compares like against like rather than
1070
+ // the achieved rate over the whole run (which starts before the first
1071
+ // kept sample and reads systematically low against the window's own
1072
+ // rate for no reason but that mismatch — measured while building this).
1073
+ const windowBytes = last.bytesWritten - first.bytesWritten;
1074
+ const achievedMBps = windowSec > 0 ? windowBytes / windowSec / 1e6 : 0;
1075
+ const requiredMBps = ((stream.megabitsPerSecond * 1e6) / 8) * speed / 1e6;
1076
+ // "Far apart" is stated, not left to the reader to eyeball: outside a
1077
+ // factor of 1.5 either way is bigger than the write-timing slop this
1078
+ // comparison carries on a healthy reading.
1079
+ const farApart = requiredMBps > 0 && (achievedMBps / requiredMBps < 1 / 1.5 || achievedMBps / requiredMBps > 1.5);
1080
+ log.info(
1081
+ `hwaccel: decode pipe fed ${achievedMBps.toFixed(1)} MB/s, ${requiredMBps.toFixed(1)} MB/s ` +
1082
+ `needed for ${speed.toFixed(2)}x` +
1083
+ (farApart ? " — far enough apart to be worth a second look" : "")
1084
+ );
1085
+ resolve({
1086
+ speed,
1087
+ windowSec,
1088
+ megapixelsPerSecond: stream.megapixelsPerSecond,
1089
+ megabitsPerSecond: stream.megabitsPerSecond,
1090
+ pipeThroughputMBps: achievedMBps
1091
+ });
1092
+ };
1093
+ const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
1094
+ try {
1095
+ child = spawn(ffmpegBin, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1096
+ } catch (error) {
1097
+ clearTimeout(timer);
1098
+ settled = true;
1099
+ resolve({ error: error instanceof Error ? error.message : String(error) });
1100
+ return;
1101
+ }
1102
+ child.stderr.on("data", (chunk) => {
1103
+ stderr += String(chunk);
1104
+ });
1105
+ // Keep the decoder fed. `write` returning false means the pipe is full, and
1106
+ // the next copy goes on the `drain` — so the decoder is never starved and
1107
+ // this process never buffers more than the pipe holds.
1108
+ const writeOnce = () => {
1109
+ const accepted = child.stdin.write(stream.bytes);
1110
+ bytesWritten += stream.bytes.length;
1111
+ return accepted;
1112
+ };
1113
+ const feed = () => {
1114
+ while (!settled && child.stdin.writable && writeOnce()) {
1115
+ // Written straight through; go round again.
1116
+ }
1117
+ };
1118
+ child.stdin.on("drain", feed);
1119
+ // The kill closes the pipe under the writer; that is the intended end.
1120
+ child.stdin.on("error", () => {});
1121
+ child.stdout.on("data", (chunk) => {
1122
+ stdout += String(chunk);
1123
+ let newline = stdout.indexOf("\n");
1124
+ while (newline >= 0) {
1125
+ const line = stdout.slice(0, newline).trim();
1126
+ stdout = stdout.slice(newline + 1);
1127
+ if (line.startsWith("out_time_ms=")) {
1128
+ const microseconds = Number(line.slice("out_time_ms=".length));
1129
+ if (Number.isFinite(microseconds)) {
1130
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6, bytesWritten });
1131
+ }
1132
+ }
1133
+ newline = stdout.indexOf("\n");
1134
+ }
1135
+ if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
1136
+ finish();
1137
+ }
1138
+ });
1139
+ child.on("error", (error) => {
1140
+ if (settled) {
1141
+ return;
1142
+ }
1143
+ clearTimeout(timer);
1144
+ settled = true;
1145
+ try {
1146
+ child?.stdin?.destroy();
1147
+ } catch {
1148
+ // already gone
1149
+ }
1150
+ try {
1151
+ child?.kill("SIGKILL");
1152
+ } catch {
1153
+ // already gone
1154
+ }
1155
+ resolve({ error: error instanceof Error ? error.message : String(error) });
1156
+ });
1157
+ child.on("close", finish);
1158
+ feed();
1159
+ });
1160
+ }
1161
+
1162
+
1163
+ /**
1164
+ * How many times realtime this host can DECODE a source of these
1165
+ * characteristics, from the startup fit. `null` when the fit is unavailable or
1166
+ * the source figures are not known.
1167
+ *
1168
+ * @param {{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null} model
1169
+ * @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
1170
+ * @returns {number | null}
1171
+ */
1172
+ export function decodeSpeedFor(model, source) {
1173
+ if (!model) {
1174
+ return null;
1175
+ }
1176
+ const pixels = Number(source?.megapixelsPerSecond);
1177
+ const bits = Number(source?.megabitsPerSecond);
1178
+ if (!Number.isFinite(pixels) || pixels <= 0 || !Number.isFinite(bits) || bits < 0) {
1179
+ return null;
1180
+ }
1181
+ const cost = model.pixelTerm * pixels + model.bitrateTerm * bits + model.constantTerm;
1182
+ if (!(cost > 0)) {
1183
+ return null;
1184
+ }
1185
+ return 1 / cost;
1186
+ }
1187
+
1188
+ /**
1189
+ * How many times realtime a re-encode of this source at this output pixel rate
1190
+ * would run: decoding and encoding share the machine, so their costs add and
1191
+ * their speeds combine as
1192
+ *
1193
+ * 1 / (1/decodeSpeed + 1/encodeSpeed)
1194
+ *
1195
+ * Checked 2026-08-14 on the rung that broke playback: 1/(1/2.31 + 1/5.99) =
1196
+ * 1.67× against 1.48× measured. With no decode fit this falls back to the
1197
+ * encode speed alone — which is what the budget did before, and which
1198
+ * overestimated that rung five to eleven times.
1199
+ *
1200
+ * @param {{ decodeModel: { pixelTerm: number, bitrateTerm: number, constantTerm: number } | null, encodePixelsPerSec: number, outputPixelsPerSec: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1201
+ * @returns {number | null}
1202
+ */
1203
+ export function predictedRealtimeSpeed({
1204
+ decodeModel,
1205
+ encodePixelsPerSec,
1206
+ outputPixelsPerSec,
1207
+ source,
1208
+ observedDecodeCostSec = null
1209
+ }) {
1210
+ if (!Number.isFinite(encodePixelsPerSec) || encodePixelsPerSec <= 0) {
1211
+ return null;
1212
+ }
1213
+ if (!Number.isFinite(outputPixelsPerSec) || outputPixelsPerSec <= 0) {
1214
+ return null;
1215
+ }
1216
+ const encodeSpeed = encodePixelsPerSec / outputPixelsPerSec;
1217
+ // What this very file has been seen to cost, when it has been: the clips are
1218
+ // H.264 and a source that has to be re-encoded usually is not, so a figure
1219
+ // taken from the encoder actually running on THIS source beats any model of
1220
+ // a stand-in. It arrives seconds into playback and replaces the estimate.
1221
+ const decodeSpeed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1222
+ ? 1 / observedDecodeCostSec
1223
+ : (source ? decodeSpeedFor(decodeModel, source) : null);
1224
+ if (decodeSpeed === null) {
1225
+ return encodeSpeed;
1226
+ }
1227
+ return 1 / (1 / decodeSpeed + 1 / encodeSpeed);
1228
+ }
1229
+
1230
+ /**
1231
+ * Whether this host can hold realtime, with the margin, while re-encoding this
1232
+ * source to this output pixel rate — and the predicted speed either way, so a
1233
+ * refusal can say what it refused on.
1234
+ *
1235
+ * The encoder figure is the FASTEST benchmarked preset: it is the best this
1236
+ * host can do, so a rung it cannot hold cannot be held at any quality setting.
1237
+ *
1238
+ * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number, requiredSpeed?: number | null }} params
1239
+ * @returns {{ speed: number | null, sustainable: boolean }}
1240
+ */
1241
+ export function canSustainOutput({
1242
+ benchmark,
1243
+ decodeModel = null,
1244
+ source = null,
1245
+ outputPixelsPerSec,
1246
+ observedDecodeCostSec = null,
1247
+ concurrentCostSec = 0,
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
1253
+ }) {
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.
1258
+ return { speed: null, sustainable: true };
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
+ }
1271
+ const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1272
+ ? observedDecodeCostSec
1273
+ : null;
1274
+ if (observed === null && !isDecodePriced({ decodeModel, source })) {
1275
+ // An encoder-only figure was several times too optimistic on the rung this
1276
+ // check exists for, so it is not fit to refuse anything. Without the decode
1277
+ // term the ladder is offered whole, exactly as it was before.
1278
+ return { speed: null, sustainable: true };
1279
+ }
1280
+ const alone = predictedRealtimeSpeed({
1281
+ decodeModel,
1282
+ encodePixelsPerSec,
1283
+ outputPixelsPerSec,
1284
+ source,
1285
+ observedDecodeCostSec: observed
1286
+ });
1287
+ // What ELSE will be running while this rung is. A rung is never the only
1288
+ // thing on the machine: the picture it accompanies is being copied or
1289
+ // encoded, an audio track may have its own encoder, and a warm-up is two
1290
+ // encoders by design. Measured on the addon host, a copy alone takes about an
1291
+ // eighth of the machine per second of video, and the field case of
1292
+ // 2026-08-15 adds up exactly: 0.125 for the copy plus ~1.05 for the rung is
1293
+ // more than the one second per second the machine has, which is what was
1294
+ // observed.
1295
+ //
1296
+ // Zero when nothing else is known to be running, or when nothing has been
1297
+ // measured yet — then this is a LOWER bound on the cost and the check is as
1298
+ // permissive as it was before.
1299
+ const speed = alone === null || !(concurrentCostSec > 0)
1300
+ ? alone
1301
+ : 1 / (1 / alone + concurrentCostSec);
1302
+ if (speed === null) {
1303
+ return { speed: null, sustainable: true };
1304
+ }
1305
+ return { speed, sustainable: speed >= speedBar(requiredSpeed) };
1306
+ }
1307
+
1308
+ /**
1309
+ * The speed a step has to reach to be worth offering.
1310
+ *
1311
+ * Realtime is not enough on its own: a step that produces exactly one second
1312
+ * per second never recovers the seconds lost while its reader waits for the
1313
+ * swarm, so it survives its own supply only if what it gains between
1314
+ * interruptions covers what one interruption costs. That is measured per file
1315
+ * and per swarm by the reader — `1 + worst wait / median interval`, in
1316
+ * `torrent/supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1317
+ * against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
1318
+ * swarm no encoder could have kept up with.
1319
+ *
1320
+ * Where that figure does not exist yet — fewer than two interruptions measured
1321
+ * — the bar is realtime. It is the one thing that can be said without
1322
+ * measuring the swarm, and the offer is restated as soon as the reader has
1323
+ * something to say.
1324
+ *
1325
+ * @param {number | null | undefined} requiredSpeed - What this file's own
1326
+ * interruptions demand, when they have been measured.
1327
+ * @returns {number}
1328
+ */
1329
+ export function speedBar(requiredSpeed) {
1330
+ return Number.isFinite(requiredSpeed) && requiredSpeed > REALTIME ? requiredSpeed : REALTIME;
1331
+ }
1332
+
1333
+ /**
1334
+ * The bar for a cost description — the supply's demand where decoding is
1335
+ * priced, and never below the unpriced-decode bar where it is not.
1336
+ *
1337
+ * @param {{ decodeModel?: object | null, source?: object | null, observedDecodeCostSec?: number | null, requiredSpeed?: number | null }} cost
1338
+ * @returns {number}
1339
+ */
1340
+ function barFor(cost) {
1341
+ const measured = speedBar(cost?.requiredSpeed);
1342
+ return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
1343
+ }
1344
+
1345
+ /**
1346
+ * Benchmark software libx264 presets on this host. Encodes a short synthetic
1347
+ * clip at a fixed reference resolution with each preset and measures encoder
1348
+ * throughput in pixels/second. The session manager uses this to pick, per
1349
+ * stream, the highest-quality preset that still encodes the actual
1350
+ * (source-capped) resolution faster than realtime.
1351
+ *
1352
+ * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
1353
+ * omitted from the result.
1354
+ *
1355
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
1356
+ * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
1357
+ */
1358
+ export async function benchmarkSoftwarePresets({ ffmpegBin, logger, encoder = null }) {
1359
+ const log = logger ?? { info: () => {}, warn: () => {} };
1360
+
1361
+ // REAL footage, decoded ONCE into raw frames, and the presets are then timed
1362
+ // on those frames.
1363
+ //
1364
+ // Two reasons, both measured. The pattern this replaced (`testsrc2`) has flat
1365
+ // areas and no grain and encodes 1.23x cheaper than film on the same machine
1366
+ // and preset — an error that always points at offering a rung the host cannot
1367
+ // hold. And feeding a compressed clip to each preset instead would put
1368
+ // decoding and scaling inside the measurement: subtracting them afterwards
1369
+ // compares a wall clock that includes process startup against a decode figure
1370
+ // measured to exclude it, while inside one ffmpeg the two halves overlap. On
1371
+ // the fastest preset — the one every ladder decision reads as the ceiling —
1372
+ // that subtraction is most of the number being measured, so a small error in
1373
+ // it becomes a large error in the answer.
1374
+ //
1375
+ // Raw frames remove all of it: no decoder, no scaler, nothing to subtract,
1376
+ // and no dependence on the decode model. The cost is 25 MB of memory in a
1377
+ // pipe for a few seconds.
1378
+ const rawFramesPath = await decodeToRawFrames(ffmpegBin, log);
1379
+ if (rawFramesPath === null) {
1380
+ // Said once more, in the words that matter to whoever reads the log next:
1381
+ // with no benchmark, `#sustainableHeights` filters nothing and every rung
1382
+ // is offered, which is the failure of 2026-08-14 in full.
1383
+ log.warn("hwaccel: the quality ladder is UNFILTERED on this host — nothing measured the encoder");
1384
+ return [];
1385
+ }
1386
+ /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
1387
+ const results = [];
1388
+ try {
1389
+ // THE CHOSEN ENCODER'S OWN LADDER, whatever kind it is. NVENC walks p1…p7,
1390
+ // QSV veryfast…veryslow, VAAPI its quality levels. A kind with no ladder is
1391
+ // measured once, which is still a reading where there was none at all.
1392
+ const ladder = encoder?.speedLadder;
1393
+ const rungs = Array.isArray(ladder?.values) && ladder.values.length > 0 ? ladder.values : [null];
1394
+ for (const rung of rungs) {
1395
+ const speed = await measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath);
1396
+ if (speed === null) {
1397
+ log.warn(`hwaccel: the benchmark of "${rung ?? encoder?.name}" produced no usable reading; skipping`);
1398
+ continue;
1399
+ }
1400
+ const pixelsPerSec = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * speed;
1401
+ results.push({ preset: rung ?? encoder?.name, pixelsPerSec });
1402
+ log.info(
1403
+ `hwaccel: ${encoder?.name} "${rung ?? "as it comes"}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
1404
+ `(${speed.toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H}, real footage)`
1405
+ );
1406
+ }
1407
+ } finally {
1408
+ // The encoder was killed a moment ago and on Windows the handle outlives
1409
+ // the signal, so removal is retried and its failure is not worth a session:
1410
+ // this is a temp directory the operating system will clear anyway.
1411
+ try {
1412
+ rmSync(path.dirname(rawFramesPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1413
+ } catch (error) {
1414
+ log.warn(`hwaccel: could not remove the benchmark's raw frames: ${error instanceof Error ? error.message : String(error)}`);
1415
+ }
1416
+ }
1417
+ return results;
1418
+ }
1419
+
1420
+ /**
1421
+ * How fast one preset encodes, from ffmpeg's own reports of how much video it
1422
+ * has written — not from the clock around the process.
1423
+ *
1424
+ * Timing whole runs measures the run STARTING. Measured 2026-08-15 on a desktop
1425
+ * that spawns ffmpeg in ~0.4 s: three seconds of raw frames encoded that way
1426
+ * put `fast` and `ultrafast` within 1.24x of each other, when libx264's own
1427
+ * presets differ by several times — the constant had swallowed the difference.
1428
+ * The slope between two progress reports contains no part of the startup.
1429
+ *
1430
+ * The frames are written repeatedly so there is runway to measure over,
1431
+ * whatever the preset's speed.
1432
+ *
1433
+ * @param {string} ffmpegBin
1434
+ * @param {string} preset
1435
+ * @param {string} rawFramesPath
1436
+ * @returns {Promise<number | null>} Video seconds encoded per second of clock.
1437
+ */
1438
+ /**
1439
+ * Video seconds produced per second of clock, from ffmpeg's own reports.
1440
+ *
1441
+ * Startup is excluded by taking a DIFFERENCE: it lands in the wall clock of
1442
+ * every report equally, so it cancels between two of them. (The decode
1443
+ * benchmark drops its first report instead, because there the first one is
1444
+ * emitted at out_time zero; here reports with no time yet are discarded before
1445
+ * they arrive, so the first kept one is already running.)
1446
+ *
1447
+ * @param {Array<{ wallSec: number, outSec: number }>} samples
1448
+ * @param {number} [minimumWindowSec=ENCODE_BENCHMARK_WINDOW_SEC]
1449
+ * @returns {number | null}
1450
+ */
1451
+ export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC) {
1452
+ const first = samples[0];
1453
+ const last = samples[samples.length - 1];
1454
+ if (!first || !last || first === last) {
1455
+ return null;
1456
+ }
1457
+ const took = last.wallSec - first.wallSec;
1458
+ const produced = last.outSec - first.outSec;
1459
+ if (!(took >= minimumWindowSec) || !(produced > 0)) {
1460
+ return null;
1461
+ }
1462
+ const slope = produced / took;
1463
+ // Nothing encodes a thousand times realtime. A figure above that is a
1464
+ // measurement fault, and letting it through opens the whole ladder.
1465
+ return slope <= ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED ? slope : null;
1466
+ }
1467
+
1468
+ export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
1469
+ return new Promise((resolve) => {
1470
+ const args = [
1471
+ "-hide_banner", "-loglevel", "error", "-nostats",
1472
+ // What a device-backed encoder needs before the input — the device. Not
1473
+ // its decoding setup: this is fed raw frames and there is nothing to
1474
+ // decode, and `-hwaccel vaapi` over rawvideo fails to open.
1475
+ ...(typeof encoder?.benchmarkInputArgs === "function" ? encoder.benchmarkInputArgs() : []),
1476
+ "-stream_loop", "-1",
1477
+ "-f", "rawvideo", "-pix_fmt", "yuv420p",
1478
+ "-s", `${frame.width}x${frame.height}`, "-r", String(TRANSCODE_FPS),
1479
+ "-i", rawFramesPath,
1480
+ // THE ENCODER SAYS HOW TO MEASURE ITSELF. It was libx264 written here, so
1481
+ // a host with NVENC, QSV, VAAPI or V4L2M2M measured its encoder not at all
1482
+ // and the quality offer, which is arithmetic over pixels per second, had
1483
+ // no pixels per second to work with.
1484
+ ...encoder.benchmarkArgs(rung),
1485
+ "-f", "null", "-",
1486
+ "-progress", "pipe:1"
1487
+ ];
1488
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
1489
+ const samples = [];
1490
+ let settled = false;
1491
+ let buffered = "";
1492
+ let child;
1493
+ const startedAt = Date.now();
1494
+ const finish = (value) => {
1495
+ if (settled) {
1496
+ return;
1497
+ }
1498
+ settled = true;
1499
+ clearTimeout(timer);
1500
+ try {
1501
+ child?.kill("SIGKILL");
1502
+ } catch {
1503
+ // already gone
1504
+ }
1505
+ resolve(value);
1506
+ };
1507
+ const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1508
+ try {
1509
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1510
+ } catch {
1511
+ finish(null);
1512
+ return;
1513
+ }
1514
+ // The frames come from a FILE, read on repeat by ffmpeg itself. Fed through
1515
+ // a pipe instead, the fastest presets measured the pipe: `ultrafast` on a
1516
+ // desktop wants raw frames at hundreds of megabytes a second, which no
1517
+ // writer here can supply, and the reading then describes the feeding rather
1518
+ // than the encoder.
1519
+ child.stdout.on("data", (chunk) => {
1520
+ buffered += String(chunk);
1521
+ let newline = buffered.indexOf(NEWLINE);
1522
+ while (newline >= 0) {
1523
+ const line = buffered.slice(0, newline).trim();
1524
+ buffered = buffered.slice(newline + 1);
1525
+ if (line.startsWith("out_time_ms=")) {
1526
+ const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1527
+ // `N/A` is not the only way ffmpeg says "no position yet": some builds
1528
+ // print the smallest signed 64-bit integer, which IS finite and would
1529
+ // be taken for a position nine trillion seconds before the start.
1530
+ if (Number.isFinite(outSec) && outSec >= 0) {
1531
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1532
+ }
1533
+ }
1534
+ newline = buffered.indexOf(NEWLINE);
1535
+ }
1536
+ const slope = slopeOf(samples);
1537
+ if (slope !== null) {
1538
+ finish(slope);
1539
+ }
1540
+ });
1541
+ child.on("error", () => finish(null));
1542
+ // A preset that finished before the window was wide enough is measured from
1543
+ // whatever it did report, provided two reports exist at all.
1544
+ // A preset that finished before the wide window was covered is still
1545
+ // measured — but never over a window of nothing. Two reports a millisecond
1546
+ // apart would divide a frame of video by that millisecond and call the host
1547
+ // twenty times faster than it is, and one such reading becomes the figure
1548
+ // every ladder decision is taken from.
1549
+ child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1550
+ });
1551
+ }
1552
+
1553
+ /**
1554
+ * The benchmark's footage as raw frames: the calibration clip, looped to the
1555
+ * benchmark's length and scaled to its size, decoded once.
1556
+ *
1557
+ * @param {string} ffmpegBin
1558
+ * @param {{ info: (m: string) => void, warn: (m: string) => void }} log
1559
+ * @returns {Promise<string | null>} Path to the raw frames, or null.
1560
+ */
1561
+ export async function decodeToRawFrames(ffmpegBin, log, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
1562
+ // A benchmark may leave a host unmeasured; it may never stop it from
1563
+ // starting. Before this the temp directory was made outside any guard, so a
1564
+ // read-only or missing TMPDIR rejected the promise that starts the proxy.
1565
+ let directory;
1566
+ try {
1567
+ directory = mkdtempSync(path.join(os.tmpdir(), "torrent-tv-bench-"));
1568
+ } catch (error) {
1569
+ log.warn(
1570
+ `hwaccel: no writable temp directory for the preset benchmark (${error instanceof Error ? error.message : String(error)}); ` +
1571
+ "presets unmeasured, so no quality rung will be refused on this host"
1572
+ );
1573
+ return null;
1574
+ }
1575
+ const rawPath = path.join(directory, "frames.yuv");
1576
+ const args = [
1577
+ "-hide_banner", "-loglevel", "error",
1578
+ "-stream_loop", "-1",
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}`,
1582
+ "-an", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-y", rawPath
1583
+ ];
1584
+ const { code } = await runFfmpeg(ffmpegBin, args, 30000);
1585
+ const expectedBytes = frame.width * frame.height * 1.5 * rawFrameCountFor(frame);
1586
+ let written = 0;
1587
+ try {
1588
+ written = statSync(rawPath).size;
1589
+ } catch {
1590
+ written = 0;
1591
+ }
1592
+ if (code !== 0 || written < expectedBytes * 0.9) {
1593
+ log.warn(
1594
+ "hwaccel: could not decode the calibration clip for the preset benchmark " +
1595
+ `(${written} of ~${Math.round(expectedBytes)} bytes); presets unmeasured, ` +
1596
+ "so no quality rung will be refused on this host"
1597
+ );
1598
+ try {
1599
+ rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1600
+ } catch {
1601
+ // A temp directory the operating system will clear; not worth a start-up.
1602
+ }
1603
+ return null;
1604
+ }
1605
+ return rawPath;
1606
+ }
1607
+
1608
+ /**
1609
+ * Pick the highest-quality (slowest) benchmarked preset that can encode
1610
+ * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
1611
+ * benchmarked preset, or `"ultrafast"` when no benchmark is available.
1612
+ *
1613
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1614
+ * @param {number} pixelsPerSecNeeded
1615
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1616
+ * @returns {string}
1617
+ */
1618
+ /**
1619
+ * What this host can do at its CHEAPEST preset — the ceiling of the ladder.
1620
+ *
1621
+ * Deliberately not the largest reading in the array. The list is in quality
1622
+ * order, so its last measured entry is the cheapest preset; taking the maximum
1623
+ * instead would let one noisy reading of an expensive preset raise the bar that
1624
+ * decides which rungs are offered, and a rung offered on noise is a rung the
1625
+ * host cannot hold. For choosing a preset the direction of that error is
1626
+ * harmless; for deciding what to offer it is not, so the two use different
1627
+ * statistics on purpose.
1628
+ *
1629
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark
1630
+ * @returns {number}
1631
+ */
1632
+ function cheapestPresetPixelsPerSec(benchmark, frame = null) {
1633
+ const cheapest = benchmark[benchmark.length - 1];
1634
+ return cheapest ? throughputAt(cheapest, frame) : null;
1635
+ }
1636
+
1637
+ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}, frame = null) {
1638
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1639
+ return "ultrafast";
1640
+ }
1641
+ const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1642
+ ? cost.observedDecodeCostSec
1643
+ : null;
1644
+ const bar = barFor(cost);
1645
+ // The FIRST entry that clears the bar wins — the list is in quality order, so
1646
+ // that is the best picture this host can hold. Every entry is examined rather
1647
+ // than the walk stopping at the first miss, because the measurements do not
1648
+ // always ascend with the list: on a busy machine on 2026-08-15 `faster` read
1649
+ // below `fast` twice.
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
+ }
1657
+ const speed = predictedRealtimeSpeed({
1658
+ decodeModel: cost.decodeModel ?? null,
1659
+ encodePixelsPerSec,
1660
+ outputPixelsPerSec: pixelsPerSecNeeded,
1661
+ source: cost.source ?? null,
1662
+ observedDecodeCostSec: observed
1663
+ });
1664
+ if (speed !== null && speed >= bar) {
1665
+ return entry.preset;
1666
+ }
1667
+ }
1668
+ // Nothing clears the bar: the cheapest preset, which is the last in quality
1669
+ // order. Returning whichever preset measured fastest would hand an expensive
1670
+ // one to a host that has just been shown to hold no rung at all.
1671
+ return benchmark[benchmark.length - 1].preset;
1672
+ }
1673
+
1674
+ /**
1675
+ * Whether a cost description can actually price decoding — a fit AND a source
1676
+ * to apply it to. Without both, every prediction is encoder-only.
1677
+ *
1678
+ * @param {{ decodeModel?: object | null, source?: object | null }} cost
1679
+ * @returns {boolean}
1680
+ */
1681
+ function isDecodePriced(cost) {
1682
+ if (Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0) {
1683
+ return true; // measured on the source itself, which needs no fit to stand on
1684
+ }
1685
+ return Boolean(cost?.decodeModel) && Boolean(cost?.source);
1686
+ }
1687
+
1688
+ /**
1689
+ * Build the resolution ladder for a ceiling box. Returns candidate output
1690
+ * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
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.
1694
+ *
1695
+ * @param {number} ceilingWidth
1696
+ * @param {number} ceilingHeight
1697
+ * @returns {Array<{ width: number, height: number }>} high→low
1698
+ */
1699
+ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1700
+ const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
1701
+ const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
1702
+ if (!cw || !ch) {
1703
+ return [];
1704
+ }
1705
+ const even = (v) => {
1706
+ const r = Math.round(v);
1707
+ return Math.max(2, r - (r % 2));
1708
+ };
1709
+ /** @type {Array<{ width: number, height: number }>} */
1710
+ const rungs = [{ width: cw, height: ch }];
1711
+ for (const h of LADDER_HEIGHTS) {
1712
+ if (h >= ch) {
1713
+ continue; // at/above the ceiling — the ceiling rung already covers it
1714
+ }
1715
+ rungs.push({ width: even(cw * (h / ch)), height: h });
1716
+ }
1717
+ const seen = new Set();
1718
+ return rungs.filter((rung) => {
1719
+ if (seen.has(rung.height)) {
1720
+ return false;
1721
+ }
1722
+ seen.add(rung.height);
1723
+ return true;
1724
+ });
1725
+ }
1726
+
1727
+ /**
1728
+ * Choose the software encode settings (resolution + preset) that fit the
1729
+ * realtime budget on this host. From the resolution ladder (ceiling downward),
1730
+ * pick the HIGHEST rung whose encode throughput — predicted from the startup
1731
+ * benchmark's fastest preset — clears the speed this file's own supply
1732
+ * demands (`speedBar`). Then, at that resolution, pick the highest-quality
1733
+ * preset that still clears it. When even the lowest rung cannot clear it, use the lowest rung with
1734
+ * the fastest preset (best effort — a smaller picture beats sub-realtime
1735
+ * playback at full size). Returns null when no benchmark or ceiling is
1736
+ * available (the caller keeps the ceiling resolution and the default preset).
1737
+ *
1738
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1739
+ * @param {{ width: number, height: number }} ceiling
1740
+ * @param {number} outputFps
1741
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1742
+ * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1743
+ */
1744
+ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1745
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1746
+ return null;
1747
+ }
1748
+ const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
1749
+ const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
1750
+ if (ladder.length === 0) {
1751
+ return null;
1752
+ }
1753
+ const bar = barFor(cost);
1754
+ let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
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
+ }
1761
+ const speed = predictedRealtimeSpeed({
1762
+ decodeModel: cost.decodeModel ?? null,
1763
+ encodePixelsPerSec: fastest,
1764
+ outputPixelsPerSec: ladder[i].width * ladder[i].height * fps,
1765
+ source: cost.source ?? null
1766
+ });
1767
+ if (speed !== null && speed >= bar) {
1768
+ chosenIndex = i;
1769
+ break;
1770
+ }
1771
+ }
1772
+ const chosen = ladder[chosenIndex];
1773
+ const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost, chosen);
1774
+ return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1775
+ }
1776
+
1777
+ /**
1778
+ * How fast this machine COPIES a picture, in seconds of film per second.
1779
+ *
1780
+ * The startup measurements price encoding and decoding, and a copied picture
1781
+ * does neither: it reads packets and writes them out again. That left one whole
1782
+ * branch of what this proxy does with no figure at all, and a figure is what
1783
+ * every decision in the encoding layer is made from — where to put an encoder,
1784
+ * how many to run, whether anybody will be left waiting. Without it a copied
1785
+ * output was planned with no speed until its own run had been running long
1786
+ * enough to report one, which is exactly the moment the plan matters most.
1787
+ *
1788
+ * Measured the same way as the others: ffmpeg's own progress, read as a slope
1789
+ * over a window, so the process starting is outside the figure.
1790
+ *
1791
+ * The clip is joined to itself first rather than looped with `-stream_loop`.
1792
+ * Looping charges a re-initialisation per lap — measured on the addon host at
1793
+ * 0.03 s for 480p and 0.12 s for 1080p — and a copy of a five-second clip laps
1794
+ * many times a second, so the reading would have been mostly re-initialisation.
1795
+ *
1796
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} params
1797
+ * @returns {Promise<number | null>} Seconds of film per second, or null where
1798
+ * the reading could not be taken. Null means unmeasured and is never a
1799
+ * substitute for a number.
1800
+ */
1801
+ export async function benchmarkCopySpeed({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
1802
+ const log = logger ?? { info: () => {}, warn: () => {} };
1803
+ const startedAt = Date.now();
1804
+ // The largest clip in the set. A copy moves BYTES, so what it can do is a
1805
+ // statement about the biggest pictures this host will be asked to pass
1806
+ // through, and the small ones are covered by the same figure.
1807
+ const clip = path.join(clipsDir, "cal-h264-1080-hi.mp4");
1808
+ const speed = await measureCopySlope(ffmpegBin, clip);
1809
+ if (!(speed > 0)) {
1810
+ log.warn("hwaccel: copying could not be measured; a copied picture will be planned from its own run instead");
1811
+ return null;
1812
+ }
1813
+ log.info(
1814
+ `hwaccel: this host copies a picture at ${speed.toFixed(0)}x realtime ` +
1815
+ `(measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
1816
+ );
1817
+ return speed;
1818
+ }
1819
+
1820
+ /**
1821
+ * Seconds of film per second, copying one file, read from ffmpeg's progress.
1822
+ *
1823
+ * @param {string} ffmpegBin
1824
+ * @param {string} filePath
1825
+ * @returns {Promise<number | null>}
1826
+ */
1827
+ function measureCopySlope(ffmpegBin, filePath) {
1828
+ return new Promise((resolve) => {
1829
+ const args = [
1830
+ "-hide_banner", "-loglevel", "error", "-nostats",
1831
+ // Played over and over, because a copy gets through a five-second clip in
1832
+ // milliseconds and a slope needs a window to be taken over. Looping
1833
+ // charges the demuxer being re-opened once a lap, so what comes out is a
1834
+ // FLOOR on what this host can copy — the safe direction, since a plan made
1835
+ // from it expects copying to be slower than it is.
1836
+ "-stream_loop", "-1", "-i", filePath,
1837
+ // What a copied output does: packets in, packets out, nothing decoded and
1838
+ // nothing encoded. Written nowhere, so the figure is this machine's own
1839
+ // handling and not the disk under a temp directory.
1840
+ "-c", "copy", "-f", "null", "-",
1841
+ // Progress is reported every half second by default, which over a window
1842
+ // of one second is two readings. This asks for twenty.
1843
+ "-stats_period", "0.05",
1844
+ "-progress", "pipe:1"
1845
+ ];
1846
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
1847
+ const samples = [];
1848
+ let settled = false;
1849
+ let buffered = "";
1850
+ let child;
1851
+ const startedAt = Date.now();
1852
+ const finish = (value) => {
1853
+ if (settled) {
1854
+ return;
1855
+ }
1856
+ settled = true;
1857
+ clearTimeout(timer);
1858
+ try {
1859
+ child?.kill("SIGKILL");
1860
+ } catch {
1861
+ // already gone
1862
+ }
1863
+ resolve(value);
1864
+ };
1865
+ const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1866
+ try {
1867
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1868
+ } catch {
1869
+ finish(null);
1870
+ return;
1871
+ }
1872
+ child.stdout.on("data", (chunk) => {
1873
+ buffered += String(chunk);
1874
+ let newline = buffered.indexOf(NEWLINE);
1875
+ while (newline >= 0) {
1876
+ const line = buffered.slice(0, newline).trim();
1877
+ buffered = buffered.slice(newline + 1);
1878
+ if (line.startsWith("out_time_ms=")) {
1879
+ const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1880
+ if (Number.isFinite(outSec) && outSec >= 0) {
1881
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1882
+ }
1883
+ }
1884
+ newline = buffered.indexOf(NEWLINE);
1885
+ }
1886
+ const slope = slopeOf(samples);
1887
+ if (slope !== null) {
1888
+ finish(slope);
1889
+ }
1890
+ });
1891
+ child.on("error", () => finish(null));
1892
+ child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1893
+ });
1894
+ }