@torrent-tv/proxy 2.87.21 → 2.88.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2242 -2234
- 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 +1 -1
- 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 +50 -50
- 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 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +106 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -217
- 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 +138 -138
- package/routes/api/transcode-sessions/post.js +173 -173
- 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 +380 -380
- 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 +725 -725
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +376 -376
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +813 -813
- package/services/encode/EncodeRuns.js +1235 -1235
- 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 +758 -758
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2184 -2184
- 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 +1141 -1141
- 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 +315 -315
- 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 +754 -754
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +456 -456
- package/services/encode/segment-formats/index.js +85 -85
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +515 -515
- 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 -245
- 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 +339 -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 +931 -931
- 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 +1150 -1150
- package/services/server/ViewerRequests.js +760 -760
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/playback-readiness.js +696 -696
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +992 -992
- 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 +539 -539
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1931 -1931
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1240 -1240
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +382 -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/episode-naming.js +182 -0
- 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 +2675 -2675
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +765 -765
- 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 +371 -371
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/torrent/worker/worker.js +1129 -1129
- package/services/transport/data-channel-handler.js +1369 -1369
- 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 +691 -691
- 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 -212
- 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/data-channel-viewer-lifecycle.test.js +92 -92
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +43 -43
- 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 +162 -162
- 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/episode-naming.test.js +131 -0
- 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-presentation-clock.test.js +147 -147
- 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 +401 -401
- 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 +595 -595
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +403 -403
- 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/playback-readiness.test.js +453 -453
- package/test/playback-start-regressions.test.js +99 -99
- 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 +12 -2
- 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 +209 -209
- 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-metadata.test.js +15 -0
- 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/utils/torrent-source-key.js +38 -38
|
@@ -1,363 +1,363 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Fit the cost of decoding from measured clips — and say which terms the
|
|
3
|
-
* measurements actually determine.
|
|
4
|
-
*
|
|
5
|
-
* The model is `cost = pixelTerm × Mpixel/s + bitrateTerm × Mbit/s +
|
|
6
|
-
* constantTerm`, in seconds of work per second of content.
|
|
7
|
-
*
|
|
8
|
-
* What went wrong with the fit this replaces, measured 2026-08-17: three clips
|
|
9
|
-
* for three unknowns is an EXACT system, and two of those clips shared a pixel
|
|
10
|
-
* rate and differed only in bitrate. A system that near-singular does not fail
|
|
11
|
-
* loudly — it returns whatever satisfies the three equations, and on that boot
|
|
12
|
-
* it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`. The bitrate
|
|
13
|
-
* term and the constant were zero, so a film's own bitrate never entered its
|
|
14
|
-
* price, and the whole prediction was 1.8-2.2x optimistic against what the same
|
|
15
|
-
* file measured while playing.
|
|
16
|
-
*
|
|
17
|
-
* Two things are different here, and both are about honesty rather than
|
|
18
|
-
* accuracy:
|
|
19
|
-
*
|
|
20
|
-
* - the system is OVER-determined (six clips, three unknowns) and solved by
|
|
21
|
-
* least squares, so no single noisy reading decides a term;
|
|
22
|
-
* - a term is published only if the measurements determine it. The test is
|
|
23
|
-
* the coefficient against its OWN standard error: a term smaller than the
|
|
24
|
-
* uncertainty in it has not been measured, and stating it as a number —
|
|
25
|
-
* zero or otherwise — claims knowledge that is not there. It is dropped,
|
|
26
|
-
* the remaining terms are fitted again, and the caller is told which shape
|
|
27
|
-
* survived.
|
|
28
|
-
*
|
|
29
|
-
* There is no threshold to tune in that rule and no coefficient anywhere in
|
|
30
|
-
* this file. "Smaller than its own uncertainty" is a statement about the data,
|
|
31
|
-
* not a setting.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* One measured clip.
|
|
36
|
-
*
|
|
37
|
-
* @typedef {object} DecodeSample
|
|
38
|
-
* @property {number} megapixelsPerSecond - Width × height × frame rate ÷ 1e6.
|
|
39
|
-
* @property {number} megabitsPerSecond - The clip's own measured bitrate.
|
|
40
|
-
* @property {number} costSecondsPerSecond - Seconds of work per second of
|
|
41
|
-
* content, from ffmpeg's own progress.
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The fitted model, with the shape the data supported.
|
|
46
|
-
*
|
|
47
|
-
* @typedef {object} DecodeCostModel
|
|
48
|
-
* @property {number} pixelTerm
|
|
49
|
-
* @property {number} bitrateTerm
|
|
50
|
-
* @property {number} constantTerm
|
|
51
|
-
* @property {string} shape - Which terms were determined.
|
|
52
|
-
* @property {string[]} dropped - Terms the measurements could not determine.
|
|
53
|
-
* @property {number} samples
|
|
54
|
-
* @property {number} residualRms - Typical disagreement between the fit and a
|
|
55
|
-
* measurement, in the same units as the cost.
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
/** Every term the model can carry, in column order. */
|
|
59
|
-
const TERMS = ["pixels", "bitrate", "constant"];
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Fit the model, dropping any term the measurements do not determine.
|
|
63
|
-
*
|
|
64
|
-
* @param {DecodeSample[]} samples
|
|
65
|
-
* @returns {DecodeCostModel | null} Null when nothing at all can be said: fewer
|
|
66
|
-
* measurements than terms, or no measurable dependence on the source.
|
|
67
|
-
*/
|
|
68
|
-
export function fitDecodeCost(samples) {
|
|
69
|
-
const usable = (Array.isArray(samples) ? samples : []).filter(
|
|
70
|
-
(sample) =>
|
|
71
|
-
Number.isFinite(sample?.megapixelsPerSecond) &&
|
|
72
|
-
sample.megapixelsPerSecond > 0 &&
|
|
73
|
-
Number.isFinite(sample?.megabitsPerSecond) &&
|
|
74
|
-
sample.megabitsPerSecond >= 0 &&
|
|
75
|
-
Number.isFinite(sample?.costSecondsPerSecond) &&
|
|
76
|
-
sample.costSecondsPerSecond > 0
|
|
77
|
-
);
|
|
78
|
-
// Fitting three terms to three points is what produced the degenerate answer
|
|
79
|
-
// this file exists to prevent: it has no residual and therefore no way to
|
|
80
|
-
// notice that two of the points said the same thing.
|
|
81
|
-
if (usable.length < TERMS.length + 1) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let columns = [...TERMS];
|
|
86
|
-
let dropped = [];
|
|
87
|
-
for (let attempt = 0; attempt < TERMS.length; attempt += 1) {
|
|
88
|
-
const fit = leastSquares(usable, columns);
|
|
89
|
-
if (!fit) {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
// Which terms the data did not determine: a coefficient no larger than the
|
|
93
|
-
// uncertainty in it. Only one is dropped per pass — removing a column
|
|
94
|
-
// changes the uncertainty in the others, so the question has to be asked
|
|
95
|
-
// again of the smaller model.
|
|
96
|
-
const undetermined = columns
|
|
97
|
-
.map((name, index) => ({
|
|
98
|
-
name,
|
|
99
|
-
coefficient: fit.coefficients[index],
|
|
100
|
-
ratio: determination(usable, columns, name, fit.coefficients[index], fit.standardErrors[index], fit.residualRms)
|
|
101
|
-
}))
|
|
102
|
-
// The pixel term is the one relationship every measurement agrees on, and
|
|
103
|
-
// a model without it says nothing at all. If IT is undetermined the
|
|
104
|
-
// answer is no model, which the caller handles by pricing the encoder
|
|
105
|
-
// alone and refusing nothing.
|
|
106
|
-
// A NEGATIVE coefficient is undetermined whatever its ratio says. Every
|
|
107
|
-
// term here is physically non-negative — more pixels cannot cost less
|
|
108
|
-
// work, and neither can more bits — so a negative fit is noise winning
|
|
109
|
-
// over an effect, not a discovery about the host. Clamping it to zero
|
|
110
|
-
// instead, which the first version of this file did, publishes a zero
|
|
111
|
-
// that looks measured: exactly the dishonesty the module exists to
|
|
112
|
-
// remove.
|
|
113
|
-
.filter((term) => term.ratio < 1 || term.coefficient < 0)
|
|
114
|
-
.sort((left, right) => {
|
|
115
|
-
const negative = (term) => (term.coefficient < 0 ? 0 : 1);
|
|
116
|
-
return negative(left) - negative(right) || left.ratio - right.ratio;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
if (undetermined.length === 0) {
|
|
120
|
-
return published(columns, fit, dropped, usable.length);
|
|
121
|
-
}
|
|
122
|
-
const weakest = undetermined[0].name;
|
|
123
|
-
if (weakest === "pixels") {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
columns = columns.filter((name) => name !== weakest);
|
|
127
|
-
dropped = [...dropped, weakest];
|
|
128
|
-
if (columns.length === 0) {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* How well one term is determined, as a ratio against 1.
|
|
138
|
-
*
|
|
139
|
-
* Two ways a term can fail to be measured, and both have to be asked, because
|
|
140
|
-
* each is blind where the other sees:
|
|
141
|
-
*
|
|
142
|
-
* - **against the noise.** The term's effect across the range the clips
|
|
143
|
-
* actually cover — its coefficient times the spread of its own column — has
|
|
144
|
-
* to be larger than the typical disagreement between the fit and the
|
|
145
|
-
* measurements. A term whose whole contribution is smaller than the scatter
|
|
146
|
-
* was not measured; it was fitted to the scatter.
|
|
147
|
-
* - **against the arithmetic.** When a fit happens to be exact the scatter is
|
|
148
|
-
* zero, every standard error vanishes, and a coefficient of 7e-18 — which
|
|
149
|
-
* is how double-precision writes "nothing" — passes any test framed as a
|
|
150
|
-
* ratio. So the effect must also be larger than what the arithmetic itself
|
|
151
|
-
* can represent at this scale. That floor is a property of the numbers, not
|
|
152
|
-
* a setting: it moves with the size of the measurements.
|
|
153
|
-
*
|
|
154
|
-
* @param {DecodeSample[]} samples
|
|
155
|
-
* @param {string[]} columns
|
|
156
|
-
* @param {string} name
|
|
157
|
-
* @param {number} coefficient
|
|
158
|
-
* @param {number} standardError
|
|
159
|
-
* @param {number} residualRms
|
|
160
|
-
* @returns {number} Below 1 means the measurements do not determine this term.
|
|
161
|
-
*/
|
|
162
|
-
function determination(samples, columns, name, coefficient, standardError, residualRms) {
|
|
163
|
-
const values = samples.map((sample) => {
|
|
164
|
-
if (name === "pixels") {
|
|
165
|
-
return sample.megapixelsPerSecond;
|
|
166
|
-
}
|
|
167
|
-
if (name === "bitrate") {
|
|
168
|
-
return sample.megabitsPerSecond;
|
|
169
|
-
}
|
|
170
|
-
return 1;
|
|
171
|
-
});
|
|
172
|
-
const spread = name === "constant" ? 1 : Math.max(...values) - Math.min(...values);
|
|
173
|
-
const effect = Math.abs(coefficient) * spread;
|
|
174
|
-
const meanCost = samples.reduce((sum, sample) => sum + sample.costSecondsPerSecond, 0) / samples.length;
|
|
175
|
-
const arithmeticFloor = Math.abs(meanCost) * Number.EPSILON * samples.length * columns.length;
|
|
176
|
-
const noiseFloor = Math.max(residualRms, arithmeticFloor);
|
|
177
|
-
const againstNoise = noiseFloor > 0 ? effect / noiseFloor : Number.POSITIVE_INFINITY;
|
|
178
|
-
const againstError = standardError > 0 ? Math.abs(coefficient) / standardError : Number.POSITIVE_INFINITY;
|
|
179
|
-
return Math.min(againstNoise, againstError);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Assemble the answer, with absent terms as zero — and `dropped` saying that
|
|
184
|
-
* the zero means "not measured" rather than "measured to be nothing".
|
|
185
|
-
*
|
|
186
|
-
* @param {string[]} columns
|
|
187
|
-
* @param {{ coefficients: number[], residualRms: number }} fit
|
|
188
|
-
* @param {string[]} dropped
|
|
189
|
-
* @param {number} samples
|
|
190
|
-
* @returns {DecodeCostModel | null}
|
|
191
|
-
*/
|
|
192
|
-
function published(columns, fit, dropped, samples) {
|
|
193
|
-
const value = (name) => {
|
|
194
|
-
const index = columns.indexOf(name);
|
|
195
|
-
return index < 0 ? 0 : fit.coefficients[index];
|
|
196
|
-
};
|
|
197
|
-
const pixelTerm = value("pixels");
|
|
198
|
-
// A negative price for pixels is not a host being unusual — it is noise
|
|
199
|
-
// larger than the effect, and carrying it would price a bigger picture as
|
|
200
|
-
// cheaper than a smaller one.
|
|
201
|
-
if (!(pixelTerm > 0)) {
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
const constantTerm = value("constant");
|
|
205
|
-
return {
|
|
206
|
-
pixelTerm,
|
|
207
|
-
bitrateTerm: Math.max(0, value("bitrate")),
|
|
208
|
-
// A negative constant would make a small enough source free, which no
|
|
209
|
-
// measurement here supports: it is the fitted line crossing below zero
|
|
210
|
-
// outside the range that was measured.
|
|
211
|
-
constantTerm: Math.max(0, constantTerm),
|
|
212
|
-
shape: columns.join("+"),
|
|
213
|
-
dropped,
|
|
214
|
-
samples,
|
|
215
|
-
residualRms: fit.residualRms
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Least squares over the named columns, with the standard error of each
|
|
221
|
-
* coefficient.
|
|
222
|
-
*
|
|
223
|
-
* Solved through the normal equations. The matrix is at most 3×3 and the
|
|
224
|
-
* columns are of similar magnitude once a clip set varies them deliberately,
|
|
225
|
-
* which is exactly what the set is designed for.
|
|
226
|
-
*
|
|
227
|
-
* @param {DecodeSample[]} samples
|
|
228
|
-
* @param {string[]} columns
|
|
229
|
-
* @returns {{ coefficients: number[], standardErrors: number[], residualRms: number } | null}
|
|
230
|
-
*/
|
|
231
|
-
function leastSquares(samples, columns) {
|
|
232
|
-
const rowOf = (sample) =>
|
|
233
|
-
columns.map((name) => {
|
|
234
|
-
if (name === "pixels") {
|
|
235
|
-
return sample.megapixelsPerSecond;
|
|
236
|
-
}
|
|
237
|
-
if (name === "bitrate") {
|
|
238
|
-
return sample.megabitsPerSecond;
|
|
239
|
-
}
|
|
240
|
-
return 1;
|
|
241
|
-
});
|
|
242
|
-
const width = columns.length;
|
|
243
|
-
const normal = Array.from({ length: width }, () => new Array(width).fill(0));
|
|
244
|
-
const right = new Array(width).fill(0);
|
|
245
|
-
for (const sample of samples) {
|
|
246
|
-
const row = rowOf(sample);
|
|
247
|
-
for (let i = 0; i < width; i += 1) {
|
|
248
|
-
right[i] += row[i] * sample.costSecondsPerSecond;
|
|
249
|
-
for (let j = 0; j < width; j += 1) {
|
|
250
|
-
normal[i][j] += row[i] * row[j];
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
const inverse = invert(normal);
|
|
255
|
-
if (!inverse) {
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
const coefficients = inverse.map((row) => row.reduce((sum, value, index) => sum + value * right[index], 0));
|
|
259
|
-
|
|
260
|
-
let residualSquares = 0;
|
|
261
|
-
for (const sample of samples) {
|
|
262
|
-
const row = rowOf(sample);
|
|
263
|
-
const predicted = row.reduce((sum, value, index) => sum + value * coefficients[index], 0);
|
|
264
|
-
residualSquares += (sample.costSecondsPerSecond - predicted) ** 2;
|
|
265
|
-
}
|
|
266
|
-
const degreesOfFreedom = samples.length - width;
|
|
267
|
-
const variance = degreesOfFreedom > 0 ? residualSquares / degreesOfFreedom : 0;
|
|
268
|
-
const standardErrors = inverse.map((row, index) => Math.sqrt(Math.max(0, variance * row[index])));
|
|
269
|
-
return {
|
|
270
|
-
coefficients,
|
|
271
|
-
standardErrors,
|
|
272
|
-
residualRms: Math.sqrt(residualSquares / samples.length)
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Invert a small symmetric matrix by Gauss-Jordan, or null when it is singular.
|
|
278
|
-
*
|
|
279
|
-
* @param {number[][]} matrix
|
|
280
|
-
* @returns {number[][] | null}
|
|
281
|
-
*/
|
|
282
|
-
function invert(matrix) {
|
|
283
|
-
const size = matrix.length;
|
|
284
|
-
const work = matrix.map((row, index) => [
|
|
285
|
-
...row,
|
|
286
|
-
...Array.from({ length: size }, (_unused, column) => (column === index ? 1 : 0))
|
|
287
|
-
]);
|
|
288
|
-
for (let column = 0; column < size; column += 1) {
|
|
289
|
-
let pivot = column;
|
|
290
|
-
for (let row = column + 1; row < size; row += 1) {
|
|
291
|
-
if (Math.abs(work[row][column]) > Math.abs(work[pivot][column])) {
|
|
292
|
-
pivot = row;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
// Singular to the precision of the arithmetic: the columns are not
|
|
296
|
-
// independent, which is a statement about the clip set, not about the host.
|
|
297
|
-
if (Math.abs(work[pivot][column]) < 1e-12) {
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
[work[column], work[pivot]] = [work[pivot], work[column]];
|
|
301
|
-
const scale = work[column][column];
|
|
302
|
-
for (let k = 0; k < 2 * size; k += 1) {
|
|
303
|
-
work[column][k] /= scale;
|
|
304
|
-
}
|
|
305
|
-
for (let row = 0; row < size; row += 1) {
|
|
306
|
-
if (row === column) {
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
const factor = work[row][column];
|
|
310
|
-
for (let k = 0; k < 2 * size; k += 1) {
|
|
311
|
-
work[row][k] -= factor * work[column][k];
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
return work.map((row) => row.slice(size));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Which measured family prices a source of this codec and depth.
|
|
320
|
-
*
|
|
321
|
-
* The families are the ones there are clips for. Anything else is priced as
|
|
322
|
-
* H.264 — not because it decodes like H.264, but because that is the only
|
|
323
|
-
* measurement of this host there is, and saying so in one place beats each
|
|
324
|
-
* caller inventing its own fallback.
|
|
325
|
-
*
|
|
326
|
-
* 10-bit is its own family rather than a multiplier on the 8-bit one: the
|
|
327
|
-
* samples are wider, the arithmetic is wider, and how much that costs is a
|
|
328
|
-
* property of the machine, which is what is being measured.
|
|
329
|
-
*
|
|
330
|
-
* @param {{ codec?: string | null, bitDepth?: number | null }} source
|
|
331
|
-
* @returns {string}
|
|
332
|
-
*/
|
|
333
|
-
export function decodeFamilyOf(source) {
|
|
334
|
-
const codec = typeof source?.codec === "string" ? source.codec.trim().toLowerCase() : "";
|
|
335
|
-
const depth = Number(source?.bitDepth);
|
|
336
|
-
const tenBit = Number.isFinite(depth) && depth >= 10;
|
|
337
|
-
if (codec === "hevc" || codec === "h265" || codec === "x265") {
|
|
338
|
-
return tenBit ? "hevc10" : "hevc";
|
|
339
|
-
}
|
|
340
|
-
return "h264";
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* What a model prices one source at.
|
|
345
|
-
*
|
|
346
|
-
* The model may carry a set of per-family fits (`families`) alongside the H.264
|
|
347
|
-
* terms it also spreads at the top level, which is what a caller that knows
|
|
348
|
-
* nothing about codecs still reads. When the source names a codec and that
|
|
349
|
-
* family was measured, its own constants are used.
|
|
350
|
-
*
|
|
351
|
-
* @param {DecodeCostModel & { families?: Record<string, DecodeCostModel> }} model
|
|
352
|
-
* @param {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec?: string | null, bitDepth?: number | null }} source
|
|
353
|
-
* @returns {number} Seconds of work per second of content.
|
|
354
|
-
*/
|
|
355
|
-
export function decodeCostOf(model, source) {
|
|
356
|
-
const family = decodeFamilyOf(source);
|
|
357
|
-
const terms = model?.families?.[family] ?? model;
|
|
358
|
-
return (
|
|
359
|
-
terms.pixelTerm * source.megapixelsPerSecond +
|
|
360
|
-
terms.bitrateTerm * source.megabitsPerSecond +
|
|
361
|
-
terms.constantTerm
|
|
362
|
-
);
|
|
363
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Fit the cost of decoding from measured clips — and say which terms the
|
|
3
|
+
* measurements actually determine.
|
|
4
|
+
*
|
|
5
|
+
* The model is `cost = pixelTerm × Mpixel/s + bitrateTerm × Mbit/s +
|
|
6
|
+
* constantTerm`, in seconds of work per second of content.
|
|
7
|
+
*
|
|
8
|
+
* What went wrong with the fit this replaces, measured 2026-08-17: three clips
|
|
9
|
+
* for three unknowns is an EXACT system, and two of those clips shared a pixel
|
|
10
|
+
* rate and differed only in bitrate. A system that near-singular does not fail
|
|
11
|
+
* loudly — it returns whatever satisfies the three equations, and on that boot
|
|
12
|
+
* it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`. The bitrate
|
|
13
|
+
* term and the constant were zero, so a film's own bitrate never entered its
|
|
14
|
+
* price, and the whole prediction was 1.8-2.2x optimistic against what the same
|
|
15
|
+
* file measured while playing.
|
|
16
|
+
*
|
|
17
|
+
* Two things are different here, and both are about honesty rather than
|
|
18
|
+
* accuracy:
|
|
19
|
+
*
|
|
20
|
+
* - the system is OVER-determined (six clips, three unknowns) and solved by
|
|
21
|
+
* least squares, so no single noisy reading decides a term;
|
|
22
|
+
* - a term is published only if the measurements determine it. The test is
|
|
23
|
+
* the coefficient against its OWN standard error: a term smaller than the
|
|
24
|
+
* uncertainty in it has not been measured, and stating it as a number —
|
|
25
|
+
* zero or otherwise — claims knowledge that is not there. It is dropped,
|
|
26
|
+
* the remaining terms are fitted again, and the caller is told which shape
|
|
27
|
+
* survived.
|
|
28
|
+
*
|
|
29
|
+
* There is no threshold to tune in that rule and no coefficient anywhere in
|
|
30
|
+
* this file. "Smaller than its own uncertainty" is a statement about the data,
|
|
31
|
+
* not a setting.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One measured clip.
|
|
36
|
+
*
|
|
37
|
+
* @typedef {object} DecodeSample
|
|
38
|
+
* @property {number} megapixelsPerSecond - Width × height × frame rate ÷ 1e6.
|
|
39
|
+
* @property {number} megabitsPerSecond - The clip's own measured bitrate.
|
|
40
|
+
* @property {number} costSecondsPerSecond - Seconds of work per second of
|
|
41
|
+
* content, from ffmpeg's own progress.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The fitted model, with the shape the data supported.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {object} DecodeCostModel
|
|
48
|
+
* @property {number} pixelTerm
|
|
49
|
+
* @property {number} bitrateTerm
|
|
50
|
+
* @property {number} constantTerm
|
|
51
|
+
* @property {string} shape - Which terms were determined.
|
|
52
|
+
* @property {string[]} dropped - Terms the measurements could not determine.
|
|
53
|
+
* @property {number} samples
|
|
54
|
+
* @property {number} residualRms - Typical disagreement between the fit and a
|
|
55
|
+
* measurement, in the same units as the cost.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** Every term the model can carry, in column order. */
|
|
59
|
+
const TERMS = ["pixels", "bitrate", "constant"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fit the model, dropping any term the measurements do not determine.
|
|
63
|
+
*
|
|
64
|
+
* @param {DecodeSample[]} samples
|
|
65
|
+
* @returns {DecodeCostModel | null} Null when nothing at all can be said: fewer
|
|
66
|
+
* measurements than terms, or no measurable dependence on the source.
|
|
67
|
+
*/
|
|
68
|
+
export function fitDecodeCost(samples) {
|
|
69
|
+
const usable = (Array.isArray(samples) ? samples : []).filter(
|
|
70
|
+
(sample) =>
|
|
71
|
+
Number.isFinite(sample?.megapixelsPerSecond) &&
|
|
72
|
+
sample.megapixelsPerSecond > 0 &&
|
|
73
|
+
Number.isFinite(sample?.megabitsPerSecond) &&
|
|
74
|
+
sample.megabitsPerSecond >= 0 &&
|
|
75
|
+
Number.isFinite(sample?.costSecondsPerSecond) &&
|
|
76
|
+
sample.costSecondsPerSecond > 0
|
|
77
|
+
);
|
|
78
|
+
// Fitting three terms to three points is what produced the degenerate answer
|
|
79
|
+
// this file exists to prevent: it has no residual and therefore no way to
|
|
80
|
+
// notice that two of the points said the same thing.
|
|
81
|
+
if (usable.length < TERMS.length + 1) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let columns = [...TERMS];
|
|
86
|
+
let dropped = [];
|
|
87
|
+
for (let attempt = 0; attempt < TERMS.length; attempt += 1) {
|
|
88
|
+
const fit = leastSquares(usable, columns);
|
|
89
|
+
if (!fit) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// Which terms the data did not determine: a coefficient no larger than the
|
|
93
|
+
// uncertainty in it. Only one is dropped per pass — removing a column
|
|
94
|
+
// changes the uncertainty in the others, so the question has to be asked
|
|
95
|
+
// again of the smaller model.
|
|
96
|
+
const undetermined = columns
|
|
97
|
+
.map((name, index) => ({
|
|
98
|
+
name,
|
|
99
|
+
coefficient: fit.coefficients[index],
|
|
100
|
+
ratio: determination(usable, columns, name, fit.coefficients[index], fit.standardErrors[index], fit.residualRms)
|
|
101
|
+
}))
|
|
102
|
+
// The pixel term is the one relationship every measurement agrees on, and
|
|
103
|
+
// a model without it says nothing at all. If IT is undetermined the
|
|
104
|
+
// answer is no model, which the caller handles by pricing the encoder
|
|
105
|
+
// alone and refusing nothing.
|
|
106
|
+
// A NEGATIVE coefficient is undetermined whatever its ratio says. Every
|
|
107
|
+
// term here is physically non-negative — more pixels cannot cost less
|
|
108
|
+
// work, and neither can more bits — so a negative fit is noise winning
|
|
109
|
+
// over an effect, not a discovery about the host. Clamping it to zero
|
|
110
|
+
// instead, which the first version of this file did, publishes a zero
|
|
111
|
+
// that looks measured: exactly the dishonesty the module exists to
|
|
112
|
+
// remove.
|
|
113
|
+
.filter((term) => term.ratio < 1 || term.coefficient < 0)
|
|
114
|
+
.sort((left, right) => {
|
|
115
|
+
const negative = (term) => (term.coefficient < 0 ? 0 : 1);
|
|
116
|
+
return negative(left) - negative(right) || left.ratio - right.ratio;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (undetermined.length === 0) {
|
|
120
|
+
return published(columns, fit, dropped, usable.length);
|
|
121
|
+
}
|
|
122
|
+
const weakest = undetermined[0].name;
|
|
123
|
+
if (weakest === "pixels") {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
columns = columns.filter((name) => name !== weakest);
|
|
127
|
+
dropped = [...dropped, weakest];
|
|
128
|
+
if (columns.length === 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How well one term is determined, as a ratio against 1.
|
|
138
|
+
*
|
|
139
|
+
* Two ways a term can fail to be measured, and both have to be asked, because
|
|
140
|
+
* each is blind where the other sees:
|
|
141
|
+
*
|
|
142
|
+
* - **against the noise.** The term's effect across the range the clips
|
|
143
|
+
* actually cover — its coefficient times the spread of its own column — has
|
|
144
|
+
* to be larger than the typical disagreement between the fit and the
|
|
145
|
+
* measurements. A term whose whole contribution is smaller than the scatter
|
|
146
|
+
* was not measured; it was fitted to the scatter.
|
|
147
|
+
* - **against the arithmetic.** When a fit happens to be exact the scatter is
|
|
148
|
+
* zero, every standard error vanishes, and a coefficient of 7e-18 — which
|
|
149
|
+
* is how double-precision writes "nothing" — passes any test framed as a
|
|
150
|
+
* ratio. So the effect must also be larger than what the arithmetic itself
|
|
151
|
+
* can represent at this scale. That floor is a property of the numbers, not
|
|
152
|
+
* a setting: it moves with the size of the measurements.
|
|
153
|
+
*
|
|
154
|
+
* @param {DecodeSample[]} samples
|
|
155
|
+
* @param {string[]} columns
|
|
156
|
+
* @param {string} name
|
|
157
|
+
* @param {number} coefficient
|
|
158
|
+
* @param {number} standardError
|
|
159
|
+
* @param {number} residualRms
|
|
160
|
+
* @returns {number} Below 1 means the measurements do not determine this term.
|
|
161
|
+
*/
|
|
162
|
+
function determination(samples, columns, name, coefficient, standardError, residualRms) {
|
|
163
|
+
const values = samples.map((sample) => {
|
|
164
|
+
if (name === "pixels") {
|
|
165
|
+
return sample.megapixelsPerSecond;
|
|
166
|
+
}
|
|
167
|
+
if (name === "bitrate") {
|
|
168
|
+
return sample.megabitsPerSecond;
|
|
169
|
+
}
|
|
170
|
+
return 1;
|
|
171
|
+
});
|
|
172
|
+
const spread = name === "constant" ? 1 : Math.max(...values) - Math.min(...values);
|
|
173
|
+
const effect = Math.abs(coefficient) * spread;
|
|
174
|
+
const meanCost = samples.reduce((sum, sample) => sum + sample.costSecondsPerSecond, 0) / samples.length;
|
|
175
|
+
const arithmeticFloor = Math.abs(meanCost) * Number.EPSILON * samples.length * columns.length;
|
|
176
|
+
const noiseFloor = Math.max(residualRms, arithmeticFloor);
|
|
177
|
+
const againstNoise = noiseFloor > 0 ? effect / noiseFloor : Number.POSITIVE_INFINITY;
|
|
178
|
+
const againstError = standardError > 0 ? Math.abs(coefficient) / standardError : Number.POSITIVE_INFINITY;
|
|
179
|
+
return Math.min(againstNoise, againstError);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Assemble the answer, with absent terms as zero — and `dropped` saying that
|
|
184
|
+
* the zero means "not measured" rather than "measured to be nothing".
|
|
185
|
+
*
|
|
186
|
+
* @param {string[]} columns
|
|
187
|
+
* @param {{ coefficients: number[], residualRms: number }} fit
|
|
188
|
+
* @param {string[]} dropped
|
|
189
|
+
* @param {number} samples
|
|
190
|
+
* @returns {DecodeCostModel | null}
|
|
191
|
+
*/
|
|
192
|
+
function published(columns, fit, dropped, samples) {
|
|
193
|
+
const value = (name) => {
|
|
194
|
+
const index = columns.indexOf(name);
|
|
195
|
+
return index < 0 ? 0 : fit.coefficients[index];
|
|
196
|
+
};
|
|
197
|
+
const pixelTerm = value("pixels");
|
|
198
|
+
// A negative price for pixels is not a host being unusual — it is noise
|
|
199
|
+
// larger than the effect, and carrying it would price a bigger picture as
|
|
200
|
+
// cheaper than a smaller one.
|
|
201
|
+
if (!(pixelTerm > 0)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const constantTerm = value("constant");
|
|
205
|
+
return {
|
|
206
|
+
pixelTerm,
|
|
207
|
+
bitrateTerm: Math.max(0, value("bitrate")),
|
|
208
|
+
// A negative constant would make a small enough source free, which no
|
|
209
|
+
// measurement here supports: it is the fitted line crossing below zero
|
|
210
|
+
// outside the range that was measured.
|
|
211
|
+
constantTerm: Math.max(0, constantTerm),
|
|
212
|
+
shape: columns.join("+"),
|
|
213
|
+
dropped,
|
|
214
|
+
samples,
|
|
215
|
+
residualRms: fit.residualRms
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Least squares over the named columns, with the standard error of each
|
|
221
|
+
* coefficient.
|
|
222
|
+
*
|
|
223
|
+
* Solved through the normal equations. The matrix is at most 3×3 and the
|
|
224
|
+
* columns are of similar magnitude once a clip set varies them deliberately,
|
|
225
|
+
* which is exactly what the set is designed for.
|
|
226
|
+
*
|
|
227
|
+
* @param {DecodeSample[]} samples
|
|
228
|
+
* @param {string[]} columns
|
|
229
|
+
* @returns {{ coefficients: number[], standardErrors: number[], residualRms: number } | null}
|
|
230
|
+
*/
|
|
231
|
+
function leastSquares(samples, columns) {
|
|
232
|
+
const rowOf = (sample) =>
|
|
233
|
+
columns.map((name) => {
|
|
234
|
+
if (name === "pixels") {
|
|
235
|
+
return sample.megapixelsPerSecond;
|
|
236
|
+
}
|
|
237
|
+
if (name === "bitrate") {
|
|
238
|
+
return sample.megabitsPerSecond;
|
|
239
|
+
}
|
|
240
|
+
return 1;
|
|
241
|
+
});
|
|
242
|
+
const width = columns.length;
|
|
243
|
+
const normal = Array.from({ length: width }, () => new Array(width).fill(0));
|
|
244
|
+
const right = new Array(width).fill(0);
|
|
245
|
+
for (const sample of samples) {
|
|
246
|
+
const row = rowOf(sample);
|
|
247
|
+
for (let i = 0; i < width; i += 1) {
|
|
248
|
+
right[i] += row[i] * sample.costSecondsPerSecond;
|
|
249
|
+
for (let j = 0; j < width; j += 1) {
|
|
250
|
+
normal[i][j] += row[i] * row[j];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const inverse = invert(normal);
|
|
255
|
+
if (!inverse) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const coefficients = inverse.map((row) => row.reduce((sum, value, index) => sum + value * right[index], 0));
|
|
259
|
+
|
|
260
|
+
let residualSquares = 0;
|
|
261
|
+
for (const sample of samples) {
|
|
262
|
+
const row = rowOf(sample);
|
|
263
|
+
const predicted = row.reduce((sum, value, index) => sum + value * coefficients[index], 0);
|
|
264
|
+
residualSquares += (sample.costSecondsPerSecond - predicted) ** 2;
|
|
265
|
+
}
|
|
266
|
+
const degreesOfFreedom = samples.length - width;
|
|
267
|
+
const variance = degreesOfFreedom > 0 ? residualSquares / degreesOfFreedom : 0;
|
|
268
|
+
const standardErrors = inverse.map((row, index) => Math.sqrt(Math.max(0, variance * row[index])));
|
|
269
|
+
return {
|
|
270
|
+
coefficients,
|
|
271
|
+
standardErrors,
|
|
272
|
+
residualRms: Math.sqrt(residualSquares / samples.length)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Invert a small symmetric matrix by Gauss-Jordan, or null when it is singular.
|
|
278
|
+
*
|
|
279
|
+
* @param {number[][]} matrix
|
|
280
|
+
* @returns {number[][] | null}
|
|
281
|
+
*/
|
|
282
|
+
function invert(matrix) {
|
|
283
|
+
const size = matrix.length;
|
|
284
|
+
const work = matrix.map((row, index) => [
|
|
285
|
+
...row,
|
|
286
|
+
...Array.from({ length: size }, (_unused, column) => (column === index ? 1 : 0))
|
|
287
|
+
]);
|
|
288
|
+
for (let column = 0; column < size; column += 1) {
|
|
289
|
+
let pivot = column;
|
|
290
|
+
for (let row = column + 1; row < size; row += 1) {
|
|
291
|
+
if (Math.abs(work[row][column]) > Math.abs(work[pivot][column])) {
|
|
292
|
+
pivot = row;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Singular to the precision of the arithmetic: the columns are not
|
|
296
|
+
// independent, which is a statement about the clip set, not about the host.
|
|
297
|
+
if (Math.abs(work[pivot][column]) < 1e-12) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
[work[column], work[pivot]] = [work[pivot], work[column]];
|
|
301
|
+
const scale = work[column][column];
|
|
302
|
+
for (let k = 0; k < 2 * size; k += 1) {
|
|
303
|
+
work[column][k] /= scale;
|
|
304
|
+
}
|
|
305
|
+
for (let row = 0; row < size; row += 1) {
|
|
306
|
+
if (row === column) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const factor = work[row][column];
|
|
310
|
+
for (let k = 0; k < 2 * size; k += 1) {
|
|
311
|
+
work[row][k] -= factor * work[column][k];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return work.map((row) => row.slice(size));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Which measured family prices a source of this codec and depth.
|
|
320
|
+
*
|
|
321
|
+
* The families are the ones there are clips for. Anything else is priced as
|
|
322
|
+
* H.264 — not because it decodes like H.264, but because that is the only
|
|
323
|
+
* measurement of this host there is, and saying so in one place beats each
|
|
324
|
+
* caller inventing its own fallback.
|
|
325
|
+
*
|
|
326
|
+
* 10-bit is its own family rather than a multiplier on the 8-bit one: the
|
|
327
|
+
* samples are wider, the arithmetic is wider, and how much that costs is a
|
|
328
|
+
* property of the machine, which is what is being measured.
|
|
329
|
+
*
|
|
330
|
+
* @param {{ codec?: string | null, bitDepth?: number | null }} source
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
export function decodeFamilyOf(source) {
|
|
334
|
+
const codec = typeof source?.codec === "string" ? source.codec.trim().toLowerCase() : "";
|
|
335
|
+
const depth = Number(source?.bitDepth);
|
|
336
|
+
const tenBit = Number.isFinite(depth) && depth >= 10;
|
|
337
|
+
if (codec === "hevc" || codec === "h265" || codec === "x265") {
|
|
338
|
+
return tenBit ? "hevc10" : "hevc";
|
|
339
|
+
}
|
|
340
|
+
return "h264";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* What a model prices one source at.
|
|
345
|
+
*
|
|
346
|
+
* The model may carry a set of per-family fits (`families`) alongside the H.264
|
|
347
|
+
* terms it also spreads at the top level, which is what a caller that knows
|
|
348
|
+
* nothing about codecs still reads. When the source names a codec and that
|
|
349
|
+
* family was measured, its own constants are used.
|
|
350
|
+
*
|
|
351
|
+
* @param {DecodeCostModel & { families?: Record<string, DecodeCostModel> }} model
|
|
352
|
+
* @param {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec?: string | null, bitDepth?: number | null }} source
|
|
353
|
+
* @returns {number} Seconds of work per second of content.
|
|
354
|
+
*/
|
|
355
|
+
export function decodeCostOf(model, source) {
|
|
356
|
+
const family = decodeFamilyOf(source);
|
|
357
|
+
const terms = model?.families?.[family] ?? model;
|
|
358
|
+
return (
|
|
359
|
+
terms.pixelTerm * source.megapixelsPerSecond +
|
|
360
|
+
terms.bitrateTerm * source.megabitsPerSecond +
|
|
361
|
+
terms.constantTerm
|
|
362
|
+
);
|
|
363
|
+
}
|