@torrent-tv/proxy 2.86.0 → 2.87.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/CHANGELOG.md +2142 -1915
  2. package/CLAUDE.md +322 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/browser-proxy-contract.md +94 -0
  7. package/docs/container-architecture.md +218 -213
  8. package/docs/download-architecture.md +7 -7
  9. package/docs/encode-architecture.md +287 -11
  10. package/docs/encode-run-state.md +2 -2
  11. package/docs/logs.md +97 -97
  12. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  13. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  14. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  15. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  18. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  19. package/openspec/changes/cold-start/design.md +1 -1
  20. package/openspec/changes/disk-cap/proposal.md +1 -1
  21. package/openspec/changes/proxy-observability/proposal.md +2 -2
  22. package/openspec/changes/track-selection/proposal.md +1 -1
  23. package/openspec/changes/transcode-quality/proposal.md +1 -1
  24. package/package.json +2 -2
  25. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  26. package/research/double-spawn-2026-09-10.md +171 -171
  27. package/routes/api/playback-plan/post.js +2 -2
  28. package/routes/api/sources/files/get.js +1 -1
  29. package/routes/api/sources/stats/get.js +1 -1
  30. package/routes/api/sources/warm/post.js +10 -3
  31. package/routes/api/subtitles/get.js +230 -230
  32. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  33. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  34. package/routes/api/transcode-sessions/post.js +160 -139
  35. package/routes/api/transcode-sessions/progress/get.js +3 -3
  36. package/routes/api/transcode-sessions/release/post.js +3 -3
  37. package/routes/api/transcode-sessions/seek/post.js +12 -3
  38. package/routes/stream/get.js +2 -2
  39. package/routes/transcode/audio-file/get.js +15 -6
  40. package/routes/transcode/audio-warm/get.js +9 -6
  41. package/routes/transcode/session-file/get.js +52 -268
  42. package/routes/transcode/variant-file/get.js +46 -7
  43. package/routes/transcode/variant-warm/get.js +19 -6
  44. package/scripts/render-run-graph.js +1 -1
  45. package/server.js +720 -465
  46. package/services/encode/CoverageMap.js +428 -428
  47. package/services/encode/CushionReport.js +345 -0
  48. package/services/encode/EncodeAdmission.js +332 -0
  49. package/services/{orchestrators → encode}/EncodeOrchestrator.js +307 -29
  50. package/services/encode/EncodeRun.js +16 -9
  51. package/services/encode/EncodeRuns.js +1229 -0
  52. package/services/encode/Encoder.js +18 -2
  53. package/services/encode/EncoderSelection.js +38 -0
  54. package/services/encode/LocalObservations.js +359 -0
  55. package/services/encode/NvencEncoder.js +5 -0
  56. package/services/encode/OutputOpening.js +755 -0
  57. package/services/encode/OutputTimes.js +579 -0
  58. package/services/encode/Renditions.js +2162 -0
  59. package/services/encode/RunProgress.js +84 -0
  60. package/services/encode/SoftwareEncoder.js +12 -7
  61. package/services/encode/args.js +402 -186
  62. package/services/encode/calibration.js +361 -0
  63. package/services/encode/contention.js +128 -128
  64. package/services/encode/encode-run-state.js +16 -20
  65. package/services/encode/fingerprint.js +322 -0
  66. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  67. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  68. package/services/encode/output/EncodedOutput.js +57 -0
  69. package/services/encode/output/Output.js +131 -0
  70. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  71. package/services/encode/output/OutputSpec.js +390 -0
  72. package/services/{output → encode/output}/Timeline.js +12 -37
  73. package/services/encode/output/index.js +20 -0
  74. package/services/encode/output/ladder.js +32 -0
  75. package/services/{output → encode/output}/playlists.js +3 -0
  76. package/services/encode/output-key-format.js +21 -0
  77. package/services/encode/quality/EncodeCost.js +1083 -0
  78. package/services/encode/quality/HostLoad.js +525 -0
  79. package/services/encode/quality/HostTimings.js +249 -0
  80. package/services/encode/quality/OutputQualityState.js +28 -0
  81. package/services/encode/quality/QualityController.js +935 -0
  82. package/services/encode/quality/QualityOffer.js +416 -0
  83. package/services/encode/quality/drain-threat.js +47 -0
  84. package/services/encode/quality/link-budget.js +254 -0
  85. package/services/encode/quality/output-format.js +266 -0
  86. package/services/encode/quality/serving-output.js +216 -0
  87. package/services/encode/quality/visible-rung.js +57 -0
  88. package/services/encode/run-command.js +23 -20
  89. package/services/encode/run-inputs.js +42 -0
  90. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  91. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  92. package/services/encode/segment-formats/init-compat.js +324 -0
  93. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  94. package/services/encode/throughput.js +97 -0
  95. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  96. package/services/media/KeyframeTables.js +316 -0
  97. package/services/{source → media}/SourceFile.js +7 -55
  98. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  99. package/services/media/SubtitleOrchestrator.js +167 -0
  100. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  101. package/services/{container → media/container}/AviContainer.js +266 -266
  102. package/services/{container → media/container}/Container.js +596 -601
  103. package/services/media/container/ContainerFactory.js +56 -0
  104. package/services/media/container/KeyframeTable.js +272 -0
  105. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  106. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  107. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  108. package/services/media/keyframe-probe.js +157 -0
  109. package/services/media/media-info-probe.js +93 -0
  110. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  111. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  112. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  113. package/services/server/OutputLifecycle.js +418 -0
  114. package/services/server/SegmentServing.js +1146 -0
  115. package/services/server/ViewerRequests.js +522 -0
  116. package/services/server/controllers/PlaybackController.js +36 -0
  117. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  118. package/services/server/http-responses.js +60 -0
  119. package/services/server/transcode-session-files.js +325 -0
  120. package/services/server/wire-outputs.js +983 -0
  121. package/services/storage/Diagnostics.js +202 -0
  122. package/services/storage/MachineBudget.js +235 -0
  123. package/services/{piece-store → storage}/allowance.js +107 -107
  124. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  125. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  126. package/services/{disk → storage}/free.js +33 -0
  127. package/services/storage/machine-memory.js +56 -0
  128. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  129. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  130. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  131. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  132. package/services/storage/wire.js +175 -0
  133. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  134. package/services/{download → torrent/download}/registry.js +2 -2
  135. package/services/torrent/files.js +1 -1
  136. package/services/torrent/piece-store-of.js +29 -0
  137. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  138. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  139. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  140. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  141. package/services/torrent/worker/held-bytes.js +155 -0
  142. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  143. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  144. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  145. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  146. package/services/torrent/worker/resume-warm.js +107 -0
  147. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  148. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  149. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  150. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  151. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  152. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  153. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  154. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  155. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  156. package/services/viewer/Assignments.js +340 -0
  157. package/services/{priority → viewer}/PriorityMap.js +390 -390
  158. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  159. package/services/viewer/Viewer.js +657 -483
  160. package/services/viewer/Viewers.js +368 -257
  161. package/services/viewer/active-output.js +37 -54
  162. package/services/viewer/audio-start.js +4 -3
  163. package/services/viewer/buffer-trend.js +81 -0
  164. package/services/viewer/choices.js +647 -0
  165. package/services/viewer/positions.js +126 -120
  166. package/services/viewer/report-intake.js +46 -45
  167. package/test/asset-paths.test.js +28 -0
  168. package/test/audio-inventory.test.js +3 -3
  169. package/test/audio-track-bitrate.test.js +40 -0
  170. package/test/auto-quality-step.test.js +707 -515
  171. package/test/available-share.test.js +1 -1
  172. package/test/background-fill.test.js +1 -1
  173. package/test/client-log-file.test.js +110 -110
  174. package/test/completed-files.test.js +1 -1
  175. package/test/concurrent-cost.test.js +13 -3
  176. package/test/container-media-info.test.js +3 -3
  177. package/test/core-dumps.test.js +1 -1
  178. package/test/coverage-follows-the-disk.test.js +46 -9
  179. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  180. package/test/cut-times-timeline.test.js +2 -2
  181. package/test/cuts-follow-published-grid.test.js +9 -8
  182. package/test/data-channel-frame.test.js +1 -1
  183. package/test/dead-torrent-handle.test.js +1 -1
  184. package/test/declared-rates.test.js +2 -2
  185. package/test/declared-tracks.test.js +2 -2
  186. package/test/decode-cost-fit.test.js +1 -1
  187. package/test/decode-cost.test.js +34 -25
  188. package/test/delivery-probe.test.js +1 -1
  189. package/test/demand-register.test.js +4 -4
  190. package/test/dht-bootstrap.test.js +1 -1
  191. package/test/drain-threat.test.js +65 -0
  192. package/test/duplicate-add.test.js +1 -1
  193. package/test/encode-admission.test.js +146 -0
  194. package/test/encode-cost.test.js +199 -10
  195. package/test/encode-orchestrator.test.js +39 -1
  196. package/test/encode-plan-viewers.test.js +2 -2
  197. package/test/encode-run.test.js +2 -2
  198. package/test/encode-slope.test.js +1 -1
  199. package/test/encoded-output.test.js +39 -0
  200. package/test/encoder-readings.test.js +1 -1
  201. package/test/every-claimant-is-told-its-share.test.js +152 -0
  202. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  203. package/test/failed-start-breaker.test.js +81 -30
  204. package/test/fastest-wires.test.js +1 -1
  205. package/test/ffmpeg-stream-counts.test.js +1 -1
  206. package/test/file-edges.test.js +3 -3
  207. package/test/fmp4-self-contained.test.js +1 -1
  208. package/test/fmp4-timestamps.test.js +1 -1
  209. package/test/fmp4-track-completeness.test.js +1 -1
  210. package/test/format-keyed-outputs.test.js +78 -0
  211. package/test/health-metrics.test.js +3 -2
  212. package/test/held-request-width.test.js +22 -23
  213. package/test/helpers/encode-run.js +136 -136
  214. package/test/helpers/held-file.js +29 -0
  215. package/test/helpers/manager.js +57 -0
  216. package/test/helpers/output-spec.js +63 -0
  217. package/test/host-load.test.js +1 -1
  218. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  219. package/test/init-compat.test.js +219 -0
  220. package/test/input-lost-quiets-the-plan.test.js +1 -1
  221. package/test/input-unavailable.test.js +1 -1
  222. package/test/keeping-period.test.js +4 -4
  223. package/test/keyframe-index-accuracy.test.js +97 -45
  224. package/test/keyframe-table-budget.test.js +28 -33
  225. package/test/keyframes-belong-to-the-file.test.js +174 -57
  226. package/test/learned-median.test.js +1 -1
  227. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  228. package/test/limit-row-by-area.test.js +60 -0
  229. package/test/link-budget.test.js +111 -0
  230. package/test/link-probe.test.js +56 -56
  231. package/test/local-encoding-data.test.js +273 -0
  232. package/test/look-ahead-coverage.test.js +1 -1
  233. package/test/manager-surface.test.js +294 -0
  234. package/test/matroska-blocks.test.js +0 -0
  235. package/test/matroska-cues-track.test.js +1 -1
  236. package/test/memory-budget.test.js +2 -2
  237. package/test/mp4-composition-times.test.js +0 -0
  238. package/test/mp4-subtitles.test.js +1 -1
  239. package/test/one-authority.test.js +45 -41
  240. package/test/one-budget.test.js +149 -0
  241. package/test/one-output-one-session.test.js +204 -0
  242. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  243. package/test/one-serving-output-per-height.test.js +458 -0
  244. package/test/orchestrator-wired.test.js +46 -45
  245. package/test/output-catalog.test.js +39 -0
  246. package/test/output-shape.test.js +4 -51
  247. package/test/output-spec.test.js +251 -157
  248. package/test/output-speed.test.js +17 -15
  249. package/test/outputs-being-prepared.test.js +40 -0
  250. package/test/packet-witness-ring.test.js +2 -2
  251. package/test/packet-witness.test.js +1 -1
  252. package/test/piece-disk-store.test.js +2 -2
  253. package/test/piece-from-whole-file.test.js +1 -1
  254. package/test/piece-lru.test.js +1 -1
  255. package/test/piece-reader.test.js +2 -2
  256. package/test/piece-store-eviction.test.js +1 -1
  257. package/test/piece-store-never-refuses.test.js +1 -1
  258. package/test/piece-store-reservations.test.js +1 -1
  259. package/test/piece-store-slow-disk.test.js +1 -1
  260. package/test/piece-tail.test.js +1 -1
  261. package/test/plan-host-timings.test.js +1 -1
  262. package/test/plan-is-steady.test.js +225 -225
  263. package/test/priority-map-download.test.js +4 -4
  264. package/test/priority-map-emptied.test.js +204 -205
  265. package/test/priority-map-per-output.test.js +215 -209
  266. package/test/priority-map.test.js +1 -1
  267. package/test/probe-wedge-certainty.test.js +1 -1
  268. package/test/produced-copy-choice.test.js +15 -60
  269. package/test/quality-offer.test.js +162 -0
  270. package/test/quality-variants.test.js +1229 -1167
  271. package/test/rate-control-in-the-output.test.js +180 -0
  272. package/test/read-survives-withdrawal.test.js +2 -2
  273. package/test/read-window.test.js +10 -3
  274. package/test/resume-warm.test.js +1 -1
  275. package/test/run-inputs.test.js +80 -0
  276. package/test/run-position-follows-published-grid.test.js +13 -11
  277. package/test/run-progress.test.js +65 -0
  278. package/test/same-height-switch.test.js +664 -0
  279. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  280. package/test/seek-landing.test.js +13 -13
  281. package/test/seek-target-not-superseded.test.js +28 -29
  282. package/test/segment-closed-on-both-branches.test.js +96 -0
  283. package/test/segment-cut-times.test.js +1 -1
  284. package/test/segment-hold-supersede.test.js +28 -35
  285. package/test/segment-judged-by-its-own-header.test.js +221 -0
  286. package/test/segment-serve-wiring.test.js +130 -39
  287. package/test/segment-store-eviction.test.js +22 -7
  288. package/test/segment-store.test.js +98 -14
  289. package/test/segment-true-start.test.js +1 -1
  290. package/test/segments-are-shared.test.js +17 -16
  291. package/test/send-chunk-bytes.test.js +1 -1
  292. package/test/serving-existing-output.test.js +102 -0
  293. package/test/serving-output.test.js +102 -0
  294. package/test/shared-piece-store.test.js +1 -1
  295. package/test/source-file.test.js +1 -1
  296. package/test/stale-request-after-seek.test.js +17 -21
  297. package/test/startup-readings.test.js +10 -5
  298. package/test/subtitle-cue-framing.test.js +5 -5
  299. package/test/subtitle-cue-source.test.js +36 -38
  300. package/test/subtitle-cue-walk.test.js +11 -10
  301. package/test/subtitle-defaults.test.js +1 -1
  302. package/test/subtitle-language.test.js +4 -4
  303. package/test/subtitle-track-numbering.test.js +5 -4
  304. package/test/supply-margin.test.js +1 -1
  305. package/test/swarm-follows-readers.test.js +3 -3
  306. package/test/swarm-reach.test.js +1 -1
  307. package/test/swarm-selection.test.js +3 -3
  308. package/test/tail-duplication.test.js +1 -1
  309. package/test/the-channel-has-both-ends.test.js +91 -0
  310. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  311. package/test/thread-pool.test.js +3 -3
  312. package/test/timeline.test.js +1 -12
  313. package/test/torrent-cost.test.js +1 -1
  314. package/test/tracks-begin-together.test.js +38 -24
  315. package/test/tunnel-renewal.test.js +1 -1
  316. package/test/two-viewers-one-picture.test.js +120 -87
  317. package/test/upload-hurry.test.js +3 -3
  318. package/test/usrsctp-state.test.js +1 -1
  319. package/test/video-facts.test.js +1 -1
  320. package/test/viewer-assignments.test.js +143 -0
  321. package/test/viewer-outputs.test.js +60 -52
  322. package/test/viewer-presence.test.js +22 -25
  323. package/test/viewer-requests-admission.test.js +89 -0
  324. package/test/viewer-states.test.js +189 -202
  325. package/test/viewer-subtitle-subscription.test.js +1 -1
  326. package/test/viewer.test.js +139 -136
  327. package/test/viewing-generations.test.js +390 -0
  328. package/test/visible-rung.test.js +37 -0
  329. package/test/wait-ledger.test.js +1 -1
  330. package/test/webrtc-shim.test.js +3 -3
  331. package/test/wedge-certainty.test.js +2 -2
  332. package/test/withdraw-piece-claim.test.js +2 -2
  333. package/test/worker-channel.test.js +1 -31
  334. package/utils/client-log-file.js +151 -151
  335. package/scripts/read-container-index.mjs +0 -108
  336. package/services/container/ContainerFactory.js +0 -122
  337. package/services/controllers/PlaybackController.js +0 -33
  338. package/services/disk/DiskSpace.js +0 -150
  339. package/services/disk/wire.js +0 -61
  340. package/services/hls-session-manager.js +0 -9608
  341. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  342. package/services/output/Output.js +0 -94
  343. package/services/output/OutputSpec.js +0 -202
  344. package/services/output/index.js +0 -1
  345. package/services/output/ladder.js +0 -26
  346. package/services/produced-index.js +0 -222
  347. package/services/quality/EncodeCost.js +0 -555
  348. package/services/torrent-worker/container-tracks.js +0 -414
  349. package/services/viewer/link-readings.js +0 -56
  350. package/test/produced-index.test.js +0 -142
  351. /package/{services → bin}/thread-pool.js +0 -0
  352. /package/services/{available-share.js → encode/available-share.js} +0 -0
  353. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  354. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  355. /package/services/{host-load.js → encode/host-load.js} +0 -0
  356. /package/services/{output → encode/output}/cut-grid.js +0 -0
  357. /package/services/{output → encode/output}/rates.js +0 -0
  358. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  359. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  360. /package/services/{container → media/container}/ebml-reader.js +0 -0
  361. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  362. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  365. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  366. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  367. /package/services/{tracks → media/tracks}/index.js +0 -0
  368. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  369. /package/services/{disk → storage}/keep.js +0 -0
  370. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  371. /package/services/{disk → storage}/returns.js +0 -0
  372. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  373. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  374. /package/services/{demand → torrent/demand}/Window.js +0 -0
  375. /package/services/{demand → torrent/demand}/index.js +0 -0
  376. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  377. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  378. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  379. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  380. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  381. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  382. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  383. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  384. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  385. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -23,15 +23,23 @@
