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