@torrent-tv/proxy 2.87.5 → 2.87.7
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/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +8 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/browser-proxy-contract.md +101 -94
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -0
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +129 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +361 -361
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/server.js +4 -0
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2162 -2162
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1083 -1083
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +266 -266
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +245 -212
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +131 -59
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1146 -1146
- package/services/server/ViewerRequests.js +522 -522
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -36
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +983 -983
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2 -2
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +1 -1
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/data-channel-handler.js +1356 -1356
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +657 -657
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-inventory.test.js +212 -176
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +35 -35
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +161 -161
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/{services/torrent → utils}/torrent-source-key.js +38 -38
|
@@ -1,361 +1,361 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Which encoding modes this machine may use, and what each costs at
|
|
3
|
-
* every size it may be asked for — measured at startup, before any viewer
|
|
4
|
-
* (roadmap item 97, step 14).
|
|
5
|
-
*
|
|
6
|
-
* A MODE is one encoder at one of the settings the proxy can actually select:
|
|
7
|
-
* every libx264 preset the offer chooses from, and the one setting a hardware
|
|
8
|
-
* encoder is driven at. A mode is usable only if it has been shown here to do
|
|
9
|
-
* two things on THIS machine:
|
|
10
|
-
*
|
|
11
|
-
* 1. produce a correct stream through the product's own arguments — the same
|
|
12
|
-
* `buildVideoArgs` a real output is encoded with, into fMP4 segments, each
|
|
13
|
-
* of which must then decode with no error. A device being present, or one
|
|
14
|
-
* of its settings passing, qualifies nothing else;
|
|
15
|
-
* 2. encode at a known speed at the size it is asked for.
|
|
16
|
-
*
|
|
17
|
-
* WHY SEVERAL SIZES. The speed used to be read at 640x360 alone and scaled to
|
|
18
|
-
* every other size by pixel count. Measured 2026-08-22, cost per pixel is not
|
|
19
|
-
* constant: 1280x720 came back at 0.445x the predicted throughput on
|
|
20
|
-
* `ultrafast` and 0.725x on `fast` (`research/preset-benchmark-size-scaling-
|
|
21
|
-
* 2026-08-22.md`). So each mode is read at a set of sizes that spans the
|
|
22
|
-
* ladder, and a size between two readings is priced by interpolating between
|
|
23
|
-
* them — never beyond the smallest or the largest one read. A frame outside
|
|
24
|
-
* what was read is a mode this machine has not shown it can do, and is not
|
|
25
|
-
* offered.
|
|
26
|
-
*
|
|
27
|
-
* HOW FAR AN INTERPOLATION MAY BE TRUSTED is measured too, not assumed: where
|
|
28
|
-
* three consecutive sizes were read, the middle one is predicted from the two
|
|
29
|
-
* outside it and compared with its own reading. The largest such error for the
|
|
30
|
-
* mode is its error bound, and every interpolated figure is lowered by it. A
|
|
31
|
-
* mode with fewer than three sizes read has no bound, and is then used only at
|
|
32
|
-
* the sizes it was read at.
|
|
33
|
-
*
|
|
34
|
-
* WHAT IS NOT READ, so the startup stays short. Sizes are read from the
|
|
35
|
-
* smallest up and, at each size, modes from the fastest down. A mode that
|
|
36
|
-
* cannot keep up at a size ends that size: every slower mode is slower still.
|
|
37
|
-
* A size at which even the fastest mode cannot keep up ends the walk: every
|
|
38
|
-
* larger size is larger still. What is left unread is unavailable, which is
|
|
39
|
-
* the answer it would have got anyway.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
43
|
-
import os from "node:os";
|
|
44
|
-
import path from "node:path";
|
|
45
|
-
import { softwareRateControlFor, TRANSCODE_FPS } from "./args.js";
|
|
46
|
-
import {
|
|
47
|
-
CALIBRATION_DIR,
|
|
48
|
-
CALIBRATION_REFERENCE_CLIP,
|
|
49
|
-
decodeToRawFrames,
|
|
50
|
-
measureEncodeSlope,
|
|
51
|
-
runFfmpeg,
|
|
52
|
-
verifySegmentsDecodeCleanly
|
|
53
|
-
} from "./hwaccel.js";
|
|
54
|
-
import { interpolationErrorOf } from "./throughput.js";
|
|
55
|
-
|
|
56
|
-
export { throughputAt, interpolationErrorOf } from "./throughput.js";
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The sizes a mode is read at, smallest first.
|
|
60
|
-
*
|
|
61
|
-
* The smallest is below every frame the ladder produces — its lowest row is
|
|
62
|
-
* 426x240, and a 4:3 or narrower source gives less area than that at the same
|
|
63
|
-
* height. The largest is the 2160 row. The two between are the 720 and 1080
|
|
64
|
-
* rows, where most films are watched. 640x360 is the reference size every
|
|
65
|
-
* earlier reading was taken at, so the figures stay comparable with them.
|
|
66
|
-
*/
|
|
67
|
-
export const CALIBRATION_FRAMES = Object.freeze([
|
|
68
|
-
Object.freeze({ width: 256, height: 144 }),
|
|
69
|
-
Object.freeze({ width: 640, height: 360 }),
|
|
70
|
-
Object.freeze({ width: 1280, height: 720 }),
|
|
71
|
-
Object.freeze({ width: 1920, height: 1080 }),
|
|
72
|
-
Object.freeze({ width: 3840, height: 2160 })
|
|
73
|
-
]);
|
|
74
|
-
|
|
75
|
-
/** The size the correctness check encodes at, and every earlier reading was taken at. */
|
|
76
|
-
const CHECK_FRAME = Object.freeze({ width: 640, height: 360 });
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* @typedef {object} SizeReading
|
|
80
|
-
* @property {number} width
|
|
81
|
-
* @property {number} height
|
|
82
|
-
* @property {number} pixelsPerSec
|
|
83
|
-
*
|
|
84
|
-
* @typedef {object} CalibratedMode
|
|
85
|
-
* @property {string} preset - The setting, or the encoder's name where it has one setting.
|
|
86
|
-
* @property {number} pixelsPerSec - At 640x360, for everything that has always read that figure.
|
|
87
|
-
* @property {SizeReading[]} bySize - Smallest first.
|
|
88
|
-
* @property {number | null} interpolationError - The largest measured relative
|
|
89
|
-
* error of an interpolation between two readings, or null where it could not
|
|
90
|
-
* be measured.
|
|
91
|
-
* @property {{ averageKbps: number, peakKbps: number } | null} segmentKbps -
|
|
92
|
-
* What the correctness check's segments carried.
|
|
93
|
-
*/
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The settings a mode is known by, as text, for the log and for the key its
|
|
97
|
-
* later observations are filed under.
|
|
98
|
-
*
|
|
99
|
-
* @param {{ benchmarkArgs: (rung: string | null) => string[] }} encoder
|
|
100
|
-
* @param {string | null} rung
|
|
101
|
-
* @returns {string}
|
|
102
|
-
*/
|
|
103
|
-
export function settingsOf(encoder, rung) {
|
|
104
|
-
return encoder.benchmarkArgs(rung).join(" ");
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Encode the calibration clip through the product's own arguments for this
|
|
109
|
-
* mode, and check every segment decodes on its own.
|
|
110
|
-
*
|
|
111
|
-
* @param {{ ffmpegBin: string, encoder: object, rung: string | null, segmentDurationSec: number }} params
|
|
112
|
-
* @returns {Promise<{ ok: boolean, reason: string, segmentKbps: { averageKbps: number, peakKbps: number } | null }>}
|
|
113
|
-
*/
|
|
114
|
-
export async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, rung, segmentDurationSec }) {
|
|
115
|
-
let directory;
|
|
116
|
-
try {
|
|
117
|
-
directory = mkdtempSync(path.join(os.tmpdir(), "tt-modecheck-"));
|
|
118
|
-
} catch (error) {
|
|
119
|
-
return { ok: false, reason: `no writable temp directory (${error instanceof Error ? error.message : String(error)})`, segmentKbps: null };
|
|
120
|
-
}
|
|
121
|
-
// Three segments' worth, so at least two are cut and each is checked alone.
|
|
122
|
-
const seconds = segmentDurationSec * 3;
|
|
123
|
-
const rateControl = encoder.kind === "software"
|
|
124
|
-
? softwareRateControlFor({ ...CHECK_FRAME, fps: TRANSCODE_FPS })
|
|
125
|
-
: null;
|
|
126
|
-
const args = [
|
|
127
|
-
"-hide_banner", "-loglevel", "error",
|
|
128
|
-
...encoder.inputArgs,
|
|
129
|
-
"-stream_loop", "-1",
|
|
130
|
-
"-i", path.join(CALIBRATION_DIR, CALIBRATION_REFERENCE_CLIP),
|
|
131
|
-
"-t", String(seconds),
|
|
132
|
-
"-map", "0:v:0", "-an",
|
|
133
|
-
...encoder.buildVideoArgs({
|
|
134
|
-
targetWidth: CHECK_FRAME.width,
|
|
135
|
-
targetHeight: CHECK_FRAME.height,
|
|
136
|
-
segmentDurationSec,
|
|
137
|
-
preset: rung ?? undefined,
|
|
138
|
-
fps: TRANSCODE_FPS,
|
|
139
|
-
tonemap: false,
|
|
140
|
-
forcedKeyframeTimes: null,
|
|
141
|
-
rateControl
|
|
142
|
-
}),
|
|
143
|
-
"-f", "hls",
|
|
144
|
-
"-hls_time", String(segmentDurationSec),
|
|
145
|
-
"-hls_list_size", "0",
|
|
146
|
-
"-hls_flags", "independent_segments",
|
|
147
|
-
"-hls_segment_type", "fmp4",
|
|
148
|
-
"-hls_fmp4_init_filename", "init.mp4",
|
|
149
|
-
"-hls_segment_filename", path.join(directory, "seg-%03d.m4s"),
|
|
150
|
-
path.join(directory, "index.m3u8")
|
|
151
|
-
];
|
|
152
|
-
try {
|
|
153
|
-
const encoded = await runFfmpeg(ffmpegBin, args, 60_000);
|
|
154
|
-
if (encoded.code !== 0) {
|
|
155
|
-
const line = encoded.stderr.trim().split(/\r?\n/).pop() ?? "";
|
|
156
|
-
return { ok: false, reason: `the encode failed (exit ${encoded.code}${line ? `: ${line}` : ""})`, segmentKbps: null };
|
|
157
|
-
}
|
|
158
|
-
if (!(await verifySegmentsDecodeCleanly(ffmpegBin, directory))) {
|
|
159
|
-
return { ok: false, reason: "a segment it produced does not decode on its own", segmentKbps: null };
|
|
160
|
-
}
|
|
161
|
-
const sizes = readdirSync(directory)
|
|
162
|
-
.filter((name) => /^seg-\d+\.m4s$/.test(name))
|
|
163
|
-
.sort()
|
|
164
|
-
.map((name) => statSync(path.join(directory, name)).size);
|
|
165
|
-
// The last segment may be short; only whole ones say what a segment carries.
|
|
166
|
-
const whole = sizes.slice(0, Math.max(1, sizes.length - 1));
|
|
167
|
-
const kbps = whole.map((bytes) => (bytes * 8) / segmentDurationSec / 1000);
|
|
168
|
-
return {
|
|
169
|
-
ok: true,
|
|
170
|
-
reason: "",
|
|
171
|
-
segmentKbps: {
|
|
172
|
-
averageKbps: kbps.reduce((sum, value) => sum + value, 0) / kbps.length,
|
|
173
|
-
peakKbps: Math.max(...kbps)
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
} finally {
|
|
177
|
-
try {
|
|
178
|
-
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
179
|
-
} catch {
|
|
180
|
-
// A temp directory the operating system will clear.
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Qualify and measure every selectable mode of one encoder.
|
|
187
|
-
*
|
|
188
|
-
* Always resolves. A mode that fails either half is left out, with the reason
|
|
189
|
-
* logged; an encoder none of whose modes qualify comes back with no modes,
|
|
190
|
-
* and nothing on this host is then re-encoded with it.
|
|
191
|
-
*
|
|
192
|
-
* @param {object} params
|
|
193
|
-
* @param {string} params.ffmpegBin
|
|
194
|
-
* @param {object} params.encoder - An `Encoder`.
|
|
195
|
-
* @param {{ info: (m: string) => void, warn: (m: string) => void }} [params.logger]
|
|
196
|
-
* @param {number} [params.segmentDurationSec]
|
|
197
|
-
* @param {Array<{ width: number, height: number }>} [params.frames]
|
|
198
|
-
* @param {object} [params.measure] - Replaces the three readings, for a check
|
|
199
|
-
* that must not run ffmpeg: `check`, `rawFrames`, `speed`.
|
|
200
|
-
* @returns {Promise<{ modes: CalibratedMode[], refused: Array<{ preset: string, reason: string }> }>}
|
|
201
|
-
*/
|
|
202
|
-
export async function calibrateEncoder({
|
|
203
|
-
ffmpegBin,
|
|
204
|
-
encoder,
|
|
205
|
-
logger,
|
|
206
|
-
segmentDurationSec = 4,
|
|
207
|
-
frames = CALIBRATION_FRAMES,
|
|
208
|
-
measure = null
|
|
209
|
-
}) {
|
|
210
|
-
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
211
|
-
const check = measure?.check ?? checkModeProducesCorrectSegments;
|
|
212
|
-
const rawFrames = measure?.rawFrames ?? ((frame) => decodeToRawFrames(ffmpegBin, log, frame));
|
|
213
|
-
const speed = measure?.speed ?? ((rung, rawPath, frame) => measureEncodeSlope(ffmpegBin, encoder, rung, rawPath, frame));
|
|
214
|
-
const release = measure?.release ?? ((rawPath) => {
|
|
215
|
-
try {
|
|
216
|
-
rmSync(path.dirname(rawPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
217
|
-
} catch {
|
|
218
|
-
// A temp directory the operating system will clear.
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
const rungs = Array.isArray(encoder.selectableRungs) && encoder.selectableRungs.length > 0
|
|
222
|
-
? encoder.selectableRungs
|
|
223
|
-
: [null];
|
|
224
|
-
const startedAt = Date.now();
|
|
225
|
-
|
|
226
|
-
/** @type {Map<string | null, { ok: boolean, reason: string, segmentKbps: object | null }>} */
|
|
227
|
-
const checked = new Map();
|
|
228
|
-
for (const rung of rungs) {
|
|
229
|
-
const answer = await check({ ffmpegBin, encoder, rung, segmentDurationSec });
|
|
230
|
-
checked.set(rung, answer);
|
|
231
|
-
if (!answer.ok) {
|
|
232
|
-
log.warn(`calibration: ${encoder.name} "${rung ?? "as it comes"}" is not used on this host — ${answer.reason}`);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
const qualified = rungs.filter((rung) => checked.get(rung)?.ok === true);
|
|
236
|
-
|
|
237
|
-
/** @type {Map<string | null, SizeReading[]>} */
|
|
238
|
-
const readings = new Map(qualified.map((rung) => [rung, []]));
|
|
239
|
-
const sortedFrames = [...frames].sort((left, right) => left.width * left.height - right.width * right.height);
|
|
240
|
-
for (const frame of sortedFrames) {
|
|
241
|
-
if (qualified.length === 0) {
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
const rawPath = await rawFrames(frame);
|
|
245
|
-
if (rawPath === null) {
|
|
246
|
-
log.warn(`calibration: no frames at ${frame.width}x${frame.height}; ${encoder.name} is not read there or above`);
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
let fastestKeptUp = false;
|
|
250
|
-
try {
|
|
251
|
-
// Fastest first: the ladder is slowest first, so walk it backwards.
|
|
252
|
-
for (let index = qualified.length - 1; index >= 0; index -= 1) {
|
|
253
|
-
const rung = qualified[index];
|
|
254
|
-
const realtimes = await speed(rung, rawPath, frame);
|
|
255
|
-
if (realtimes === null) {
|
|
256
|
-
log.warn(`calibration: ${encoder.name} "${rung ?? "as it comes"}" gave no reading at ${frame.width}x${frame.height}`);
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
readings.get(rung).push({
|
|
260
|
-
width: frame.width,
|
|
261
|
-
height: frame.height,
|
|
262
|
-
pixelsPerSec: frame.width * frame.height * TRANSCODE_FPS * realtimes
|
|
263
|
-
});
|
|
264
|
-
if (index === qualified.length - 1) {
|
|
265
|
-
fastestKeptUp = realtimes >= 1;
|
|
266
|
-
}
|
|
267
|
-
if (realtimes < 1) {
|
|
268
|
-
// Every slower mode is slower still at this size.
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
} finally {
|
|
273
|
-
release(rawPath);
|
|
274
|
-
}
|
|
275
|
-
if (!fastestKeptUp) {
|
|
276
|
-
// Every larger size is larger still.
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** @type {CalibratedMode[]} */
|
|
282
|
-
const modes = [];
|
|
283
|
-
/** @type {Array<{ preset: string, reason: string }>} */
|
|
284
|
-
const refused = [];
|
|
285
|
-
for (const rung of rungs) {
|
|
286
|
-
const preset = rung ?? encoder.name;
|
|
287
|
-
if (checked.get(rung)?.ok !== true) {
|
|
288
|
-
refused.push({ preset, reason: checked.get(rung)?.reason ?? "not checked" });
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
const bySize = readings.get(rung) ?? [];
|
|
292
|
-
if (bySize.length === 0) {
|
|
293
|
-
refused.push({ preset, reason: "no size was read" });
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
const reference = bySize.find((reading) => reading.width === CHECK_FRAME.width && reading.height === CHECK_FRAME.height) ?? bySize[0];
|
|
297
|
-
const interpolationError = interpolationErrorOf(bySize);
|
|
298
|
-
modes.push({
|
|
299
|
-
preset,
|
|
300
|
-
pixelsPerSec: reference.pixelsPerSec,
|
|
301
|
-
bySize,
|
|
302
|
-
interpolationError,
|
|
303
|
-
segmentKbps: checked.get(rung).segmentKbps
|
|
304
|
-
});
|
|
305
|
-
log.info(
|
|
306
|
-
`calibration: ${encoder.name} "${preset}" ` +
|
|
307
|
-
bySize.map((reading) => `${reading.width}x${reading.height}=${(reading.pixelsPerSec / 1e6).toFixed(1)}Mpx/s`).join(" ") +
|
|
308
|
-
` interpolation error ${interpolationError === null ? "not measured, used only at the sizes read" : `${(interpolationError * 100).toFixed(1)}%`}` +
|
|
309
|
-
(checked.get(rung).segmentKbps
|
|
310
|
-
? ` segments ${Math.round(checked.get(rung).segmentKbps.averageKbps)}kbps average, ${Math.round(checked.get(rung).segmentKbps.peakKbps)}kbps peak at ${CHECK_FRAME.width}x${CHECK_FRAME.height}`
|
|
311
|
-
: "")
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
log.info(
|
|
315
|
-
`calibration: ${encoder.name} ${modes.length} of ${rungs.length} mode(s) usable ` +
|
|
316
|
-
`(${((Date.now() - startedAt) / 1000).toFixed(1)}s)` +
|
|
317
|
-
(refused.length > 0 ? `; not usable: ${refused.map((one) => `${one.preset} (${one.reason})`).join(", ")}` : "")
|
|
318
|
-
);
|
|
319
|
-
return { modes, refused };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* What every encoder this proxy may use was measured to do, by kind: the one
|
|
324
|
-
* detection chose, and software, which is where a failing hardware encoder
|
|
325
|
-
* falls back to for the rest of the process.
|
|
326
|
-
*/
|
|
327
|
-
export class HostCalibration {
|
|
328
|
-
/** @type {Map<string, CalibratedMode[]>} */
|
|
329
|
-
#byKind;
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* @param {object} params
|
|
333
|
-
* @param {Record<string, CalibratedMode[]>} params.byKind
|
|
334
|
-
* @param {import("./fingerprint.js").HostFingerprint | null} [params.fingerprint]
|
|
335
|
-
*/
|
|
336
|
-
constructor({ byKind, fingerprint = null }) {
|
|
337
|
-
this.#byKind = new Map(Object.entries(byKind ?? {}).map(([kind, modes]) => [kind, Array.isArray(modes) ? modes : []]));
|
|
338
|
-
this.fingerprint = fingerprint;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* The usable modes of one encoder kind, slowest first — the shape the offer
|
|
343
|
-
* and the preset choice have always read. Null where that kind was not
|
|
344
|
-
* calibrated at all.
|
|
345
|
-
*
|
|
346
|
-
* @param {string | null | undefined} kind
|
|
347
|
-
* @returns {CalibratedMode[] | null}
|
|
348
|
-
*/
|
|
349
|
-
modesFor(kind) {
|
|
350
|
-
return kind && this.#byKind.has(kind) ? this.#byKind.get(kind) : null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Every kind that was calibrated.
|
|
355
|
-
*
|
|
356
|
-
* @returns {string[]}
|
|
357
|
-
*/
|
|
358
|
-
kinds() {
|
|
359
|
-
return [...this.#byKind.keys()];
|
|
360
|
-
}
|
|
361
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Which encoding modes this machine may use, and what each costs at
|
|
3
|
+
* every size it may be asked for — measured at startup, before any viewer
|
|
4
|
+
* (roadmap item 97, step 14).
|
|
5
|
+
*
|
|
6
|
+
* A MODE is one encoder at one of the settings the proxy can actually select:
|
|
7
|
+
* every libx264 preset the offer chooses from, and the one setting a hardware
|
|
8
|
+
* encoder is driven at. A mode is usable only if it has been shown here to do
|
|
9
|
+
* two things on THIS machine:
|
|
10
|
+
*
|
|
11
|
+
* 1. produce a correct stream through the product's own arguments — the same
|
|
12
|
+
* `buildVideoArgs` a real output is encoded with, into fMP4 segments, each
|
|
13
|
+
* of which must then decode with no error. A device being present, or one
|
|
14
|
+
* of its settings passing, qualifies nothing else;
|
|
15
|
+
* 2. encode at a known speed at the size it is asked for.
|
|
16
|
+
*
|
|
17
|
+
* WHY SEVERAL SIZES. The speed used to be read at 640x360 alone and scaled to
|
|
18
|
+
* every other size by pixel count. Measured 2026-08-22, cost per pixel is not
|
|
19
|
+
* constant: 1280x720 came back at 0.445x the predicted throughput on
|
|
20
|
+
* `ultrafast` and 0.725x on `fast` (`research/preset-benchmark-size-scaling-
|
|
21
|
+
* 2026-08-22.md`). So each mode is read at a set of sizes that spans the
|
|
22
|
+
* ladder, and a size between two readings is priced by interpolating between
|
|
23
|
+
* them — never beyond the smallest or the largest one read. A frame outside
|
|
24
|
+
* what was read is a mode this machine has not shown it can do, and is not
|
|
25
|
+
* offered.
|
|
26
|
+
*
|
|
27
|
+
* HOW FAR AN INTERPOLATION MAY BE TRUSTED is measured too, not assumed: where
|
|
28
|
+
* three consecutive sizes were read, the middle one is predicted from the two
|
|
29
|
+
* outside it and compared with its own reading. The largest such error for the
|
|
30
|
+
* mode is its error bound, and every interpolated figure is lowered by it. A
|
|
31
|
+
* mode with fewer than three sizes read has no bound, and is then used only at
|
|
32
|
+
* the sizes it was read at.
|
|
33
|
+
*
|
|
34
|
+
* WHAT IS NOT READ, so the startup stays short. Sizes are read from the
|
|
35
|
+
* smallest up and, at each size, modes from the fastest down. A mode that
|
|
36
|
+
* cannot keep up at a size ends that size: every slower mode is slower still.
|
|
37
|
+
* A size at which even the fastest mode cannot keep up ends the walk: every
|
|
38
|
+
* larger size is larger still. What is left unread is unavailable, which is
|
|
39
|
+
* the answer it would have got anyway.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
43
|
+
import os from "node:os";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { softwareRateControlFor, TRANSCODE_FPS } from "./args.js";
|
|
46
|
+
import {
|
|
47
|
+
CALIBRATION_DIR,
|
|
48
|
+
CALIBRATION_REFERENCE_CLIP,
|
|
49
|
+
decodeToRawFrames,
|
|
50
|
+
measureEncodeSlope,
|
|
51
|
+
runFfmpeg,
|
|
52
|
+
verifySegmentsDecodeCleanly
|
|
53
|
+
} from "./hwaccel.js";
|
|
54
|
+
import { interpolationErrorOf } from "./throughput.js";
|
|
55
|
+
|
|
56
|
+
export { throughputAt, interpolationErrorOf } from "./throughput.js";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The sizes a mode is read at, smallest first.
|
|
60
|
+
*
|
|
61
|
+
* The smallest is below every frame the ladder produces — its lowest row is
|
|
62
|
+
* 426x240, and a 4:3 or narrower source gives less area than that at the same
|
|
63
|
+
* height. The largest is the 2160 row. The two between are the 720 and 1080
|
|
64
|
+
* rows, where most films are watched. 640x360 is the reference size every
|
|
65
|
+
* earlier reading was taken at, so the figures stay comparable with them.
|
|
66
|
+
*/
|
|
67
|
+
export const CALIBRATION_FRAMES = Object.freeze([
|
|
68
|
+
Object.freeze({ width: 256, height: 144 }),
|
|
69
|
+
Object.freeze({ width: 640, height: 360 }),
|
|
70
|
+
Object.freeze({ width: 1280, height: 720 }),
|
|
71
|
+
Object.freeze({ width: 1920, height: 1080 }),
|
|
72
|
+
Object.freeze({ width: 3840, height: 2160 })
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** The size the correctness check encodes at, and every earlier reading was taken at. */
|
|
76
|
+
const CHECK_FRAME = Object.freeze({ width: 640, height: 360 });
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} SizeReading
|
|
80
|
+
* @property {number} width
|
|
81
|
+
* @property {number} height
|
|
82
|
+
* @property {number} pixelsPerSec
|
|
83
|
+
*
|
|
84
|
+
* @typedef {object} CalibratedMode
|
|
85
|
+
* @property {string} preset - The setting, or the encoder's name where it has one setting.
|
|
86
|
+
* @property {number} pixelsPerSec - At 640x360, for everything that has always read that figure.
|
|
87
|
+
* @property {SizeReading[]} bySize - Smallest first.
|
|
88
|
+
* @property {number | null} interpolationError - The largest measured relative
|
|
89
|
+
* error of an interpolation between two readings, or null where it could not
|
|
90
|
+
* be measured.
|
|
91
|
+
* @property {{ averageKbps: number, peakKbps: number } | null} segmentKbps -
|
|
92
|
+
* What the correctness check's segments carried.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The settings a mode is known by, as text, for the log and for the key its
|
|
97
|
+
* later observations are filed under.
|
|
98
|
+
*
|
|
99
|
+
* @param {{ benchmarkArgs: (rung: string | null) => string[] }} encoder
|
|
100
|
+
* @param {string | null} rung
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function settingsOf(encoder, rung) {
|
|
104
|
+
return encoder.benchmarkArgs(rung).join(" ");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Encode the calibration clip through the product's own arguments for this
|
|
109
|
+
* mode, and check every segment decodes on its own.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ ffmpegBin: string, encoder: object, rung: string | null, segmentDurationSec: number }} params
|
|
112
|
+
* @returns {Promise<{ ok: boolean, reason: string, segmentKbps: { averageKbps: number, peakKbps: number } | null }>}
|
|
113
|
+
*/
|
|
114
|
+
export async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, rung, segmentDurationSec }) {
|
|
115
|
+
let directory;
|
|
116
|
+
try {
|
|
117
|
+
directory = mkdtempSync(path.join(os.tmpdir(), "tt-modecheck-"));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { ok: false, reason: `no writable temp directory (${error instanceof Error ? error.message : String(error)})`, segmentKbps: null };
|
|
120
|
+
}
|
|
121
|
+
// Three segments' worth, so at least two are cut and each is checked alone.
|
|
122
|
+
const seconds = segmentDurationSec * 3;
|
|
123
|
+
const rateControl = encoder.kind === "software"
|
|
124
|
+
? softwareRateControlFor({ ...CHECK_FRAME, fps: TRANSCODE_FPS })
|
|
125
|
+
: null;
|
|
126
|
+
const args = [
|
|
127
|
+
"-hide_banner", "-loglevel", "error",
|
|
128
|
+
...encoder.inputArgs,
|
|
129
|
+
"-stream_loop", "-1",
|
|
130
|
+
"-i", path.join(CALIBRATION_DIR, CALIBRATION_REFERENCE_CLIP),
|
|
131
|
+
"-t", String(seconds),
|
|
132
|
+
"-map", "0:v:0", "-an",
|
|
133
|
+
...encoder.buildVideoArgs({
|
|
134
|
+
targetWidth: CHECK_FRAME.width,
|
|
135
|
+
targetHeight: CHECK_FRAME.height,
|
|
136
|
+
segmentDurationSec,
|
|
137
|
+
preset: rung ?? undefined,
|
|
138
|
+
fps: TRANSCODE_FPS,
|
|
139
|
+
tonemap: false,
|
|
140
|
+
forcedKeyframeTimes: null,
|
|
141
|
+
rateControl
|
|
142
|
+
}),
|
|
143
|
+
"-f", "hls",
|
|
144
|
+
"-hls_time", String(segmentDurationSec),
|
|
145
|
+
"-hls_list_size", "0",
|
|
146
|
+
"-hls_flags", "independent_segments",
|
|
147
|
+
"-hls_segment_type", "fmp4",
|
|
148
|
+
"-hls_fmp4_init_filename", "init.mp4",
|
|
149
|
+
"-hls_segment_filename", path.join(directory, "seg-%03d.m4s"),
|
|
150
|
+
path.join(directory, "index.m3u8")
|
|
151
|
+
];
|
|
152
|
+
try {
|
|
153
|
+
const encoded = await runFfmpeg(ffmpegBin, args, 60_000);
|
|
154
|
+
if (encoded.code !== 0) {
|
|
155
|
+
const line = encoded.stderr.trim().split(/\r?\n/).pop() ?? "";
|
|
156
|
+
return { ok: false, reason: `the encode failed (exit ${encoded.code}${line ? `: ${line}` : ""})`, segmentKbps: null };
|
|
157
|
+
}
|
|
158
|
+
if (!(await verifySegmentsDecodeCleanly(ffmpegBin, directory))) {
|
|
159
|
+
return { ok: false, reason: "a segment it produced does not decode on its own", segmentKbps: null };
|
|
160
|
+
}
|
|
161
|
+
const sizes = readdirSync(directory)
|
|
162
|
+
.filter((name) => /^seg-\d+\.m4s$/.test(name))
|
|
163
|
+
.sort()
|
|
164
|
+
.map((name) => statSync(path.join(directory, name)).size);
|
|
165
|
+
// The last segment may be short; only whole ones say what a segment carries.
|
|
166
|
+
const whole = sizes.slice(0, Math.max(1, sizes.length - 1));
|
|
167
|
+
const kbps = whole.map((bytes) => (bytes * 8) / segmentDurationSec / 1000);
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
reason: "",
|
|
171
|
+
segmentKbps: {
|
|
172
|
+
averageKbps: kbps.reduce((sum, value) => sum + value, 0) / kbps.length,
|
|
173
|
+
peakKbps: Math.max(...kbps)
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
} finally {
|
|
177
|
+
try {
|
|
178
|
+
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
179
|
+
} catch {
|
|
180
|
+
// A temp directory the operating system will clear.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Qualify and measure every selectable mode of one encoder.
|
|
187
|
+
*
|
|
188
|
+
* Always resolves. A mode that fails either half is left out, with the reason
|
|
189
|
+
* logged; an encoder none of whose modes qualify comes back with no modes,
|
|
190
|
+
* and nothing on this host is then re-encoded with it.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} params
|
|
193
|
+
* @param {string} params.ffmpegBin
|
|
194
|
+
* @param {object} params.encoder - An `Encoder`.
|
|
195
|
+
* @param {{ info: (m: string) => void, warn: (m: string) => void }} [params.logger]
|
|
196
|
+
* @param {number} [params.segmentDurationSec]
|
|
197
|
+
* @param {Array<{ width: number, height: number }>} [params.frames]
|
|
198
|
+
* @param {object} [params.measure] - Replaces the three readings, for a check
|
|
199
|
+
* that must not run ffmpeg: `check`, `rawFrames`, `speed`.
|
|
200
|
+
* @returns {Promise<{ modes: CalibratedMode[], refused: Array<{ preset: string, reason: string }> }>}
|
|
201
|
+
*/
|
|
202
|
+
export async function calibrateEncoder({
|
|
203
|
+
ffmpegBin,
|
|
204
|
+
encoder,
|
|
205
|
+
logger,
|
|
206
|
+
segmentDurationSec = 4,
|
|
207
|
+
frames = CALIBRATION_FRAMES,
|
|
208
|
+
measure = null
|
|
209
|
+
}) {
|
|
210
|
+
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
211
|
+
const check = measure?.check ?? checkModeProducesCorrectSegments;
|
|
212
|
+
const rawFrames = measure?.rawFrames ?? ((frame) => decodeToRawFrames(ffmpegBin, log, frame));
|
|
213
|
+
const speed = measure?.speed ?? ((rung, rawPath, frame) => measureEncodeSlope(ffmpegBin, encoder, rung, rawPath, frame));
|
|
214
|
+
const release = measure?.release ?? ((rawPath) => {
|
|
215
|
+
try {
|
|
216
|
+
rmSync(path.dirname(rawPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
217
|
+
} catch {
|
|
218
|
+
// A temp directory the operating system will clear.
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
const rungs = Array.isArray(encoder.selectableRungs) && encoder.selectableRungs.length > 0
|
|
222
|
+
? encoder.selectableRungs
|
|
223
|
+
: [null];
|
|
224
|
+
const startedAt = Date.now();
|
|
225
|
+
|
|
226
|
+
/** @type {Map<string | null, { ok: boolean, reason: string, segmentKbps: object | null }>} */
|
|
227
|
+
const checked = new Map();
|
|
228
|
+
for (const rung of rungs) {
|
|
229
|
+
const answer = await check({ ffmpegBin, encoder, rung, segmentDurationSec });
|
|
230
|
+
checked.set(rung, answer);
|
|
231
|
+
if (!answer.ok) {
|
|
232
|
+
log.warn(`calibration: ${encoder.name} "${rung ?? "as it comes"}" is not used on this host — ${answer.reason}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const qualified = rungs.filter((rung) => checked.get(rung)?.ok === true);
|
|
236
|
+
|
|
237
|
+
/** @type {Map<string | null, SizeReading[]>} */
|
|
238
|
+
const readings = new Map(qualified.map((rung) => [rung, []]));
|
|
239
|
+
const sortedFrames = [...frames].sort((left, right) => left.width * left.height - right.width * right.height);
|
|
240
|
+
for (const frame of sortedFrames) {
|
|
241
|
+
if (qualified.length === 0) {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
const rawPath = await rawFrames(frame);
|
|
245
|
+
if (rawPath === null) {
|
|
246
|
+
log.warn(`calibration: no frames at ${frame.width}x${frame.height}; ${encoder.name} is not read there or above`);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
let fastestKeptUp = false;
|
|
250
|
+
try {
|
|
251
|
+
// Fastest first: the ladder is slowest first, so walk it backwards.
|
|
252
|
+
for (let index = qualified.length - 1; index >= 0; index -= 1) {
|
|
253
|
+
const rung = qualified[index];
|
|
254
|
+
const realtimes = await speed(rung, rawPath, frame);
|
|
255
|
+
if (realtimes === null) {
|
|
256
|
+
log.warn(`calibration: ${encoder.name} "${rung ?? "as it comes"}" gave no reading at ${frame.width}x${frame.height}`);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
readings.get(rung).push({
|
|
260
|
+
width: frame.width,
|
|
261
|
+
height: frame.height,
|
|
262
|
+
pixelsPerSec: frame.width * frame.height * TRANSCODE_FPS * realtimes
|
|
263
|
+
});
|
|
264
|
+
if (index === qualified.length - 1) {
|
|
265
|
+
fastestKeptUp = realtimes >= 1;
|
|
266
|
+
}
|
|
267
|
+
if (realtimes < 1) {
|
|
268
|
+
// Every slower mode is slower still at this size.
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
release(rawPath);
|
|
274
|
+
}
|
|
275
|
+
if (!fastestKeptUp) {
|
|
276
|
+
// Every larger size is larger still.
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @type {CalibratedMode[]} */
|
|
282
|
+
const modes = [];
|
|
283
|
+
/** @type {Array<{ preset: string, reason: string }>} */
|
|
284
|
+
const refused = [];
|
|
285
|
+
for (const rung of rungs) {
|
|
286
|
+
const preset = rung ?? encoder.name;
|
|
287
|
+
if (checked.get(rung)?.ok !== true) {
|
|
288
|
+
refused.push({ preset, reason: checked.get(rung)?.reason ?? "not checked" });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const bySize = readings.get(rung) ?? [];
|
|
292
|
+
if (bySize.length === 0) {
|
|
293
|
+
refused.push({ preset, reason: "no size was read" });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const reference = bySize.find((reading) => reading.width === CHECK_FRAME.width && reading.height === CHECK_FRAME.height) ?? bySize[0];
|
|
297
|
+
const interpolationError = interpolationErrorOf(bySize);
|
|
298
|
+
modes.push({
|
|
299
|
+
preset,
|
|
300
|
+
pixelsPerSec: reference.pixelsPerSec,
|
|
301
|
+
bySize,
|
|
302
|
+
interpolationError,
|
|
303
|
+
segmentKbps: checked.get(rung).segmentKbps
|
|
304
|
+
});
|
|
305
|
+
log.info(
|
|
306
|
+
`calibration: ${encoder.name} "${preset}" ` +
|
|
307
|
+
bySize.map((reading) => `${reading.width}x${reading.height}=${(reading.pixelsPerSec / 1e6).toFixed(1)}Mpx/s`).join(" ") +
|
|
308
|
+
` interpolation error ${interpolationError === null ? "not measured, used only at the sizes read" : `${(interpolationError * 100).toFixed(1)}%`}` +
|
|
309
|
+
(checked.get(rung).segmentKbps
|
|
310
|
+
? ` segments ${Math.round(checked.get(rung).segmentKbps.averageKbps)}kbps average, ${Math.round(checked.get(rung).segmentKbps.peakKbps)}kbps peak at ${CHECK_FRAME.width}x${CHECK_FRAME.height}`
|
|
311
|
+
: "")
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
log.info(
|
|
315
|
+
`calibration: ${encoder.name} ${modes.length} of ${rungs.length} mode(s) usable ` +
|
|
316
|
+
`(${((Date.now() - startedAt) / 1000).toFixed(1)}s)` +
|
|
317
|
+
(refused.length > 0 ? `; not usable: ${refused.map((one) => `${one.preset} (${one.reason})`).join(", ")}` : "")
|
|
318
|
+
);
|
|
319
|
+
return { modes, refused };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* What every encoder this proxy may use was measured to do, by kind: the one
|
|
324
|
+
* detection chose, and software, which is where a failing hardware encoder
|
|
325
|
+
* falls back to for the rest of the process.
|
|
326
|
+
*/
|
|
327
|
+
export class HostCalibration {
|
|
328
|
+
/** @type {Map<string, CalibratedMode[]>} */
|
|
329
|
+
#byKind;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {object} params
|
|
333
|
+
* @param {Record<string, CalibratedMode[]>} params.byKind
|
|
334
|
+
* @param {import("./fingerprint.js").HostFingerprint | null} [params.fingerprint]
|
|
335
|
+
*/
|
|
336
|
+
constructor({ byKind, fingerprint = null }) {
|
|
337
|
+
this.#byKind = new Map(Object.entries(byKind ?? {}).map(([kind, modes]) => [kind, Array.isArray(modes) ? modes : []]));
|
|
338
|
+
this.fingerprint = fingerprint;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The usable modes of one encoder kind, slowest first — the shape the offer
|
|
343
|
+
* and the preset choice have always read. Null where that kind was not
|
|
344
|
+
* calibrated at all.
|
|
345
|
+
*
|
|
346
|
+
* @param {string | null | undefined} kind
|
|
347
|
+
* @returns {CalibratedMode[] | null}
|
|
348
|
+
*/
|
|
349
|
+
modesFor(kind) {
|
|
350
|
+
return kind && this.#byKind.has(kind) ? this.#byKind.get(kind) : null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Every kind that was calibrated.
|
|
355
|
+
*
|
|
356
|
+
* @returns {string[]}
|
|
357
|
+
*/
|
|
358
|
+
kinds() {
|
|
359
|
+
return [...this.#byKind.keys()];
|
|
360
|
+
}
|
|
361
|
+
}
|