23
23
  * result when it is ready.
24
24
  */
25
25
 
26
- import { CoverageMap } from "../encode/CoverageMap.js";
27
- import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
28
- import { endOfRun } from "../encode/EncodeRun.js";
29
- import { ENCODE_EXIT } from "../encode/encode-exit.js";
30
- import { affordableRuns } from "../encode/run-budget.js";
31
- import { RunCosts } from "../encode/run-costs.js";
32
- import { contentionPenalty } from "../encode/contention.js";
33
- import { waits } from "../priority/WaitLedger.js";
34
- import { SegmentDemand } from "../encode/SegmentDemand.js";
26
+ import { CoverageMap } from "./CoverageMap.js";
27
+ import { firstUnmetWant, planEncoders } from "./EncodePlan.js";
28
+ import { endOfRun } from "./EncodeRun.js";
29
+ import { ENCODE_EXIT } from "./encode-exit.js";
30
+ import { affordableRuns } from "./run-budget.js";
31
+ import { RunCosts } from "./run-costs.js";
32
+ import { contentionPenalty } from "./contention.js";
33
+ import { SegmentDemand } from "./SegmentDemand.js";
34
+ import { runStateOf } from "./encode-run-state.js";
35
+
36
+ /**
37
+ * @typedef {Object} SegmentCoverage
38
+ * @property {(address: string) => number[]} provenNumbers
39
+ * @property {(address: string, index: number) => void} announce
40
+ * @property {(address: string) => number} filesHeld
41
+ * @property {(address: string, startedAt: number) => number} clearUpAfter
42
+ */
35
43
 
36
44
  /**
37
45
  * How long nothing is placed on an output whose input has just gone, and the
@@ -46,14 +54,35 @@ import { SegmentDemand } from "../encode/SegmentDemand.js";
46
54
  */
47
55
  const INPUT_QUIET_BASE_MS = 2_000;
48
56
  const INPUT_QUIET_MAX_MS = 15_000;
57
+ const START_FAST_FAIL_MS = 2_000;
58
+ const MAX_FAILED_STARTS = 3;
49
59
 
50
60
  export class EncodeOrchestrator {
61
+ /** Derived priority maps. Discardable and rebuilt from viewer state. */
62
+ #demand = new SegmentDemand();
63
+ /** @type {(address: string) => string} */
64
+ #describeWaits = () => "";
65
+
51
66
  /** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
52
67
  #coverage = new Map();
53
68
 
69
+ /** @type {SegmentCoverage | null} */
70
+ #segmentCoverage = null;
71
+
54
72
  /** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
55
73
  #runs = new Map();
56
74
 
75
+ /** Last state after an output has no registered run. @type {Map<string, string>} */
76
+ #lastState = new Map();
77
+
78
+ /** Last encoder error per output. @type {Map<string, string>} */
79
+ #lastError = new Map();
80
+
81
+ /** Last progress snapshot per output after its run ends. @type {Map<string, object>} */
82
+ #lastProgress = new Map();
83
+
84
+ /** Consecutive fast failures at one position, per output. @type {Map<string, { at: number, count: number }>} */
85
+ #failedStarts = new Map();
57
86
 
58
87
  /** The fastest speed measured on one output, kept across restarts. @type {Map<string, number>} */
59
88
  #lastSpeed = new Map();
@@ -93,12 +122,20 @@ export class EncodeOrchestrator {
93
122
  this.#costs.noteStartup(measured);
94
123
  }
95
124
 
125
+ /** Derived segment demand for one output, copied from the discardable cache. */
126
+ wantedSegmentsOn(address) {
127
+ return [...this.#demand.mapOn(address)];
128
+ }
129
+
96
130
  /** The last reason a budget was cut, so the same one is not said twice. */
97
131
  #lastBudgetReason = new Map();
98
132
 
99
133
  /** The last unmet want said out loud, so a stuck one is said once. */
100
134
  #lastUnmet = new Map();
101
135
 
136
+ /** The last time the machine bound an output's encoders, so it is said once. */
137
+ #lastMachineBound = new Map();
138
+
102
139
  /**
103
140
  * @param {object} params
104
141
  * @param {(address: string) => number} params.maxRunsFor - How many encoders
@@ -117,6 +154,12 @@ export class EncodeOrchestrator {
117
154
  * encoding speed.
118
155
  * @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
119
156
  * @param {() => number} [params.now]
157
+ * @param {(address: string) => string} [params.describeWaits] - What the
158
+ * viewers actually waited for on this output, by band, for the state line.
159
+ * HANDED IN: what anybody waited for belongs to the priority layer, and
160
+ * this one may be asked about encoders with no ledger, no clock and no
161
+ * viewer anywhere. Absent, the line simply does not carry it.
162
+ * @param {SegmentCoverage | null} [params.segmentCoverage]
120
163
  */
121
164
  constructor({
122
165
  maxRunsFor,
@@ -125,17 +168,22 @@ export class EncodeOrchestrator {
125
168
  contentionPenalties = null,
126
169
  refetchSecPerFilmSecond = () => 0,
127
170
  startingSpeedFor = () => 0,
128
- segmentStore = null,
171
+ segmentCoverage = null,
129
172
  planSoon = null,
173
+ describeWaits = null,
174
+ admission = null,
130
175
  logger,
131
176
  now
132
177
  }) {
133
- // The store of produced segments — the layer below this one. It is asked to
134
- // clean up after a run that ended other than by reaching the end of its
135
- // stretch, which is the one thing an ending must not leave behind: a file
136
- // under a name that promises a whole segment.
137
- this.segmentStore = segmentStore;
138
- this.demand = new SegmentDemand();
178
+ // HOW MANY ENCODERS THE WHOLE MACHINE HOLDS, across outputs. `maxRunsFor`
179
+ // answers for one output as though it were alone; this is what refuses the
180
+ // encoder that would slow everybody below realtime. Absent in a check that
181
+ // is about one output, and then only the per-output limit binds.
182
+ this.admission = admission && typeof admission.placesFor === "function" ? admission : null;
183
+ this.#describeWaits = typeof describeWaits === "function" ? describeWaits : () => "";
184
+ // Only the segment facts and operations this planner needs cross the
185
+ // storage boundary; the concrete storage remains in the server assembly.
186
+ this.#segmentCoverage = segmentCoverage;
139
187
  this.maxRunsFor = maxRunsFor;
140
188
  // Seconds of swarm time per second of film: what re-encoding material that
141
189
  // already exists costs the download, over and above the encoder's own time.
@@ -200,8 +248,8 @@ export class EncodeOrchestrator {
200
248
  */
201
249
  #upToDateCoverage(address) {
202
250
  const coverage = this.coverageOf(address);
203
- if (this.segmentStore) {
204
- coverage.setReady(this.segmentStore.provenNumbers(address));
251
+ if (this.#segmentCoverage) {
252
+ coverage.setReady(this.#segmentCoverage.provenNumbers(address));
205
253
  }
206
254
  return coverage;
207
255
  }
@@ -249,6 +297,189 @@ export class EncodeOrchestrator {
249
297
  return this.#runs.get(address) ?? [];
250
298
  }
251
299
 
300
+ /**
301
+ * The aggregate state of this output's current runs, or its last ending when
302
+ * none remain.
303
+ *
304
+ * @param {string} address
305
+ * @returns {string}
306
+ */
307
+ stateOf(address) {
308
+ const runs = this.runsOn(address);
309
+ return runs.length > 0 ? runStateOf(runs) : (this.#lastState.get(address) ?? runStateOf([]));
310
+ }
311
+
312
+ /** @param {string} address @returns {string} */
313
+ errorOf(address) {
314
+ return this.#lastError.get(address) ?? "";
315
+ }
316
+
317
+ /**
318
+ * Progress of the live run that covers a position, or the most recently
319
+ * updated run when no position is supplied.
320
+ *
321
+ * @param {string} address
322
+ * @param {number | null} [index]
323
+ * @returns {object | null}
324
+ */
325
+ progressOf(address, index = null) {
326
+ const live = this.runsOn(address).filter((run) => run.isAlive);
327
+ const covering = Number.isInteger(index)
328
+ ? live.filter((run) => run.from <= index && endOfRun(run) >= index)
329
+ : live;
330
+ const candidates = covering.length > 0 ? covering : live;
331
+ const run = [...candidates].sort(
332
+ (left, right) => (right.progress?.updatedAt ?? 0) - (left.progress?.updatedAt ?? 0)
333
+ )[0];
334
+ return run?.progress?.snapshot?.() ?? this.#lastProgress.get(address) ?? null;
335
+ }
336
+
337
+ /**
338
+ * Whether another start at this position is allowed.
339
+ *
340
+ * @param {string} address
341
+ * @param {number} from
342
+ * @returns {boolean}
343
+ */
344
+ mayStartAt(address, from) {
345
+ const failed = this.#failedStarts.get(address);
346
+ return failed?.at !== from || failed.count < MAX_FAILED_STARTS;
347
+ }
348
+
349
+ /**
350
+ * Record whether a failed run died before it performed sustained work.
351
+ *
352
+ * @param {string} address
353
+ * @param {number} from
354
+ * @param {number} livedMs
355
+ * @returns {{ at: number, count: number, blocked: boolean, limit: number, fastMs: number }}
356
+ */
357
+ noteStartFailure(address, from, livedMs) {
358
+ if (!(livedMs < START_FAST_FAIL_MS)) {
359
+ this.#failedStarts.delete(address);
360
+ return { at: -1, count: 0, blocked: false, limit: MAX_FAILED_STARTS, fastMs: START_FAST_FAIL_MS };
361
+ }
362
+ const previous = this.#failedStarts.get(address);
363
+ const next = {
364
+ at: from,
365
+ count: previous?.at === from ? previous.count + 1 : 1
366
+ };
367
+ this.#failedStarts.set(address, next);
368
+ return {
369
+ ...next,
370
+ blocked: next.count >= MAX_FAILED_STARTS,
371
+ limit: MAX_FAILED_STARTS,
372
+ fastMs: START_FAST_FAIL_MS
373
+ };
374
+ }
375
+
376
+ /**
377
+ * An output that no longer exists and has no run left on it.
378
+ *
379
+ * How its last run ended, what went wrong and which position kept failing are
380
+ * facts about THAT output. The name follows from the key, so the next output
381
+ * with the same parameters has the same address; kept, those facts would
382
+ * greet it as its own — a failed ending makes its first wait throw before any
383
+ * encoder is placed, and nothing would ever clear it.
384
+ *
385
+ * @param {string} address
386
+ */
387
+ forgetOutput(address) {
388
+ if (this.runsOn(address).length > 0) {
389
+ return;
390
+ }
391
+ this.#lastState.delete(address);
392
+ this.#lastError.delete(address);
393
+ this.#lastProgress.delete(address);
394
+ this.#failedStarts.delete(address);
395
+ }
396
+
397
+ /** @param {string} address @returns {{ at: number, count: number, blocked: boolean, limit: number }} */
398
+ startFailureOf(address) {
399
+ const failure = this.#failedStarts.get(address) ?? { at: -1, count: 0 };
400
+ return { ...failure, blocked: failure.count >= MAX_FAILED_STARTS, limit: MAX_FAILED_STARTS };
401
+ }
402
+
403
+ /**
404
+ * What the map says about one segment of one output, and about the map it
405
+ * sits in.
406
+ *
407
+ * Asked by whoever is holding a request and measuring the wait: a wait means
408
+ * one thing at the top rank and another at the bottom, and telling them apart
409
+ * needs both the segment's own rank and the highest rank stated, since a rank
410
+ * is a position in this map and not an absolute number.
411
+ *
412
+ * AND AN EMPTY MAP IS NOT AN ANSWER. A top rank of zero says the map has not
413
+ * been built yet — a session created a moment ago, before the first pass —
414
+ * and that is a different thing from "nobody is coming". Conflated, every
415
+ * request behind the run reads as absent for as long as a fresh session has
416
+ * no map.
417
+ *
418
+ * It exists because the one caller reached through this object into the store
419
+ * behind it. A reach like that is how a store becomes the surface: the day it
420
+ * changes, a caller two layers away breaks for a reason it cannot see.
421
+ *
422
+ * @param {string} address
423
+ * @param {number} index
424
+ * @returns {{ rank: number, topRank: number }}
425
+ */
426
+ rankAt(address, index) {
427
+ return this.#demand.rankOf(address, index);
428
+ }
429
+
430
+ /**
431
+ * How many encoder processes are running on this machine right now, over
432
+ * every output.
433
+ *
434
+ * ONE COUNT, and it is this class's because this class is what makes and
435
+ * unmakes runs: adopted the moment one exists and forgotten the moment one
436
+ * ends, both synchronously. The session manager answered the same question
437
+ * twice by walking its own registry — once per SESSION and once per RUN —
438
+ * which agreed only while a session held at most one run. It has held several
439
+ * since the plan was allowed to place them, and the per-session count is the
440
+ * wrong one of the two wherever they differ: what a second encoder costs was
441
+ * measured between ffmpeg PROCESSES, not between outputs.
442
+ *
443
+ * A SUSPENDED run is not counted. It is stopped where it stands and is using
444
+ * nothing; a cost measured beside it is a cost measured alone.
445
+ *
446
+ * @returns {number}
447
+ */
448
+ /**
449
+ * How many encoders are alive on each output, suspended ones included.
450
+ *
451
+ * What the machine-wide admission counts as holding a place. A suspended
452
+ * encoder is counted here, unlike in {@link runningCount}: that one answers
453
+ * what is competing for the processor this instant, this one what the
454
+ * machine is committed to — and a parked encoder resumes the moment the
455
+ * viewer catches up.
456
+ *
457
+ * @returns {Map<string, number>}
458
+ */
459
+ liveRunsByAddress() {
460
+ /** @type {Map<string, number>} */
461
+ const byAddress = new Map();
462
+ for (const [address, runs] of this.#runs) {
463
+ const alive = runs.filter((run) => run.isAlive).length;
464
+ if (alive > 0) {
465
+ byAddress.set(address, alive);
466
+ }
467
+ }
468
+ return byAddress;
469
+ }
470
+
471
+ runningCount() {
472
+ let running = 0;
473
+ for (const runs of this.#runs.values()) {
474
+ for (const run of runs) {
475
+ if (run.isAlive && !run.isSuspended) {
476
+ running += 1;
477
+ }
478
+ }
479
+ }
480
+ return running;
481
+ }
482
+
252
483
  /**
253
484
  * How long an output is, once its playlist is known.
254
485
  *
@@ -307,7 +538,7 @@ export class EncodeOrchestrator {
307
538
  * @param {{ from: number, to: number, priority: number, withinSeconds: number }[]} zones
308
539
  */
309
540
  notePriorityMap(address, zones) {
310
- this.demand.state(address, zones);
541
+ this.#demand.state(address, zones);
311
542
  // NOBODY IS COMING HERE, so what was remembered about this output's input
312
543
  // is about nobody. Kept, those three entries would stay for the life of the
313
544
  // process, and the wait they describe would greet whoever opens this output
@@ -328,8 +559,9 @@ export class EncodeOrchestrator {
328
559
  // Nothing is told to the store: by the time this is called the piece is
329
560
  // ALREADY under its served name, because the rename is what closing it
330
561
  // means. A statement kept beside the disk would be a second owner of one
331
- // fact, which is what item 87 removed from the coverage map.
562
+ // fact, which removed the stale second owner from the coverage map.
332
563
  this.coverageOf(address).markReady(index);
564
+ this.#segmentCoverage?.announce?.(address, index);
333
565
  for (const run of this.runsOn(address)) {
334
566
  run.noteProduced(index);
335
567
  }
@@ -364,7 +596,7 @@ export class EncodeOrchestrator {
364
596
  * state, so a pass that finds nothing to change does nothing.
365
597
  */
366
598
  reconcile() {
367
- const addresses = new Set([...this.demand.addresses(), ...this.#runs.keys()]);
599
+ const addresses = new Set([...this.#demand.addresses(), ...this.#runs.keys()]);
368
600
  for (const address of addresses) {
369
601
  this.#reconcileOne(address);
370
602
  }
@@ -433,12 +665,12 @@ export class EncodeOrchestrator {
433
665
  // used to reach into the layer that STATES them for the same arithmetic,
434
666
  // which is the coupling the layer rule forbids; the arithmetic itself now
435
667
  // lives where it belongs to nobody.
436
- const windows = this.demand.mapOn(address);
668
+ const windows = this.#demand.mapOn(address);
437
669
  const live = this.runsOn(address).filter((run) => run.isAlive);
438
670
  // Asked ONCE. It is arithmetic over measurements, but it also says out loud
439
671
  // when the reason it cuts the budget changes, so asking it three times in
440
672
  // one pass is three chances to say a thing that happened once.
441
- const maxRuns = this.#affordableOn(address, live);
673
+ const maxRuns = this.#withinMachine(address, live, this.#affordableOn(address, live));
442
674
  // THE TERMS EVERY ARRIVAL IS COMPUTED FROM, named here so the line below can
443
675
  // print them. A decision of this plan is `delay + (index - at) / rate +
444
676
  // madeBetween * refetch` against a deadline, so without the rate and the two
@@ -583,7 +815,7 @@ export class EncodeOrchestrator {
583
815
  this.#lastUnmet.delete(address);
584
816
  } else if (this.#lastUnmet.get(address) !== wanting) {
585
817
  this.#lastUnmet.set(address, wanting);
586
- const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
818
+ const held = this.#segmentCoverage ? this.#segmentCoverage.filesHeld(address) : -1;
587
819
  this.logger.warn(
588
820
  `encode: #${wanting} of ${address} is wanted and NO ENCODER IS MAKING IT — ` +
589
821
  `ready=${coverage.stats().ready} of ${coverage.segmentCount} ` +
@@ -691,6 +923,42 @@ export class EncodeOrchestrator {
691
923
  return budget.runs;
692
924
  }
693
925
 
926
+ /**
927
+ * The per-output limit, bounded by what the whole machine can still hold.
928
+ *
929
+ * The per-output answer is at least one whatever else is running; the
930
+ * machine's may be none, and that is the refusal: the output gets no encoder
931
+ * until one elsewhere ends. It is never below what already runs here, so a
932
+ * refusal stops a start and never takes an encoder away.
933
+ *
934
+ * Said out loud when the machine is what bound it, once per change.
935
+ *
936
+ * @param {string} address
937
+ * @param {object[]} live
938
+ * @param {number} perOutput
939
+ * @returns {number}
940
+ */
941
+ #withinMachine(address, live, perOutput) {
942
+ if (!this.admission) {
943
+ return perOutput;
944
+ }
945
+ const machine = this.admission.placesFor(address, live.length);
946
+ const runs = Math.min(perOutput, machine.runs);
947
+ const said = runs < perOutput ? `${runs}:${machine.because}` : "";
948
+ if (said !== (this.#lastMachineBound.get(address) ?? "")) {
949
+ if (said) {
950
+ this.logger.info(
951
+ `encode: ${runs} encoder(s) on ${address} — the whole machine holds no more ` +
952
+ `(${machine.because}; this output alone would be allowed ${perOutput})`
953
+ );
954
+ this.#lastMachineBound.set(address, said);
955
+ } else {
956
+ this.#lastMachineBound.delete(address);
957
+ }
958
+ }
959
+ return runs;
960
+ }
961
+
694
962
  /**
695
963
  * Take charge of a run this class did not start.
696
964
  *
@@ -734,6 +1002,16 @@ export class EncodeOrchestrator {
734
1002
  * @param {import("../encode/EncodeRun.js").RunEnded} ended
735
1003
  */
736
1004
  noteEnded(ended) {
1005
+ this.#lastState.set(ended.address, ended.run?.state ?? "IDLE");
1006
+ if (ended.lastError) {
1007
+ this.#lastError.set(ended.address, ended.lastError);
1008
+ } else if (ended.because && ended.ending !== ENCODE_EXIT.COMPLETE) {
1009
+ this.#lastError.set(ended.address, ended.because);
1010
+ }
1011
+ const progress = ended.run?.progress?.snapshot?.();
1012
+ if (progress) {
1013
+ this.#lastProgress.set(ended.address, progress);
1014
+ }
737
1015
  this.#costs.note(ended);
738
1016
  // Exactly one ending is normal — the run reached the end of the stretch it
739
1017
  // was given and closed its last file. Every other leaves a piece open, and
@@ -741,7 +1019,7 @@ export class EncodeOrchestrator {
741
1019
  // is a name match: no stretch to search and no bytes to judge. Done for
742
1020
  // every ending, the normal one included, since a run that finished cleanly
743
1021
  // has nothing under a working name and the sweep then removes nothing.
744
- this.segmentStore?.clearUpAfter(ended.address, ended.from);
1022
+ this.#segmentCoverage?.clearUpAfter(ended.address, ended.from);
745
1023
  this.coverageOf(ended.address).release(ended.run);
746
1024
  const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
747
1025
  if (remaining.length === 0) {
@@ -889,9 +1167,9 @@ export class EncodeOrchestrator {
889
1167
  */
890
1168
  describe() {
891
1169
  const parts = [];
892
- for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
1170
+ for (const address of new Set([...this.#demand.addresses(), ...this.#runs.keys()])) {
893
1171
  const coverage = this.coverageOf(address);
894
- const stated = this.demand.mapOn(address);
1172
+ const stated = this.#demand.mapOn(address);
895
1173
  const windows = stated.map((zone) => ({ from: zone.from, to: zone.to }));
896
1174
  const waiting = firstUnmetWant(coverage, windows);
897
1175
  const runs = this.runsOn(address)
@@ -914,14 +1192,14 @@ export class EncodeOrchestrator {
914
1192
  // different statements: files with nothing proving them closed reads as a
915
1193
  // reporting fault, no files at all reads as an output yet to be made, and
916
1194
  // the difference decides where to look.
917
- const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
1195
+ const held = this.#segmentCoverage ? this.#segmentCoverage.filesHeld(address) : -1;
918
1196
  // AND WHETHER THE MAP IS BEING SERVED IN ITS OWN ORDER. The zones say
919
1197
  // what matters most; this says what the viewer actually waited for, by
920
1198
  // band. Waits at `now` are the ones that cost a spinner, and until this
921
1199
  // existed there was no figure anywhere saying whether the urgent zone was
922
1200
  // served first — the map could have been read backwards and every line
923
1201
  // above would have looked the same.
924
- const served = waits.describe(address);
1202
+ const served = this.#describeWaits(address);
925
1203
  parts.push(
926
1204
  `${address} ready=${coverage.stats().ready}` +
927
1205
  `${held < 0 ? "" : ` of ${held} file(s) on disk`} ` +
@@ -39,6 +39,7 @@
39
39
 
40
40
  import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
41
41
  import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
42
+ import { RunProgress } from "./RunProgress.js";
42
43
 
43
44
  /** Microseconds in a second, as ffmpeg's `out_time_ms` counts them. */
44
45
  const MICROSECONDS_PER_SECOND = 1_000_000;
@@ -186,12 +187,8 @@ export class EncodeRun {
186
187
  * encoder itself names it on its own channel, and answers with the name that
187
188
  * piece is served under — because making it servable is a rename, and only
188
189
  * whoever owns the disk can perform one.
189
- * @param {(progress: { processedSeconds: number | null, speed: string | null }) => void} [params.onProgress]
190
- * Called for every `-progress` report. Seconds count from the START OF THIS
191
- * RUN on both branches — neither `-output_ts_offset` nor `-copyts` changes
192
- * what `-progress` reports, both measured — so rebasing them onto the
193
- * source's timeline is the caller's, which is the only side that knows
194
- * where this run began.
190
+ * @param {(progress: object) => void} [params.onProgress] - Called with this
191
+ * run's progress on the source timeline after every `-progress` report.
195
192
  * @param {() => number | null} [params.lastSegmentIndex] - The film's last
196
193
  * segment number, for telling "reached the end" from "the input dried up".
197
194
  * ffmpeg exits zero for both, and over a torrent it cannot tell them apart.
@@ -208,6 +205,8 @@ export class EncodeRun {
208
205
  * a closed piece's name carries. How a piece is named belongs to the format
209
206
  * that writes it, so it arrives as a plain function rather than this class
210
207
  * knowing any naming.
208
+ * @param {number} [params.startSeconds] - Where this run begins on the source timeline.
209
+ * @param {number | null} [params.totalSeconds] - Source duration when known.
211
210
  */
212
211
  constructor({
213
212
  address,
@@ -226,6 +225,8 @@ export class EncodeRun {
226
225
  argsDescribed = "",
227
226
  usesExplicitCuts = false,
228
227
  indexOfName,
228
+ startSeconds = 0,
229
+ totalSeconds = null,
229
230
  because = "no reason was given"
230
231
  }) {
231
232
  this.address = address;
@@ -236,6 +237,7 @@ export class EncodeRun {
236
237
  this.spawnProcess = spawn;
237
238
  this.logger = logger;
238
239
  this.now = typeof now === "function" ? now : Date.now;
240
+ this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now });
239
241
  this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
240
242
  this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
241
243
  // Told the NAME of every piece the encoder has closed. The name is the
@@ -449,17 +451,22 @@ export class EncodeRun {
449
451
  if (key === "out_time_ms") {
450
452
  const numeric = Number(value);
451
453
  if (Number.isFinite(numeric) && numeric >= 0) {
452
- this.onProgress({ processedSeconds: numeric / MICROSECONDS_PER_SECOND, speed: null });
454
+ this.#reportProgress({ processedSeconds: numeric / MICROSECONDS_PER_SECOND, speed: null });
453
455
  }
454
456
  } else if (key === "out_time") {
455
- this.onProgress({ processedSeconds: null, speed: null, outTime: value });
457
+ this.#reportProgress({ processedSeconds: null, speed: null, outTime: value });
456
458
  } else if (key === "speed") {
457
459
  this.noteSpeed(Number.parseFloat(value));
458
- this.onProgress({ processedSeconds: null, speed: value });
460
+ this.#reportProgress({ processedSeconds: null, speed: value });
459
461
  }
460
462
  }
461
463
  }
462
464
 
465
+ #reportProgress(report) {
466
+ this.progress.note(report);
467
+ this.onProgress(this.progress.snapshot());
468
+ }
469
+
463
470
  /**
464
471
  * A segment this run has finished.
465
472
  *