@torrent-tv/proxy 2.89.7 → 2.89.8
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 +358 -338
- package/CHANGELOG.md +2340 -2335
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +412 -412
- package/docs/browser-proxy-contract.md +155 -155
- package/docs/container-architecture.md +273 -273
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +765 -765
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/playback-start-forecast.md +113 -113
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +273 -273
- 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 +51 -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 -106
- 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 +178 -178
- 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 +416 -416
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +95 -95
- 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/build-audio-samples.mjs +31 -31
- package/scripts/render-run-graph.js +173 -173
- package/server.js +748 -748
- 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 +1192 -1192
- package/services/encode/EncodePlan.js +1368 -1368
- package/services/encode/EncodeRun.js +36 -12
- 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 +784 -784
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2283 -2283
- package/services/encode/RunClock.js +87 -87
- package/services/encode/RunProgress.js +79 -79
- 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 +418 -418
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- 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 +116 -116
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +43 -43
- 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 +24 -24
- 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 +1043 -1043
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +24 -24
- package/services/encode/quality/QualityController.js +758 -758
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +337 -337
- package/services/encode/quality/output-format.js +319 -319
- 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 +757 -757
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +53 -53
- package/services/encode/segment-formats/fmp4.js +476 -476
- package/services/encode/segment-formats/init-compat.js +324 -324
- 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 +202 -202
- package/services/media/KeyframeTables.js +356 -356
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +592 -592
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +299 -299
- package/services/media/container/AviContainer.js +268 -268
- package/services/media/container/Container.js +632 -632
- package/services/media/container/ContainerFactory.js +38 -38
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1031 -1031
- package/services/media/container/Mp4Container.js +1354 -1354
- package/services/media/container/SubtitleFileContainer.js +339 -339
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/container/ebml-stream.js +174 -174
- package/services/media/container/matroska-clusters.js +1165 -1165
- package/services/media/container/unavailable.js +77 -77
- 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 +143 -143
- package/services/media/tracks/ContainerTrack.js +84 -84
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +295 -295
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/audio-bound.js +233 -233
- 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 +1152 -1152
- package/services/server/ViewerRequests.js +785 -785
- 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/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +1029 -1029
- 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 +1937 -1937
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1294 -1294
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +382 -382
- 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 -182
- 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 +264 -264
- package/services/torrent/torrent-pool.js +2688 -2688
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +836 -836
- 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 +231 -231
- 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 +1137 -1137
- package/services/transport/data-channel-handler.js +1373 -1373
- 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 +421 -421
- 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/media-time.js +355 -355
- package/services/viewer/playback-readiness.js +635 -635
- 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-buffer-continuity.test.js +77 -77
- package/test/audio-calibration.test.js +23 -23
- package/test/audio-inventory.test.js +212 -212
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +762 -762
- 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/coalesce.test.js +39 -39
- 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 +233 -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 +475 -475
- 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 +275 -275
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +426 -426
- package/test/encode-plan-viewers.test.js +725 -725
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +20 -0
- 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 +68 -68
- package/test/episode-naming.test.js +131 -131
- 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/fixtures/audio-frame-boundary.json +1 -1
- 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 +36 -38
- package/test/held-read-ownership.test.js +77 -77
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +248 -240
- package/test/helpers/held-file.js +77 -77
- package/test/helpers/manager.js +62 -62
- package/test/helpers/matroska-file.js +296 -296
- 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 +123 -123
- 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 +291 -291
- package/test/matroska-cluster-discovery.test.js +323 -323
- package/test/matroska-cues-track.test.js +192 -192
- package/test/media-time.test.js +62 -62
- 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 +273 -273
- 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 +207 -207
- 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 +92 -87
- 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 +518 -518
- package/test/playback-start-regressions.test.js +101 -101
- 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 +160 -160
- package/test/quality-variants.test.js +1274 -1270
- package/test/rate-control-in-the-output.test.js +181 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/reads-not-yet-arrived.test.js +218 -218
- 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 +64 -64
- package/test/same-height-switch.test.js +663 -663
- 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 +114 -114
- 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 +222 -222
- package/test/segment-serve-wiring.test.js +481 -481
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +352 -352
- 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 +111 -111
- 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/soundtrack-rate-bound.test.js +289 -289
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +113 -113
- 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 +237 -237
- 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 +385 -385
- 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 -15
- package/test/subtitle-plan-waits.test.js +125 -125
- package/test/subtitle-track-numbering.test.js +391 -391
- package/test/supply-margin.test.js +190 -190
- 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 +508 -508
- 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 +92 -92
- package/test/viewer-starts-on-map.test.js +27 -27
- package/test/viewer-states.test.js +192 -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/whole-file-held-read.test.js +30 -30
- 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/coalesce.js +41 -41
- 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,758 +1,758 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The quality budget of the outputs that exist: when a picture is asked to
|
|
3
|
-
* move to another height, when its bitrate is capped by a viewer's measured link,
|
|
4
|
-
* and what the player is told to follow.
|
|
5
|
-
*
|
|
6
|
-
* Moved out of the session manager whole (plan step 3). What it needs of the
|
|
7
|
-
* rest of the proxy is the host object it is built with, and nothing else.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { logger } from "../../../utils/logger.js";
|
|
11
|
-
import { linkCouldCarry, loadOf } from "./link-budget.js";
|
|
12
|
-
import { qualityStateOf } from "./OutputQualityState.js";
|
|
13
|
-
import { drainThreat } from "./drain-threat.js";
|
|
14
|
-
import { visibleHeightCap } from "./visible-rung.js";
|
|
15
|
-
|
|
16
|
-
// How often the host's load and the torrents' download rates are sampled.
|
|
17
|
-
// A sampling period of the machine, not a quality decision: every decision
|
|
18
|
-
// about a viewer's quality is taken on that viewer's report.
|
|
19
|
-
export const BUDGET_CHECK_INTERVAL_MS = 5_000;
|
|
20
|
-
// Every quality step is judged on a report a viewer makes, for that viewer
|
|
21
|
-
// alone (roadmap item 98). DOWN: their buffer, on its present trend, would run
|
|
22
|
-
// dry before another output could have the piece they need (`drain-threat.js`),
|
|
23
|
-
// and the reason is one a smaller output removes — their link carries less
|
|
24
|
-
// than the stream, or this machine makes the picture slower than realtime
|
|
25
|
-
// over its own working time (`RunClock`). UP: their buffer is not draining,
|
|
26
|
-
// holds the time another output takes to be ready, the machine makes the
|
|
27
|
-
// picture at least at realtime, and their link carries the next height. A
|
|
28
|
-
// chosen threshold of slowness, a chosen window to sustain it, a window four
|
|
29
|
-
// times as long before stepping back up, a safety share of the link and a
|
|
30
|
-
// chosen test for a download-starved input stood here; all are gone. A manual
|
|
31
|
-
// viewer is never sent an automatic quality request.
|
|
32
|
-
|
|
33
|
-
/** @typedef {{ pathOf: (address: string, index: number) => string | null, sizesOf: (address: string) => Map<number, number> }} SegmentPathLookup */
|
|
34
|
-
|
|
35
|
-
export class QualityController {
|
|
36
|
-
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
37
|
-
#host;
|
|
38
|
-
|
|
39
|
-
/** A sampling pass is under way; a timer tick that lands meanwhile is skipped. */
|
|
40
|
-
#tickRunning = false;
|
|
41
|
-
|
|
42
|
-
/** Viewers whose report is being judged now (`noteViewerReported`). @type {Set<string>} */
|
|
43
|
-
#judging = new Set();
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Viewers whose standing request the judgement of their current report asked
|
|
47
|
-
* for again. A request stands for as long as the conditions it was asked
|
|
48
|
-
* under hold, and only a judgement can say they do; one it did not repeat is
|
|
49
|
-
* let go. @type {Set<string>}
|
|
50
|
-
*/
|
|
51
|
-
#affirmed = new Set();
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `expectedFirstSegmentMs`
|
|
55
|
-
* @param {SegmentPathLookup} host.segmentPaths - Lookup for completed
|
|
56
|
-
* segment paths needed by quality decisions.
|
|
57
|
-
*/
|
|
58
|
-
constructor(host) {
|
|
59
|
-
this.#host = host;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* One sampling pass: the host's load and the torrents' download rates.
|
|
64
|
-
*
|
|
65
|
-
* Public because it is an operation with a name, not an implementation detail
|
|
66
|
-
* of a timer. It decides nothing about any viewer: an encoder's price is
|
|
67
|
-
* learned when it closes a piece (`EncodeCost.learnFrom`), and a quality step
|
|
68
|
-
* is judged on a viewer's report (`noteViewerReported`).
|
|
69
|
-
*
|
|
70
|
-
* @returns {Promise<void>}
|
|
71
|
-
*/
|
|
72
|
-
async runQualityBudgetOnce() {
|
|
73
|
-
void this.#host.reportHostLoad();
|
|
74
|
-
// One tick at a time: the rates await torrent statistics per source, so a
|
|
75
|
-
// slow answer would otherwise let the next tick in behind it and take the
|
|
76
|
-
// same reading twice.
|
|
77
|
-
if (this.#tickRunning === true) {
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
this.#tickRunning = true;
|
|
81
|
-
try {
|
|
82
|
-
// Taken whatever the encoder is: the torrent's price is charged against
|
|
83
|
-
// this rate on every host, not only on the ones that re-encode.
|
|
84
|
-
await this.#host.sampleDownloadRates();
|
|
85
|
-
} finally {
|
|
86
|
-
this.#tickRunning = false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* This output's processing speed now, over its run's own working time, or
|
|
92
|
-
* null when no run has measured one. Copying and re-encoding alike; a copy
|
|
93
|
-
* has no encoder a smaller picture would relieve, which is decided where
|
|
94
|
-
* this is asked.
|
|
95
|
-
*
|
|
96
|
-
* @param {HlsSession} session
|
|
97
|
-
* @returns {number | null}
|
|
98
|
-
*/
|
|
99
|
-
#processingSpeedOf(session) {
|
|
100
|
-
return this.#host.encodeCost.latestSpeedReadingOf(session)?.speed ?? null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* How long another output of the mode on this viewer's screen takes to be
|
|
105
|
-
* ready here, in seconds: as observed on this host (roadmap item 97, step
|
|
106
|
-
* 14), and otherwise this host's time to a first segment. Null when neither
|
|
107
|
-
* has been measured.
|
|
108
|
-
*
|
|
109
|
-
* @param {HlsSession} session
|
|
110
|
-
* @returns {number | null}
|
|
111
|
-
*/
|
|
112
|
-
#secondsToReady(session) {
|
|
113
|
-
const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
|
|
114
|
-
const expectedMs = Number.isFinite(observedMs) ? observedMs : (this.#host.expectedFirstSegmentMs?.() ?? null);
|
|
115
|
-
return Number.isFinite(expectedMs) && expectedMs >= 0 ? expectedMs / 1000 : null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Ask the player to move down one offered rung.
|
|
120
|
-
*
|
|
121
|
-
* THE SIZE OF THE PICTURE IS NEVER REWRITTEN UNDERNEATH A RUNNING SESSION.
|
|
122
|
-
* The fMP4 init segment is fetched once — a player reads `#EXT-X-MAP` and
|
|
123
|
-
* never asks again — and `avc1` keeps SPS and PPS in it rather than in the
|
|
124
|
-
* fragments, so every fragment produced after a size change is decoded
|
|
125
|
-
* against parameter sets describing a picture that is no longer being made.
|
|
126
|
-
* Measured 2026-08-21 on two files: one browser went on reporting
|
|
127
|
-
* `size=1280x720` for three and a half minutes over a band of macroblock
|
|
128
|
-
* garbage after the encoder had left for 960x540; the other errored on the
|
|
129
|
-
* first mismatched fragment, closed the MediaSource and sat at `size=0x0`
|
|
130
|
-
* for four and a half minutes. Which of the two happens is the decoder's
|
|
131
|
-
* choice, not ours, and no layer reported an error either time.
|
|
132
|
-
*
|
|
133
|
-
* A change of resolution is a change of VARIANT, as the standard has it.
|
|
134
|
-
* Every height is already published in the master with its own init, so the
|
|
135
|
-
* step is made by ASKING the browser to move — the same act the manual menu
|
|
136
|
-
* performs, which has never had this fault.
|
|
137
|
-
*
|
|
138
|
-
* @param {HlsSession} session
|
|
139
|
-
* @param {string} reasonText
|
|
140
|
-
* @param {string[] | null} [onlyFor]
|
|
141
|
-
* @param {{ urgent?: boolean, cap?: number | null }} [options] - `urgent`
|
|
142
|
-
* when the viewer's buffer would run dry first; `cap` the height the
|
|
143
|
-
* picture they see bounds a re-encode by, preferred where it is lower than
|
|
144
|
-
* the next rung down.
|
|
145
|
-
* @returns {boolean} True when an ask was recorded.
|
|
146
|
-
*/
|
|
147
|
-
#askLowerHeight(session, reasonText, onlyFor = null, { urgent = false, cap = null } = {}) {
|
|
148
|
-
const base = this.#host.outputs.pictureOf(session);
|
|
149
|
-
const current = this.#host.outputs.variantHeightOf(session);
|
|
150
|
-
const offered = this.#host.qualityOffer.offeredHeights(base);
|
|
151
|
-
// The highest rung strictly below the one on screen that this host is still
|
|
152
|
-
// willing to serve. `offeredHeights` has already refused everything the
|
|
153
|
-
// machine cannot hold, so a rung that survives it is one worth moving to.
|
|
154
|
-
// Where the picture the viewer sees bounds a re-encode lower still, the
|
|
155
|
-
// highest rung within that bound is taken instead: they are shown nothing
|
|
156
|
-
// the rung between would have added (roadmap item 98).
|
|
157
|
-
const lower = this.#host.outputs.splicableHeights(base)
|
|
158
|
-
.filter((height) => height < current && offered.includes(height));
|
|
159
|
-
const next = (Number.isInteger(cap) && cap > 0 ? lower.find((height) => height <= cap) : undefined) ?? lower[0];
|
|
160
|
-
if (next === undefined) {
|
|
161
|
-
// Nothing lower — but "lower" is not the same question as "cheaper", and
|
|
162
|
-
// on a source that is COPIED the answer is above, not below. A copied
|
|
163
|
-
// rung costs no encoder at all, whatever its size, so when a re-encode
|
|
164
|
-
// cannot keep up it is both the fastest thing this host can serve AND the
|
|
165
|
-
// best picture it has.
|
|
166
|
-
//
|
|
167
|
-
// Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
|
|
168
|
-
// 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
|
|
169
|
-
// own 1038p sat on offer beside it, copied and free. This line printed
|
|
170
|
-
// fifty times — "nothing lower is on offer; leaving the picture alone" —
|
|
171
|
-
// and the picture stood still 161 times for 940 seconds. The rescue was
|
|
172
|
-
// on the screen the whole time and the rule could only look down.
|
|
173
|
-
const copied = this.#host.qualityOffer.copiedHeightOf(base);
|
|
174
|
-
if (copied > 0 && copied !== current && offered.includes(copied)) {
|
|
175
|
-
logger.info(
|
|
176
|
-
`[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
|
|
177
|
-
`but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
|
|
178
|
-
`Asking for it instead of leaving the viewer on an encode that cannot keep up`
|
|
179
|
-
);
|
|
180
|
-
return this.#askQualityHeight(base, copied, reasonText, onlyFor, urgent);
|
|
181
|
-
}
|
|
182
|
-
logger.info(
|
|
183
|
-
`[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
|
|
184
|
-
`for "${session.file.name}"; leaving the picture alone`
|
|
185
|
-
);
|
|
186
|
-
return false;
|
|
187
|
-
}
|
|
188
|
-
return this.#askQualityHeight(base, next, reasonText, onlyFor, urgent);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Record a request to the viewer's player to move to another variant.
|
|
193
|
-
*
|
|
194
|
-
* The proxy cannot move a player between variants; it can only say which one
|
|
195
|
-
* it would rather serve. The request travels in every progress report, and
|
|
196
|
-
* the browser honours it ONLY in automatic mode — a height the viewer picked
|
|
197
|
-
* by hand is theirs, and nothing here may take it away.
|
|
198
|
-
*
|
|
199
|
-
* @param {HlsSession} base
|
|
200
|
-
* @param {number} height
|
|
201
|
-
* @param {string} reasonText
|
|
202
|
-
* @param {string[] | null} [onlyFor] - The viewers this ask is FOR. A reason
|
|
203
|
-
* that belongs to one person's link is theirs alone: a thin link asks only
|
|
204
|
-
* the viewer on it, never everybody watching the picture. Null for a reason
|
|
205
|
-
* that belongs to the output — the machine cannot keep up with it — which
|
|
206
|
-
* is every AUTO viewer's.
|
|
207
|
-
* @param {boolean} [urgent] - Their buffer would run dry before anything else
|
|
208
|
-
* could arrive, so their page switches without waiting for a cushion.
|
|
209
|
-
* @returns {boolean}
|
|
210
|
-
*/
|
|
211
|
-
#askQualityHeight(base, height, reasonText, onlyFor = null, urgent = false) {
|
|
212
|
-
if (!this.#host.outputs.publishesVariants(base)) {
|
|
213
|
-
// Said once for the session. Repeating it is not information: the answer
|
|
214
|
-
// is a property of the stream and cannot change while it plays.
|
|
215
|
-
if (qualityStateOf(base).saidNoVariants !== true) {
|
|
216
|
-
qualityStateOf(base).saidNoVariants = true;
|
|
217
|
-
logger.info(
|
|
218
|
-
`[budget] transcode ${base.id} would ask for ${height}p, but this stream publishes no ` +
|
|
219
|
-
`variants to move between; leaving the picture alone for the rest of the session`
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
return false;
|
|
223
|
-
}
|
|
224
|
-
const now = Date.now();
|
|
225
|
-
const asked = [];
|
|
226
|
-
const affirmed = [];
|
|
227
|
-
const playing = [];
|
|
228
|
-
for (const consumerId of this.#host.consumersOn(base)) {
|
|
229
|
-
if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
if (onlyFor && !onlyFor.includes(consumerId)) {
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
236
|
-
const current = onScreen ? this.#host.outputs.get(onScreen) : base;
|
|
237
|
-
const currentHeight = this.#host.outputs.variantHeightOf(current ?? base);
|
|
238
|
-
playing.push(currentHeight);
|
|
239
|
-
if (currentHeight === height) {
|
|
240
|
-
this.#host.dropAskOf(base, consumerId);
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
const standing = this.#host.standingAskOf(base, consumerId);
|
|
244
|
-
if (standing && standing.height === height) {
|
|
245
|
-
this.#affirmed.add(consumerId);
|
|
246
|
-
affirmed.push(consumerId);
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
if (this.#host.askQualityOf(base, consumerId, height, reasonText, now, urgent)) {
|
|
250
|
-
this.#affirmed.add(consumerId);
|
|
251
|
-
asked.push(consumerId);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
if (asked.length === 0) {
|
|
255
|
-
// Asked already and asked again: true, so a caller does not go on to say
|
|
256
|
-
// that nothing could be prepared; nothing new is written.
|
|
257
|
-
return affirmed.length > 0;
|
|
258
|
-
}
|
|
259
|
-
logger.info(
|
|
260
|
-
`[budget] transcode ${base.id} asks the player to move ${playing.join("p/")}p → ${height}p${urgent ? " (urgent)" : ""}: ${reasonText} ` +
|
|
261
|
-
`"${base.file.name}" for ${asked.length} AUTO viewer(s) ` +
|
|
262
|
-
`(a change of size is a change of variant — its own init describes it)`
|
|
263
|
-
);
|
|
264
|
-
return true;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* The step BACK UP, in two stages and one rung at a time.
|
|
269
|
-
*
|
|
270
|
-
* 1. ANOTHER LIMIT OF THE SAME HEIGHT: a viewer moved down to a lower limit
|
|
271
|
-
* goes back up the limits of their height first, without their player
|
|
272
|
-
* being told (roadmap item 97, step 12). A limit is part of an output, so
|
|
273
|
-
* this is a move between outputs, prepared for one viewer.
|
|
274
|
-
* 2. ANOTHER HEIGHT, asked of the player in AUTO — only for a viewer with no
|
|
275
|
-
* higher limit to go to on the height they watch, and only if their own
|
|
276
|
-
* link admits the next height's whole load.
|
|
277
|
-
*
|
|
278
|
-
* Both follow one window: the machine and the viewer's link have held the
|
|
279
|
-
* output on screen, with room to spare, for four times as long as a step
|
|
280
|
-
* DOWN needs.
|
|
281
|
-
*
|
|
282
|
-
* @param {HlsSession} session
|
|
283
|
-
* @param {number} now
|
|
284
|
-
* @returns {Promise<void>}
|
|
285
|
-
*/
|
|
286
|
-
async #checkStepUp(session, consumerId) {
|
|
287
|
-
if (!this.#roomToSpare(session, consumerId)) {
|
|
288
|
-
// A step up being prepared for a viewer who no longer has room is let go:
|
|
289
|
-
// the conditions it was asked under have gone back (roadmap item 98).
|
|
290
|
-
this.#dropUpAsk(session, consumerId, "the room it was asked for has gone");
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
const base = this.#host.outputs.pictureOf(session);
|
|
294
|
-
const current = this.#host.outputs.variantHeightOf(session);
|
|
295
|
-
const reasonText = `the machine and the link carry ${current}p with room to spare`;
|
|
296
|
-
const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "up", reasonText);
|
|
297
|
-
if (move.started) {
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
// Refused because the MACHINE holds no more encoders: a higher height costs
|
|
301
|
-
// more than the limit that was just refused, so it is not asked for.
|
|
302
|
-
if (move.noPlace) {
|
|
303
|
-
logger.info(`[budget] transcode ${session.id} no step up for ${consumerId}: ${move.reason}`);
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
// One rung at a time: the lowest height above the one on screen, never
|
|
307
|
-
// above the source (upscaling invents detail and costs more than the source
|
|
308
|
-
// itself). A rung this host has been measured failing at is not offered
|
|
309
|
-
// (`QualityOffer`), so the step back up cannot return to it.
|
|
310
|
-
const higher = this.#host.qualityOffer.nextHeightUp(base, current);
|
|
311
|
-
if (higher === undefined) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
// Only where their OWN link admits the next rung, and the picture on their
|
|
315
|
-
// screen is not already served by the rung they are on: a re-encode is never
|
|
316
|
-
// made taller than the picture they see (roadmap item 98). The copy of the
|
|
317
|
-
// source is not a re-encode, and going back to it is not bounded.
|
|
318
|
-
const copied = this.#host.qualityOffer.copiedHeightOf(base);
|
|
319
|
-
const cap = this.#visibleCapOf(base, consumerId);
|
|
320
|
-
if (this.#linkAdmitsHeight(base, session, consumerId, higher) &&
|
|
321
|
-
(higher === copied || cap === null || higher <= cap)) {
|
|
322
|
-
this.#askQualityHeight(base, higher, reasonText, [consumerId]);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Whether this viewer has room for more, every term measured: the picture on
|
|
328
|
-
* their screen is made at least at realtime over its run's own working time
|
|
329
|
-
* (a copy is not limited by an encoder), their buffer is not draining, it
|
|
330
|
-
* holds at least the time another output takes to be ready here, and no move
|
|
331
|
-
* of theirs is already being prepared.
|
|
332
|
-
*
|
|
333
|
-
* @param {HlsSession} session
|
|
334
|
-
* @param {string} consumerId
|
|
335
|
-
* @returns {boolean}
|
|
336
|
-
*/
|
|
337
|
-
#roomToSpare(session, consumerId) {
|
|
338
|
-
if (!this.#onScreenHere(session, consumerId) || this.#host.sameHeightSwitchPending(consumerId)) {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
if (session.spec.transcodesVideo) {
|
|
342
|
-
const speed = this.#processingSpeedOf(session);
|
|
343
|
-
if (speed === null || speed < 1) {
|
|
344
|
-
return false;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
|
|
348
|
-
if (!buffer || !Number.isFinite(buffer.slope) || buffer.slope < 0) {
|
|
349
|
-
return false;
|
|
350
|
-
}
|
|
351
|
-
const secondsToReady = this.#secondsToReady(session);
|
|
352
|
-
return secondsToReady !== null && (buffer.bufferedSec ?? 0) >= secondsToReady;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Whether this viewer's own link admits the whole load of a height nothing
|
|
357
|
-
* may have produced yet.
|
|
358
|
-
*
|
|
359
|
-
* Nothing measured their link: no ground to refuse, the same silence that
|
|
360
|
-
* stops `#checkSupply` from blaming their link.
|
|
361
|
-
*
|
|
362
|
-
* @param {HlsSession} base
|
|
363
|
-
* @param {HlsSession} session
|
|
364
|
-
* @param {string} consumerId
|
|
365
|
-
* @param {number} height
|
|
366
|
-
* @returns {boolean}
|
|
367
|
-
*/
|
|
368
|
-
#linkAdmitsHeight(base, session, consumerId, height) {
|
|
369
|
-
const report = this.#host.linkReportOf(session, consumerId);
|
|
370
|
-
const load = loadOf(
|
|
371
|
-
this.#host.qualityOffer.videoLoadFor(base, height, this.#host.videoEncoder?.kind ?? ""),
|
|
372
|
-
this.#host.viewerAudioLoadOf(base, consumerId)
|
|
373
|
-
);
|
|
374
|
-
return linkCouldCarry(report?.linkMbps ?? null, load).admitted;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* A viewer reported on themselves: judge their link and the picture they
|
|
379
|
-
* see, for them alone, now (roadmap item 98).
|
|
380
|
-
*
|
|
381
|
-
* The report is the event that carries a change of either, so it is where
|
|
382
|
-
* they are judged — not a timer, which between two reports would only read
|
|
383
|
-
* the same statement again.
|
|
384
|
-
*
|
|
385
|
-
* @param {string} sessionId - The output the page addressed.
|
|
386
|
-
* @param {string} consumerId
|
|
387
|
-
* @returns {Promise<void>}
|
|
388
|
-
*/
|
|
389
|
-
async noteViewerReported(sessionId, consumerId) {
|
|
390
|
-
const named = sessionId ? this.#host.outputs.get(sessionId) : null;
|
|
391
|
-
if (!named || !consumerId) {
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
const base = this.#host.outputs.pictureOf(named);
|
|
395
|
-
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
396
|
-
const session = (onScreen ? this.#host.outputs.get(onScreen) : null) ?? base;
|
|
397
|
-
if (!this.#host.isLive(session) || session.spec.carries === "audio-only") {
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
// One judgement of a viewer at a time: reports can arrive while the last
|
|
401
|
-
// one is still reading its files.
|
|
402
|
-
if (this.#judging.has(consumerId)) {
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
this.#judging.add(consumerId);
|
|
406
|
-
this.#affirmed.delete(consumerId);
|
|
407
|
-
const standingBefore = this.#host.standingAskOf(base, consumerId);
|
|
408
|
-
try {
|
|
409
|
-
await this.#judge(session, consumerId);
|
|
410
|
-
// A request this judgement did not ask for again no longer has the
|
|
411
|
-
// conditions it was asked under: their buffer stopped draining, the room
|
|
412
|
-
// for a step up went, or the smaller picture is no longer smaller. It is
|
|
413
|
-
// let go here, and their page drops the move it was preparing.
|
|
414
|
-
const standing = this.#host.standingAskOf(base, consumerId);
|
|
415
|
-
// The same request, not one made meanwhile: compared by what it is, since
|
|
416
|
-
// it is read as a copy.
|
|
417
|
-
const unchanged = standing && standingBefore &&
|
|
418
|
-
standing.height === standingBefore.height && standing.at === standingBefore.at;
|
|
419
|
-
if (unchanged && !this.#affirmed.has(consumerId)) {
|
|
420
|
-
this.#host.dropAskOf(base, consumerId);
|
|
421
|
-
logger.info(
|
|
422
|
-
`[budget] transcode ${base.id} no longer asks ${consumerId} for ${standing.height}p: ` +
|
|
423
|
-
`the conditions it was asked under (${standing.reason}) no longer hold`
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
} finally {
|
|
427
|
-
this.#affirmed.delete(consumerId);
|
|
428
|
-
this.#judging.delete(consumerId);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* One judgement of one viewer's report, in order: what they are given against
|
|
434
|
-
* what keeps up, the picture they see, and the room for more.
|
|
435
|
-
*
|
|
436
|
-
* @param {HlsSession} session - The output on their screen.
|
|
437
|
-
* @param {string} consumerId
|
|
438
|
-
* @returns {Promise<void>}
|
|
439
|
-
*/
|
|
440
|
-
async #judge(session, consumerId) {
|
|
441
|
-
if (this.#host.videoEncoder?.kind === "software" &&
|
|
442
|
-
(await this.#checkSupply(session, consumerId))) {
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
if (this.#checkVisiblePicture(session, consumerId)) {
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
if (this.#host.videoEncoder?.kind === "software") {
|
|
449
|
-
await this.#checkStepUp(session, consumerId);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Whether this viewer's buffer can keep up with what they are given.
|
|
455
|
-
*
|
|
456
|
-
* THE CONDITION, every term measured: their buffer, on its present trend,
|
|
457
|
-
* would run dry before another output could close the piece they need
|
|
458
|
-
* (`drain-threat.js`), AND a smaller output removes the reason — their link
|
|
459
|
-
* carries less than the stream they are given, or this machine makes the
|
|
460
|
-
* picture slower than realtime over its run's own working time. A buffer
|
|
461
|
-
* that falls without that threat moves nothing; a threat neither reason
|
|
462
|
-
* explains (the swarm is short) is not answered with a smaller picture, which
|
|
463
|
-
* reads the same input.
|
|
464
|
-
*
|
|
465
|
-
* THE LEVERS, in order: for the link, another limit of the height on their
|
|
466
|
-
* screen (roadmap item 97, step 12), then a lower height; for the machine, a
|
|
467
|
-
* lower height. A lower height is asked of their player as URGENT — their
|
|
468
|
-
* page switches as soon as the rung is ready, without waiting for a cushion
|
|
469
|
-
* that is shrinking. Where nothing can be prepared they stay where they are,
|
|
470
|
-
* with no message.
|
|
471
|
-
*
|
|
472
|
-
* @param {HlsSession} session - The output on their screen.
|
|
473
|
-
* @param {string} consumerId
|
|
474
|
-
* @returns {Promise<boolean>} True when a move was started or asked for.
|
|
475
|
-
*/
|
|
476
|
-
async #checkSupply(session, consumerId) {
|
|
477
|
-
if (!this.#onScreenHere(session, consumerId)) {
|
|
478
|
-
return false;
|
|
479
|
-
}
|
|
480
|
-
const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
|
|
481
|
-
const { threat, secondsToEmpty } = drainThreat({
|
|
482
|
-
bufferedSec: buffer?.bufferedSec ?? 0,
|
|
483
|
-
slope: buffer?.slope ?? null,
|
|
484
|
-
reportGapSec: buffer?.reportGapSec ?? 0,
|
|
485
|
-
secondsToReady: this.#secondsToReady(session)
|
|
486
|
-
});
|
|
487
|
-
if (!threat) {
|
|
488
|
-
return false;
|
|
489
|
-
}
|
|
490
|
-
// Whatever else happens, a step UP prepared for them is not what they need.
|
|
491
|
-
this.#dropUpAsk(session, consumerId, "their buffer is draining");
|
|
492
|
-
if (this.#host.sameHeightSwitchDirection(consumerId) === "up") {
|
|
493
|
-
this.#host.cancelSameHeightSwitch(consumerId, "their buffer is draining");
|
|
494
|
-
}
|
|
495
|
-
const report = this.#host.linkReportOf(session, consumerId);
|
|
496
|
-
const observed = report ? await this.observedStreamMbps(session) : null;
|
|
497
|
-
const linkShort = report !== null && report !== undefined && observed !== null && report.linkMbps < observed;
|
|
498
|
-
const speed = session.spec.transcodesVideo ? this.#processingSpeedOf(session) : null;
|
|
499
|
-
const machineShort = speed !== null && speed < 1;
|
|
500
|
-
if (!linkShort && !machineShort) {
|
|
501
|
-
return false;
|
|
502
|
-
}
|
|
503
|
-
if (this.#host.sameHeightSwitchPending(consumerId)) {
|
|
504
|
-
return false; // a move down is already being prepared for them
|
|
505
|
-
}
|
|
506
|
-
const reasonText =
|
|
507
|
-
(linkShort ? `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` : "") +
|
|
508
|
-
(machineShort ? `CPU-bound speed=${speed.toFixed(2)}x ` : "") +
|
|
509
|
-
`buffer=${(buffer?.bufferedSec ?? 0).toFixed(1)}s empty in ${secondsToEmpty === null ? "?" : secondsToEmpty.toFixed(1)}s ` +
|
|
510
|
-
`for ${consumerId}`;
|
|
511
|
-
if (linkShort) {
|
|
512
|
-
const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "down", `viewer-link-bound ${reasonText}`);
|
|
513
|
-
if (move.started) {
|
|
514
|
-
return true;
|
|
515
|
-
}
|
|
516
|
-
logger.info(`[budget] transcode ${session.id} ${reasonText}: no lower limit to move to (${move.reason})`);
|
|
517
|
-
}
|
|
518
|
-
const base = this.#host.outputs.pictureOf(session);
|
|
519
|
-
if (this.#askLowerHeight(session, `${linkShort ? "viewer-link-bound" : "machine-bound"} ${reasonText}`, [consumerId], {
|
|
520
|
-
urgent: true,
|
|
521
|
-
cap: this.#visibleCapOf(base, consumerId)
|
|
522
|
-
})) {
|
|
523
|
-
return true;
|
|
524
|
-
}
|
|
525
|
-
logger.info(
|
|
526
|
-
`[budget] transcode ${session.id} ${reasonText}: nothing smaller can be prepared; ` +
|
|
527
|
-
`the viewer stays on what they are given`
|
|
528
|
-
);
|
|
529
|
-
return false;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* The picture this viewer sees against the height they are given (roadmap
|
|
534
|
-
* item 98).
|
|
535
|
-
*
|
|
536
|
-
* A re-encode taller than the picture they see is moved down to a rung of
|
|
537
|
-
* the right height — but only onto one that is READY, the piece they will
|
|
538
|
-
* ask for next closed on it; their page then switches once they hold the
|
|
539
|
-
* cushion this file needs. With no such rung ready, nothing is started: the
|
|
540
|
-
* next judgement of their link or of the machine takes the bound into
|
|
541
|
-
* account (`#askLowerHeight`).
|
|
542
|
-
*
|
|
543
|
-
* A picture shorter than the one they see is moved up one rung, in the
|
|
544
|
-
* background, when the machine and their link have room; the request is
|
|
545
|
-
* dropped if that room goes.
|
|
546
|
-
*
|
|
547
|
-
* A copy of the source is left alone whatever its height: it is never
|
|
548
|
-
* re-encoded for being taller than the picture on screen.
|
|
549
|
-
*
|
|
550
|
-
* @param {HlsSession} session - The output on their screen.
|
|
551
|
-
* @param {string} consumerId
|
|
552
|
-
* @returns {boolean} True when a move was asked for.
|
|
553
|
-
*/
|
|
554
|
-
#checkVisiblePicture(session, consumerId) {
|
|
555
|
-
if (!session.spec.transcodesVideo || !this.#onScreenHere(session, consumerId)) {
|
|
556
|
-
return false;
|
|
557
|
-
}
|
|
558
|
-
const base = this.#host.outputs.pictureOf(session);
|
|
559
|
-
const cap = this.#visibleCapOf(base, consumerId);
|
|
560
|
-
if (cap === null || !this.#host.outputs.publishesVariants(base)) {
|
|
561
|
-
return false;
|
|
562
|
-
}
|
|
563
|
-
const current = this.#host.outputs.variantHeightOf(session);
|
|
564
|
-
const offered = this.#host.qualityOffer.offeredHeights(base);
|
|
565
|
-
if (current > cap) {
|
|
566
|
-
const target = this.#host.outputs.splicableHeights(base)
|
|
567
|
-
.find((height) => height <= cap && offered.includes(height));
|
|
568
|
-
if (target === undefined || !this.#host.heightReadyFor(base, consumerId, target)) {
|
|
569
|
-
return false;
|
|
570
|
-
}
|
|
571
|
-
this.#askQualityHeight(base, target, `the picture they see (${cap}p) is smaller than ${current}p`, [consumerId]);
|
|
572
|
-
return true;
|
|
573
|
-
}
|
|
574
|
-
return false;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/**
|
|
578
|
-
* The height a re-encode is bounded by for this viewer, from the picture
|
|
579
|
-
* they see, or null when their page has not said.
|
|
580
|
-
*
|
|
581
|
-
* @param {HlsSession} base
|
|
582
|
-
* @param {string} consumerId
|
|
583
|
-
* @returns {number | null}
|
|
584
|
-
*/
|
|
585
|
-
#visibleCapOf(base, consumerId) {
|
|
586
|
-
return visibleHeightCap(base.file.width, base.file.height, this.#host.visiblePictureOf(base, consumerId));
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Let go of a request to move this viewer UP, if one stands.
|
|
591
|
-
*
|
|
592
|
-
* @param {HlsSession} session
|
|
593
|
-
* @param {string} consumerId
|
|
594
|
-
* @param {string} because
|
|
595
|
-
* @returns {void}
|
|
596
|
-
*/
|
|
597
|
-
#dropUpAsk(session, consumerId, because) {
|
|
598
|
-
const base = this.#host.outputs.pictureOf(session);
|
|
599
|
-
const ask = this.#host.standingAskOf(base, consumerId);
|
|
600
|
-
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
601
|
-
const current = this.#host.outputs.variantHeightOf((onScreen ? this.#host.outputs.get(onScreen) : null) ?? base);
|
|
602
|
-
if (ask && ask.height > current) {
|
|
603
|
-
this.#host.dropAskOf(base, consumerId);
|
|
604
|
-
logger.info(`[budget] transcode ${base.id} no longer asks ${consumerId} for ${ask.height}p: ${because}`);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Whether this output is the one on this viewer's screen.
|
|
610
|
-
*
|
|
611
|
-
* A viewer is registered on the picture for as long as they watch any step
|
|
612
|
-
* of it, so being present on an output does not mean watching it. What an
|
|
613
|
-
* output measures — its stream, its speed — is about the people who have it
|
|
614
|
-
* on screen, and a decision about a viewer's link is taken against the
|
|
615
|
-
* output they actually receive.
|
|
616
|
-
*
|
|
617
|
-
* @param {HlsSession} session
|
|
618
|
-
* @param {string} consumerId
|
|
619
|
-
* @returns {boolean}
|
|
620
|
-
*/
|
|
621
|
-
#onScreenHere(session, consumerId) {
|
|
622
|
-
const base = this.#host.outputs.pictureOf(session);
|
|
623
|
-
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
624
|
-
return (onScreen ?? base.id) === session.id;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Observed produced bitrate (Mbit/s): every finished piece of this output,
|
|
629
|
-
* its bytes over its own length on the cut table. A published piece is
|
|
630
|
-
* closed, so none is excluded, and no chosen number of recent pieces stands
|
|
631
|
-
* in for the stream. Null while nothing is finished.
|
|
632
|
-
*
|
|
633
|
-
* @param {HlsSession} session
|
|
634
|
-
* @returns {Promise<number | null>}
|
|
635
|
-
*/
|
|
636
|
-
async observedStreamMbps(session) {
|
|
637
|
-
// Every finished piece, each over its own length on the output's cut
|
|
638
|
-
// table: a copy is cut at the source's keyframes, so its pieces are not
|
|
639
|
-
// one length. The store knows their sizes; nothing is read from the disk.
|
|
640
|
-
const sizes = this.#host.segmentPaths.sizesOf(session.outputKey ?? "");
|
|
641
|
-
let bytes = 0;
|
|
642
|
-
let seconds = 0;
|
|
643
|
-
for (const [index, size] of sizes) {
|
|
644
|
-
const length = session.timeline
|
|
645
|
-
? session.timeline.publishedStartOf(index + 1) - session.timeline.publishedStartOf(index)
|
|
646
|
-
: this.#host.segmentDurationSec;
|
|
647
|
-
if (size > 0 && length > 0) {
|
|
648
|
-
bytes += size;
|
|
649
|
-
seconds += length;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
return seconds > 0 ? (bytes * 8) / seconds / 1e6 : null;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* The height this proxy is asking the player to move to, or 0.
|
|
657
|
-
*
|
|
658
|
-
* Cleared the moment the viewer is on it — the request has been answered —
|
|
659
|
-
* and let go by the judgement of their next report that does not ask for it
|
|
660
|
-
* again (`noteViewerReported`): a request stands for as long as the
|
|
661
|
-
* conditions it was asked under hold, not for a chosen time. A player that
|
|
662
|
-
* cannot follow it reads it in every progress answer and does nothing, which
|
|
663
|
-
* costs nothing; it is written to the log once, when it is made.
|
|
664
|
-
*
|
|
665
|
-
* @param {HlsSession} named - The session the browser addressed.
|
|
666
|
-
* @returns {number}
|
|
667
|
-
*/
|
|
668
|
-
standingAskFor(named, consumerId = "") {
|
|
669
|
-
const base = this.#host.outputs.pictureOf(named);
|
|
670
|
-
if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
|
|
671
|
-
this.#host.dropAskOf(base, consumerId);
|
|
672
|
-
return 0;
|
|
673
|
-
}
|
|
674
|
-
const ask = this.#host.standingAskOf(base, consumerId);
|
|
675
|
-
if (!ask) {
|
|
676
|
-
return 0;
|
|
677
|
-
}
|
|
678
|
-
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
679
|
-
const current = onScreen ? this.#host.outputs.get(onScreen) : base;
|
|
680
|
-
if (this.#host.outputs.variantHeightOf(current ?? base) === ask.height) {
|
|
681
|
-
this.#host.dropAskOf(base, consumerId); // this viewer is there; nothing left to ask for
|
|
682
|
-
return 0;
|
|
683
|
-
}
|
|
684
|
-
return ask.height;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* Whether the request standing for this viewer is urgent — their buffer
|
|
689
|
-
* would run dry before anything else could arrive.
|
|
690
|
-
*
|
|
691
|
-
* @param {HlsSession} named
|
|
692
|
-
* @param {string} [consumerId]
|
|
693
|
-
* @returns {boolean}
|
|
694
|
-
*/
|
|
695
|
-
standingAskIsUrgent(named, consumerId = "") {
|
|
696
|
-
if (this.standingAskFor(named, consumerId) === 0) {
|
|
697
|
-
return false;
|
|
698
|
-
}
|
|
699
|
-
return this.#host.standingAskOf(this.#host.outputs.pictureOf(named), consumerId)?.urgent === true;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
heightsOnScreen(base) {
|
|
703
|
-
return [...this.variantsOnScreen(base)]
|
|
704
|
-
.map((id) => this.#host.outputs.get(id))
|
|
705
|
-
.filter((member) => member)
|
|
706
|
-
.map((member) => this.#host.outputs.variantHeightOf(member));
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Every session of this family that a live viewer has on screen.
|
|
711
|
-
*
|
|
712
|
-
* The question "may this rung's encoder be stopped" has no single answer once
|
|
713
|
-
* two viewers watch one picture at two qualities: the rung one of them left is
|
|
714
|
-
* the rung the other is watching. Nothing may be stopped for being left unless
|
|
715
|
-
* nobody is left on it.
|
|
716
|
-
*
|
|
717
|
-
* @param {HlsSession} base
|
|
718
|
-
* @returns {Set<string>} Session ids.
|
|
719
|
-
*/
|
|
720
|
-
variantsOnScreen(base) {
|
|
721
|
-
const live = this.#host.liveConsumers(base);
|
|
722
|
-
const onScreen = new Set();
|
|
723
|
-
for (const consumerId of this.#host.consumersOn(base)) {
|
|
724
|
-
if (consumerId && live.size > 0 && !live.has(consumerId)) {
|
|
725
|
-
continue;
|
|
726
|
-
}
|
|
727
|
-
// No step chosen is the picture itself, which is on screen too.
|
|
728
|
-
onScreen.add(this.#host.stepOnScreenOf(base, consumerId) ?? base.id);
|
|
729
|
-
}
|
|
730
|
-
if (onScreen.size === 0) {
|
|
731
|
-
onScreen.add(base.id);
|
|
732
|
-
}
|
|
733
|
-
return onScreen;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* The heights this session's file will be served at, largest first, and the
|
|
738
|
-
* two lists a file would be served at before any session exists.
|
|
739
|
-
*
|
|
740
|
-
* Both are the quality layer's answers and are computed there. What is left
|
|
741
|
-
* here is the door: a route holds the session manager and asks it, and these
|
|
742
|
-
* go the day the route can ask the layer directly.
|
|
743
|
-
*
|
|
744
|
-
* @param {HlsSession} session
|
|
745
|
-
* @returns {number[]}
|
|
746
|
-
*/
|
|
747
|
-
offeredHeights(session) {
|
|
748
|
-
return this.#host.qualityOffer.offeredHeights(session);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* @param {object} mediaInfo
|
|
753
|
-
* @returns {{ copy: number[], transcode: number[] } | null}
|
|
754
|
-
*/
|
|
755
|
-
predictOfferedHeights(mediaInfo) {
|
|
756
|
-
return this.#host.qualityOffer.predictOfferedHeights(mediaInfo);
|
|
757
|
-
}
|
|
758
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file The quality budget of the outputs that exist: when a picture is asked to
|
|
3
|
+
* move to another height, when its bitrate is capped by a viewer's measured link,
|
|
4
|
+
* and what the player is told to follow.
|
|
5
|
+
*
|
|
6
|
+
* Moved out of the session manager whole (plan step 3). What it needs of the
|
|
7
|
+
* rest of the proxy is the host object it is built with, and nothing else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logger } from "../../../utils/logger.js";
|
|
11
|
+
import { linkCouldCarry, loadOf } from "./link-budget.js";
|
|
12
|
+
import { qualityStateOf } from "./OutputQualityState.js";
|
|
13
|
+
import { drainThreat } from "./drain-threat.js";
|
|
14
|
+
import { visibleHeightCap } from "./visible-rung.js";
|
|
15
|
+
|
|
16
|
+
// How often the host's load and the torrents' download rates are sampled.
|
|
17
|
+
// A sampling period of the machine, not a quality decision: every decision
|
|
18
|
+
// about a viewer's quality is taken on that viewer's report.
|
|
19
|
+
export const BUDGET_CHECK_INTERVAL_MS = 5_000;
|
|
20
|
+
// Every quality step is judged on a report a viewer makes, for that viewer
|
|
21
|
+
// alone (roadmap item 98). DOWN: their buffer, on its present trend, would run
|
|
22
|
+
// dry before another output could have the piece they need (`drain-threat.js`),
|
|
23
|
+
// and the reason is one a smaller output removes — their link carries less
|
|
24
|
+
// than the stream, or this machine makes the picture slower than realtime
|
|
25
|
+
// over its own working time (`RunClock`). UP: their buffer is not draining,
|
|
26
|
+
// holds the time another output takes to be ready, the machine makes the
|
|
27
|
+
// picture at least at realtime, and their link carries the next height. A
|
|
28
|
+
// chosen threshold of slowness, a chosen window to sustain it, a window four
|
|
29
|
+
// times as long before stepping back up, a safety share of the link and a
|
|
30
|
+
// chosen test for a download-starved input stood here; all are gone. A manual
|
|
31
|
+
// viewer is never sent an automatic quality request.
|
|
32
|
+
|
|
33
|
+
/** @typedef {{ pathOf: (address: string, index: number) => string | null, sizesOf: (address: string) => Map<number, number> }} SegmentPathLookup */
|
|
34
|
+
|
|
35
|
+
export class QualityController {
|
|
36
|
+
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
37
|
+
#host;
|
|
38
|
+
|
|
39
|
+
/** A sampling pass is under way; a timer tick that lands meanwhile is skipped. */
|
|
40
|
+
#tickRunning = false;
|
|
41
|
+
|
|
42
|
+
/** Viewers whose report is being judged now (`noteViewerReported`). @type {Set<string>} */
|
|
43
|
+
#judging = new Set();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Viewers whose standing request the judgement of their current report asked
|
|
47
|
+
* for again. A request stands for as long as the conditions it was asked
|
|
48
|
+
* under hold, and only a judgement can say they do; one it did not repeat is
|
|
49
|
+
* let go. @type {Set<string>}
|
|
50
|
+
*/
|
|
51
|
+
#affirmed = new Set();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `expectedFirstSegmentMs`
|
|
55
|
+
* @param {SegmentPathLookup} host.segmentPaths - Lookup for completed
|
|
56
|
+
* segment paths needed by quality decisions.
|
|
57
|
+
*/
|
|
58
|
+
constructor(host) {
|
|
59
|
+
this.#host = host;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One sampling pass: the host's load and the torrents' download rates.
|
|
64
|
+
*
|
|
65
|
+
* Public because it is an operation with a name, not an implementation detail
|
|
66
|
+
* of a timer. It decides nothing about any viewer: an encoder's price is
|
|
67
|
+
* learned when it closes a piece (`EncodeCost.learnFrom`), and a quality step
|
|
68
|
+
* is judged on a viewer's report (`noteViewerReported`).
|
|
69
|
+
*
|
|
70
|
+
* @returns {Promise<void>}
|
|
71
|
+
*/
|
|
72
|
+
async runQualityBudgetOnce() {
|
|
73
|
+
void this.#host.reportHostLoad();
|
|
74
|
+
// One tick at a time: the rates await torrent statistics per source, so a
|
|
75
|
+
// slow answer would otherwise let the next tick in behind it and take the
|
|
76
|
+
// same reading twice.
|
|
77
|
+
if (this.#tickRunning === true) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.#tickRunning = true;
|
|
81
|
+
try {
|
|
82
|
+
// Taken whatever the encoder is: the torrent's price is charged against
|
|
83
|
+
// this rate on every host, not only on the ones that re-encode.
|
|
84
|
+
await this.#host.sampleDownloadRates();
|
|
85
|
+
} finally {
|
|
86
|
+
this.#tickRunning = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* This output's processing speed now, over its run's own working time, or
|
|
92
|
+
* null when no run has measured one. Copying and re-encoding alike; a copy
|
|
93
|
+
* has no encoder a smaller picture would relieve, which is decided where
|
|
94
|
+
* this is asked.
|
|
95
|
+
*
|
|
96
|
+
* @param {HlsSession} session
|
|
97
|
+
* @returns {number | null}
|
|
98
|
+
*/
|
|
99
|
+
#processingSpeedOf(session) {
|
|
100
|
+
return this.#host.encodeCost.latestSpeedReadingOf(session)?.speed ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How long another output of the mode on this viewer's screen takes to be
|
|
105
|
+
* ready here, in seconds: as observed on this host (roadmap item 97, step
|
|
106
|
+
* 14), and otherwise this host's time to a first segment. Null when neither
|
|
107
|
+
* has been measured.
|
|
108
|
+
*
|
|
109
|
+
* @param {HlsSession} session
|
|
110
|
+
* @returns {number | null}
|
|
111
|
+
*/
|
|
112
|
+
#secondsToReady(session) {
|
|
113
|
+
const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
|
|
114
|
+
const expectedMs = Number.isFinite(observedMs) ? observedMs : (this.#host.expectedFirstSegmentMs?.() ?? null);
|
|
115
|
+
return Number.isFinite(expectedMs) && expectedMs >= 0 ? expectedMs / 1000 : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Ask the player to move down one offered rung.
|
|
120
|
+
*
|
|
121
|
+
* THE SIZE OF THE PICTURE IS NEVER REWRITTEN UNDERNEATH A RUNNING SESSION.
|
|
122
|
+
* The fMP4 init segment is fetched once — a player reads `#EXT-X-MAP` and
|
|
123
|
+
* never asks again — and `avc1` keeps SPS and PPS in it rather than in the
|
|
124
|
+
* fragments, so every fragment produced after a size change is decoded
|
|
125
|
+
* against parameter sets describing a picture that is no longer being made.
|
|
126
|
+
* Measured 2026-08-21 on two files: one browser went on reporting
|
|
127
|
+
* `size=1280x720` for three and a half minutes over a band of macroblock
|
|
128
|
+
* garbage after the encoder had left for 960x540; the other errored on the
|
|
129
|
+
* first mismatched fragment, closed the MediaSource and sat at `size=0x0`
|
|
130
|
+
* for four and a half minutes. Which of the two happens is the decoder's
|
|
131
|
+
* choice, not ours, and no layer reported an error either time.
|
|
132
|
+
*
|
|
133
|
+
* A change of resolution is a change of VARIANT, as the standard has it.
|
|
134
|
+
* Every height is already published in the master with its own init, so the
|
|
135
|
+
* step is made by ASKING the browser to move — the same act the manual menu
|
|
136
|
+
* performs, which has never had this fault.
|
|
137
|
+
*
|
|
138
|
+
* @param {HlsSession} session
|
|
139
|
+
* @param {string} reasonText
|
|
140
|
+
* @param {string[] | null} [onlyFor]
|
|
141
|
+
* @param {{ urgent?: boolean, cap?: number | null }} [options] - `urgent`
|
|
142
|
+
* when the viewer's buffer would run dry first; `cap` the height the
|
|
143
|
+
* picture they see bounds a re-encode by, preferred where it is lower than
|
|
144
|
+
* the next rung down.
|
|
145
|
+
* @returns {boolean} True when an ask was recorded.
|
|
146
|
+
*/
|
|
147
|
+
#askLowerHeight(session, reasonText, onlyFor = null, { urgent = false, cap = null } = {}) {
|
|
148
|
+
const base = this.#host.outputs.pictureOf(session);
|
|
149
|
+
const current = this.#host.outputs.variantHeightOf(session);
|
|
150
|
+
const offered = this.#host.qualityOffer.offeredHeights(base);
|
|
151
|
+
// The highest rung strictly below the one on screen that this host is still
|
|
152
|
+
// willing to serve. `offeredHeights` has already refused everything the
|
|
153
|
+
// machine cannot hold, so a rung that survives it is one worth moving to.
|
|
154
|
+
// Where the picture the viewer sees bounds a re-encode lower still, the
|
|
155
|
+
// highest rung within that bound is taken instead: they are shown nothing
|
|
156
|
+
// the rung between would have added (roadmap item 98).
|
|
157
|
+
const lower = this.#host.outputs.splicableHeights(base)
|
|
158
|
+
.filter((height) => height < current && offered.includes(height));
|
|
159
|
+
const next = (Number.isInteger(cap) && cap > 0 ? lower.find((height) => height <= cap) : undefined) ?? lower[0];
|
|
160
|
+
if (next === undefined) {
|
|
161
|
+
// Nothing lower — but "lower" is not the same question as "cheaper", and
|
|
162
|
+
// on a source that is COPIED the answer is above, not below. A copied
|
|
163
|
+
// rung costs no encoder at all, whatever its size, so when a re-encode
|
|
164
|
+
// cannot keep up it is both the fastest thing this host can serve AND the
|
|
165
|
+
// best picture it has.
|
|
166
|
+
//
|
|
167
|
+
// Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
|
|
168
|
+
// 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
|
|
169
|
+
// own 1038p sat on offer beside it, copied and free. This line printed
|
|
170
|
+
// fifty times — "nothing lower is on offer; leaving the picture alone" —
|
|
171
|
+
// and the picture stood still 161 times for 940 seconds. The rescue was
|
|
172
|
+
// on the screen the whole time and the rule could only look down.
|
|
173
|
+
const copied = this.#host.qualityOffer.copiedHeightOf(base);
|
|
174
|
+
if (copied > 0 && copied !== current && offered.includes(copied)) {
|
|
175
|
+
logger.info(
|
|
176
|
+
`[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
|
|
177
|
+
`but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
|
|
178
|
+
`Asking for it instead of leaving the viewer on an encode that cannot keep up`
|
|
179
|
+
);
|
|
180
|
+
return this.#askQualityHeight(base, copied, reasonText, onlyFor, urgent);
|
|
181
|
+
}
|
|
182
|
+
logger.info(
|
|
183
|
+
`[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
|
|
184
|
+
`for "${session.file.name}"; leaving the picture alone`
|
|
185
|
+
);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
return this.#askQualityHeight(base, next, reasonText, onlyFor, urgent);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Record a request to the viewer's player to move to another variant.
|
|
193
|
+
*
|
|
194
|
+
* The proxy cannot move a player between variants; it can only say which one
|
|
195
|
+
* it would rather serve. The request travels in every progress report, and
|
|
196
|
+
* the browser honours it ONLY in automatic mode — a height the viewer picked
|
|
197
|
+
* by hand is theirs, and nothing here may take it away.
|
|
198
|
+
*
|
|
199
|
+
* @param {HlsSession} base
|
|
200
|
+
* @param {number} height
|
|
201
|
+
* @param {string} reasonText
|
|
202
|
+
* @param {string[] | null} [onlyFor] - The viewers this ask is FOR. A reason
|
|
203
|
+
* that belongs to one person's link is theirs alone: a thin link asks only
|
|
204
|
+
* the viewer on it, never everybody watching the picture. Null for a reason
|
|
205
|
+
* that belongs to the output — the machine cannot keep up with it — which
|
|
206
|
+
* is every AUTO viewer's.
|
|
207
|
+
* @param {boolean} [urgent] - Their buffer would run dry before anything else
|
|
208
|
+
* could arrive, so their page switches without waiting for a cushion.
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
#askQualityHeight(base, height, reasonText, onlyFor = null, urgent = false) {
|
|
212
|
+
if (!this.#host.outputs.publishesVariants(base)) {
|
|
213
|
+
// Said once for the session. Repeating it is not information: the answer
|
|
214
|
+
// is a property of the stream and cannot change while it plays.
|
|
215
|
+
if (qualityStateOf(base).saidNoVariants !== true) {
|
|
216
|
+
qualityStateOf(base).saidNoVariants = true;
|
|
217
|
+
logger.info(
|
|
218
|
+
`[budget] transcode ${base.id} would ask for ${height}p, but this stream publishes no ` +
|
|
219
|
+
`variants to move between; leaving the picture alone for the rest of the session`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
const asked = [];
|
|
226
|
+
const affirmed = [];
|
|
227
|
+
const playing = [];
|
|
228
|
+
for (const consumerId of this.#host.consumersOn(base)) {
|
|
229
|
+
if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (onlyFor && !onlyFor.includes(consumerId)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
236
|
+
const current = onScreen ? this.#host.outputs.get(onScreen) : base;
|
|
237
|
+
const currentHeight = this.#host.outputs.variantHeightOf(current ?? base);
|
|
238
|
+
playing.push(currentHeight);
|
|
239
|
+
if (currentHeight === height) {
|
|
240
|
+
this.#host.dropAskOf(base, consumerId);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const standing = this.#host.standingAskOf(base, consumerId);
|
|
244
|
+
if (standing && standing.height === height) {
|
|
245
|
+
this.#affirmed.add(consumerId);
|
|
246
|
+
affirmed.push(consumerId);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (this.#host.askQualityOf(base, consumerId, height, reasonText, now, urgent)) {
|
|
250
|
+
this.#affirmed.add(consumerId);
|
|
251
|
+
asked.push(consumerId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (asked.length === 0) {
|
|
255
|
+
// Asked already and asked again: true, so a caller does not go on to say
|
|
256
|
+
// that nothing could be prepared; nothing new is written.
|
|
257
|
+
return affirmed.length > 0;
|
|
258
|
+
}
|
|
259
|
+
logger.info(
|
|
260
|
+
`[budget] transcode ${base.id} asks the player to move ${playing.join("p/")}p → ${height}p${urgent ? " (urgent)" : ""}: ${reasonText} ` +
|
|
261
|
+
`"${base.file.name}" for ${asked.length} AUTO viewer(s) ` +
|
|
262
|
+
`(a change of size is a change of variant — its own init describes it)`
|
|
263
|
+
);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The step BACK UP, in two stages and one rung at a time.
|
|
269
|
+
*
|
|
270
|
+
* 1. ANOTHER LIMIT OF THE SAME HEIGHT: a viewer moved down to a lower limit
|
|
271
|
+
* goes back up the limits of their height first, without their player
|
|
272
|
+
* being told (roadmap item 97, step 12). A limit is part of an output, so
|
|
273
|
+
* this is a move between outputs, prepared for one viewer.
|
|
274
|
+
* 2. ANOTHER HEIGHT, asked of the player in AUTO — only for a viewer with no
|
|
275
|
+
* higher limit to go to on the height they watch, and only if their own
|
|
276
|
+
* link admits the next height's whole load.
|
|
277
|
+
*
|
|
278
|
+
* Both follow one window: the machine and the viewer's link have held the
|
|
279
|
+
* output on screen, with room to spare, for four times as long as a step
|
|
280
|
+
* DOWN needs.
|
|
281
|
+
*
|
|
282
|
+
* @param {HlsSession} session
|
|
283
|
+
* @param {number} now
|
|
284
|
+
* @returns {Promise<void>}
|
|
285
|
+
*/
|
|
286
|
+
async #checkStepUp(session, consumerId) {
|
|
287
|
+
if (!this.#roomToSpare(session, consumerId)) {
|
|
288
|
+
// A step up being prepared for a viewer who no longer has room is let go:
|
|
289
|
+
// the conditions it was asked under have gone back (roadmap item 98).
|
|
290
|
+
this.#dropUpAsk(session, consumerId, "the room it was asked for has gone");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const base = this.#host.outputs.pictureOf(session);
|
|
294
|
+
const current = this.#host.outputs.variantHeightOf(session);
|
|
295
|
+
const reasonText = `the machine and the link carry ${current}p with room to spare`;
|
|
296
|
+
const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "up", reasonText);
|
|
297
|
+
if (move.started) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Refused because the MACHINE holds no more encoders: a higher height costs
|
|
301
|
+
// more than the limit that was just refused, so it is not asked for.
|
|
302
|
+
if (move.noPlace) {
|
|
303
|
+
logger.info(`[budget] transcode ${session.id} no step up for ${consumerId}: ${move.reason}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// One rung at a time: the lowest height above the one on screen, never
|
|
307
|
+
// above the source (upscaling invents detail and costs more than the source
|
|
308
|
+
// itself). A rung this host has been measured failing at is not offered
|
|
309
|
+
// (`QualityOffer`), so the step back up cannot return to it.
|
|
310
|
+
const higher = this.#host.qualityOffer.nextHeightUp(base, current);
|
|
311
|
+
if (higher === undefined) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// Only where their OWN link admits the next rung, and the picture on their
|
|
315
|
+
// screen is not already served by the rung they are on: a re-encode is never
|
|
316
|
+
// made taller than the picture they see (roadmap item 98). The copy of the
|
|
317
|
+
// source is not a re-encode, and going back to it is not bounded.
|
|
318
|
+
const copied = this.#host.qualityOffer.copiedHeightOf(base);
|
|
319
|
+
const cap = this.#visibleCapOf(base, consumerId);
|
|
320
|
+
if (this.#linkAdmitsHeight(base, session, consumerId, higher) &&
|
|
321
|
+
(higher === copied || cap === null || higher <= cap)) {
|
|
322
|
+
this.#askQualityHeight(base, higher, reasonText, [consumerId]);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether this viewer has room for more, every term measured: the picture on
|
|
328
|
+
* their screen is made at least at realtime over its run's own working time
|
|
329
|
+
* (a copy is not limited by an encoder), their buffer is not draining, it
|
|
330
|
+
* holds at least the time another output takes to be ready here, and no move
|
|
331
|
+
* of theirs is already being prepared.
|
|
332
|
+
*
|
|
333
|
+
* @param {HlsSession} session
|
|
334
|
+
* @param {string} consumerId
|
|
335
|
+
* @returns {boolean}
|
|
336
|
+
*/
|
|
337
|
+
#roomToSpare(session, consumerId) {
|
|
338
|
+
if (!this.#onScreenHere(session, consumerId) || this.#host.sameHeightSwitchPending(consumerId)) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
if (session.spec.transcodesVideo) {
|
|
342
|
+
const speed = this.#processingSpeedOf(session);
|
|
343
|
+
if (speed === null || speed < 1) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
|
|
348
|
+
if (!buffer || !Number.isFinite(buffer.slope) || buffer.slope < 0) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
const secondsToReady = this.#secondsToReady(session);
|
|
352
|
+
return secondsToReady !== null && (buffer.bufferedSec ?? 0) >= secondsToReady;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Whether this viewer's own link admits the whole load of a height nothing
|
|
357
|
+
* may have produced yet.
|
|
358
|
+
*
|
|
359
|
+
* Nothing measured their link: no ground to refuse, the same silence that
|
|
360
|
+
* stops `#checkSupply` from blaming their link.
|
|
361
|
+
*
|
|
362
|
+
* @param {HlsSession} base
|
|
363
|
+
* @param {HlsSession} session
|
|
364
|
+
* @param {string} consumerId
|
|
365
|
+
* @param {number} height
|
|
366
|
+
* @returns {boolean}
|
|
367
|
+
*/
|
|
368
|
+
#linkAdmitsHeight(base, session, consumerId, height) {
|
|
369
|
+
const report = this.#host.linkReportOf(session, consumerId);
|
|
370
|
+
const load = loadOf(
|
|
371
|
+
this.#host.qualityOffer.videoLoadFor(base, height, this.#host.videoEncoder?.kind ?? ""),
|
|
372
|
+
this.#host.viewerAudioLoadOf(base, consumerId)
|
|
373
|
+
);
|
|
374
|
+
return linkCouldCarry(report?.linkMbps ?? null, load).admitted;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* A viewer reported on themselves: judge their link and the picture they
|
|
379
|
+
* see, for them alone, now (roadmap item 98).
|
|
380
|
+
*
|
|
381
|
+
* The report is the event that carries a change of either, so it is where
|
|
382
|
+
* they are judged — not a timer, which between two reports would only read
|
|
383
|
+
* the same statement again.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} sessionId - The output the page addressed.
|
|
386
|
+
* @param {string} consumerId
|
|
387
|
+
* @returns {Promise<void>}
|
|
388
|
+
*/
|
|
389
|
+
async noteViewerReported(sessionId, consumerId) {
|
|
390
|
+
const named = sessionId ? this.#host.outputs.get(sessionId) : null;
|
|
391
|
+
if (!named || !consumerId) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const base = this.#host.outputs.pictureOf(named);
|
|
395
|
+
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
396
|
+
const session = (onScreen ? this.#host.outputs.get(onScreen) : null) ?? base;
|
|
397
|
+
if (!this.#host.isLive(session) || session.spec.carries === "audio-only") {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// One judgement of a viewer at a time: reports can arrive while the last
|
|
401
|
+
// one is still reading its files.
|
|
402
|
+
if (this.#judging.has(consumerId)) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
this.#judging.add(consumerId);
|
|
406
|
+
this.#affirmed.delete(consumerId);
|
|
407
|
+
const standingBefore = this.#host.standingAskOf(base, consumerId);
|
|
408
|
+
try {
|
|
409
|
+
await this.#judge(session, consumerId);
|
|
410
|
+
// A request this judgement did not ask for again no longer has the
|
|
411
|
+
// conditions it was asked under: their buffer stopped draining, the room
|
|
412
|
+
// for a step up went, or the smaller picture is no longer smaller. It is
|
|
413
|
+
// let go here, and their page drops the move it was preparing.
|
|
414
|
+
const standing = this.#host.standingAskOf(base, consumerId);
|
|
415
|
+
// The same request, not one made meanwhile: compared by what it is, since
|
|
416
|
+
// it is read as a copy.
|
|
417
|
+
const unchanged = standing && standingBefore &&
|
|
418
|
+
standing.height === standingBefore.height && standing.at === standingBefore.at;
|
|
419
|
+
if (unchanged && !this.#affirmed.has(consumerId)) {
|
|
420
|
+
this.#host.dropAskOf(base, consumerId);
|
|
421
|
+
logger.info(
|
|
422
|
+
`[budget] transcode ${base.id} no longer asks ${consumerId} for ${standing.height}p: ` +
|
|
423
|
+
`the conditions it was asked under (${standing.reason}) no longer hold`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
} finally {
|
|
427
|
+
this.#affirmed.delete(consumerId);
|
|
428
|
+
this.#judging.delete(consumerId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* One judgement of one viewer's report, in order: what they are given against
|
|
434
|
+
* what keeps up, the picture they see, and the room for more.
|
|
435
|
+
*
|
|
436
|
+
* @param {HlsSession} session - The output on their screen.
|
|
437
|
+
* @param {string} consumerId
|
|
438
|
+
* @returns {Promise<void>}
|
|
439
|
+
*/
|
|
440
|
+
async #judge(session, consumerId) {
|
|
441
|
+
if (this.#host.videoEncoder?.kind === "software" &&
|
|
442
|
+
(await this.#checkSupply(session, consumerId))) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (this.#checkVisiblePicture(session, consumerId)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (this.#host.videoEncoder?.kind === "software") {
|
|
449
|
+
await this.#checkStepUp(session, consumerId);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Whether this viewer's buffer can keep up with what they are given.
|
|
455
|
+
*
|
|
456
|
+
* THE CONDITION, every term measured: their buffer, on its present trend,
|
|
457
|
+
* would run dry before another output could close the piece they need
|
|
458
|
+
* (`drain-threat.js`), AND a smaller output removes the reason — their link
|
|
459
|
+
* carries less than the stream they are given, or this machine makes the
|
|
460
|
+
* picture slower than realtime over its run's own working time. A buffer
|
|
461
|
+
* that falls without that threat moves nothing; a threat neither reason
|
|
462
|
+
* explains (the swarm is short) is not answered with a smaller picture, which
|
|
463
|
+
* reads the same input.
|
|
464
|
+
*
|
|
465
|
+
* THE LEVERS, in order: for the link, another limit of the height on their
|
|
466
|
+
* screen (roadmap item 97, step 12), then a lower height; for the machine, a
|
|
467
|
+
* lower height. A lower height is asked of their player as URGENT — their
|
|
468
|
+
* page switches as soon as the rung is ready, without waiting for a cushion
|
|
469
|
+
* that is shrinking. Where nothing can be prepared they stay where they are,
|
|
470
|
+
* with no message.
|
|
471
|
+
*
|
|
472
|
+
* @param {HlsSession} session - The output on their screen.
|
|
473
|
+
* @param {string} consumerId
|
|
474
|
+
* @returns {Promise<boolean>} True when a move was started or asked for.
|
|
475
|
+
*/
|
|
476
|
+
async #checkSupply(session, consumerId) {
|
|
477
|
+
if (!this.#onScreenHere(session, consumerId)) {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
|
|
481
|
+
const { threat, secondsToEmpty } = drainThreat({
|
|
482
|
+
bufferedSec: buffer?.bufferedSec ?? 0,
|
|
483
|
+
slope: buffer?.slope ?? null,
|
|
484
|
+
reportGapSec: buffer?.reportGapSec ?? 0,
|
|
485
|
+
secondsToReady: this.#secondsToReady(session)
|
|
486
|
+
});
|
|
487
|
+
if (!threat) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
// Whatever else happens, a step UP prepared for them is not what they need.
|
|
491
|
+
this.#dropUpAsk(session, consumerId, "their buffer is draining");
|
|
492
|
+
if (this.#host.sameHeightSwitchDirection(consumerId) === "up") {
|
|
493
|
+
this.#host.cancelSameHeightSwitch(consumerId, "their buffer is draining");
|
|
494
|
+
}
|
|
495
|
+
const report = this.#host.linkReportOf(session, consumerId);
|
|
496
|
+
const observed = report ? await this.observedStreamMbps(session) : null;
|
|
497
|
+
const linkShort = report !== null && report !== undefined && observed !== null && report.linkMbps < observed;
|
|
498
|
+
const speed = session.spec.transcodesVideo ? this.#processingSpeedOf(session) : null;
|
|
499
|
+
const machineShort = speed !== null && speed < 1;
|
|
500
|
+
if (!linkShort && !machineShort) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
if (this.#host.sameHeightSwitchPending(consumerId)) {
|
|
504
|
+
return false; // a move down is already being prepared for them
|
|
505
|
+
}
|
|
506
|
+
const reasonText =
|
|
507
|
+
(linkShort ? `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` : "") +
|
|
508
|
+
(machineShort ? `CPU-bound speed=${speed.toFixed(2)}x ` : "") +
|
|
509
|
+
`buffer=${(buffer?.bufferedSec ?? 0).toFixed(1)}s empty in ${secondsToEmpty === null ? "?" : secondsToEmpty.toFixed(1)}s ` +
|
|
510
|
+
`for ${consumerId}`;
|
|
511
|
+
if (linkShort) {
|
|
512
|
+
const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "down", `viewer-link-bound ${reasonText}`);
|
|
513
|
+
if (move.started) {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
logger.info(`[budget] transcode ${session.id} ${reasonText}: no lower limit to move to (${move.reason})`);
|
|
517
|
+
}
|
|
518
|
+
const base = this.#host.outputs.pictureOf(session);
|
|
519
|
+
if (this.#askLowerHeight(session, `${linkShort ? "viewer-link-bound" : "machine-bound"} ${reasonText}`, [consumerId], {
|
|
520
|
+
urgent: true,
|
|
521
|
+
cap: this.#visibleCapOf(base, consumerId)
|
|
522
|
+
})) {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
logger.info(
|
|
526
|
+
`[budget] transcode ${session.id} ${reasonText}: nothing smaller can be prepared; ` +
|
|
527
|
+
`the viewer stays on what they are given`
|
|
528
|
+
);
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The picture this viewer sees against the height they are given (roadmap
|
|
534
|
+
* item 98).
|
|
535
|
+
*
|
|
536
|
+
* A re-encode taller than the picture they see is moved down to a rung of
|
|
537
|
+
* the right height — but only onto one that is READY, the piece they will
|
|
538
|
+
* ask for next closed on it; their page then switches once they hold the
|
|
539
|
+
* cushion this file needs. With no such rung ready, nothing is started: the
|
|
540
|
+
* next judgement of their link or of the machine takes the bound into
|
|
541
|
+
* account (`#askLowerHeight`).
|
|
542
|
+
*
|
|
543
|
+
* A picture shorter than the one they see is moved up one rung, in the
|
|
544
|
+
* background, when the machine and their link have room; the request is
|
|
545
|
+
* dropped if that room goes.
|
|
546
|
+
*
|
|
547
|
+
* A copy of the source is left alone whatever its height: it is never
|
|
548
|
+
* re-encoded for being taller than the picture on screen.
|
|
549
|
+
*
|
|
550
|
+
* @param {HlsSession} session - The output on their screen.
|
|
551
|
+
* @param {string} consumerId
|
|
552
|
+
* @returns {boolean} True when a move was asked for.
|
|
553
|
+
*/
|
|
554
|
+
#checkVisiblePicture(session, consumerId) {
|
|
555
|
+
if (!session.spec.transcodesVideo || !this.#onScreenHere(session, consumerId)) {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
const base = this.#host.outputs.pictureOf(session);
|
|
559
|
+
const cap = this.#visibleCapOf(base, consumerId);
|
|
560
|
+
if (cap === null || !this.#host.outputs.publishesVariants(base)) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
const current = this.#host.outputs.variantHeightOf(session);
|
|
564
|
+
const offered = this.#host.qualityOffer.offeredHeights(base);
|
|
565
|
+
if (current > cap) {
|
|
566
|
+
const target = this.#host.outputs.splicableHeights(base)
|
|
567
|
+
.find((height) => height <= cap && offered.includes(height));
|
|
568
|
+
if (target === undefined || !this.#host.heightReadyFor(base, consumerId, target)) {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
this.#askQualityHeight(base, target, `the picture they see (${cap}p) is smaller than ${current}p`, [consumerId]);
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The height a re-encode is bounded by for this viewer, from the picture
|
|
579
|
+
* they see, or null when their page has not said.
|
|
580
|
+
*
|
|
581
|
+
* @param {HlsSession} base
|
|
582
|
+
* @param {string} consumerId
|
|
583
|
+
* @returns {number | null}
|
|
584
|
+
*/
|
|
585
|
+
#visibleCapOf(base, consumerId) {
|
|
586
|
+
return visibleHeightCap(base.file.width, base.file.height, this.#host.visiblePictureOf(base, consumerId));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Let go of a request to move this viewer UP, if one stands.
|
|
591
|
+
*
|
|
592
|
+
* @param {HlsSession} session
|
|
593
|
+
* @param {string} consumerId
|
|
594
|
+
* @param {string} because
|
|
595
|
+
* @returns {void}
|
|
596
|
+
*/
|
|
597
|
+
#dropUpAsk(session, consumerId, because) {
|
|
598
|
+
const base = this.#host.outputs.pictureOf(session);
|
|
599
|
+
const ask = this.#host.standingAskOf(base, consumerId);
|
|
600
|
+
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
601
|
+
const current = this.#host.outputs.variantHeightOf((onScreen ? this.#host.outputs.get(onScreen) : null) ?? base);
|
|
602
|
+
if (ask && ask.height > current) {
|
|
603
|
+
this.#host.dropAskOf(base, consumerId);
|
|
604
|
+
logger.info(`[budget] transcode ${base.id} no longer asks ${consumerId} for ${ask.height}p: ${because}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Whether this output is the one on this viewer's screen.
|
|
610
|
+
*
|
|
611
|
+
* A viewer is registered on the picture for as long as they watch any step
|
|
612
|
+
* of it, so being present on an output does not mean watching it. What an
|
|
613
|
+
* output measures — its stream, its speed — is about the people who have it
|
|
614
|
+
* on screen, and a decision about a viewer's link is taken against the
|
|
615
|
+
* output they actually receive.
|
|
616
|
+
*
|
|
617
|
+
* @param {HlsSession} session
|
|
618
|
+
* @param {string} consumerId
|
|
619
|
+
* @returns {boolean}
|
|
620
|
+
*/
|
|
621
|
+
#onScreenHere(session, consumerId) {
|
|
622
|
+
const base = this.#host.outputs.pictureOf(session);
|
|
623
|
+
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
624
|
+
return (onScreen ?? base.id) === session.id;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Observed produced bitrate (Mbit/s): every finished piece of this output,
|
|
629
|
+
* its bytes over its own length on the cut table. A published piece is
|
|
630
|
+
* closed, so none is excluded, and no chosen number of recent pieces stands
|
|
631
|
+
* in for the stream. Null while nothing is finished.
|
|
632
|
+
*
|
|
633
|
+
* @param {HlsSession} session
|
|
634
|
+
* @returns {Promise<number | null>}
|
|
635
|
+
*/
|
|
636
|
+
async observedStreamMbps(session) {
|
|
637
|
+
// Every finished piece, each over its own length on the output's cut
|
|
638
|
+
// table: a copy is cut at the source's keyframes, so its pieces are not
|
|
639
|
+
// one length. The store knows their sizes; nothing is read from the disk.
|
|
640
|
+
const sizes = this.#host.segmentPaths.sizesOf(session.outputKey ?? "");
|
|
641
|
+
let bytes = 0;
|
|
642
|
+
let seconds = 0;
|
|
643
|
+
for (const [index, size] of sizes) {
|
|
644
|
+
const length = session.timeline
|
|
645
|
+
? session.timeline.publishedStartOf(index + 1) - session.timeline.publishedStartOf(index)
|
|
646
|
+
: this.#host.segmentDurationSec;
|
|
647
|
+
if (size > 0 && length > 0) {
|
|
648
|
+
bytes += size;
|
|
649
|
+
seconds += length;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return seconds > 0 ? (bytes * 8) / seconds / 1e6 : null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* The height this proxy is asking the player to move to, or 0.
|
|
657
|
+
*
|
|
658
|
+
* Cleared the moment the viewer is on it — the request has been answered —
|
|
659
|
+
* and let go by the judgement of their next report that does not ask for it
|
|
660
|
+
* again (`noteViewerReported`): a request stands for as long as the
|
|
661
|
+
* conditions it was asked under hold, not for a chosen time. A player that
|
|
662
|
+
* cannot follow it reads it in every progress answer and does nothing, which
|
|
663
|
+
* costs nothing; it is written to the log once, when it is made.
|
|
664
|
+
*
|
|
665
|
+
* @param {HlsSession} named - The session the browser addressed.
|
|
666
|
+
* @returns {number}
|
|
667
|
+
*/
|
|
668
|
+
standingAskFor(named, consumerId = "") {
|
|
669
|
+
const base = this.#host.outputs.pictureOf(named);
|
|
670
|
+
if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
|
|
671
|
+
this.#host.dropAskOf(base, consumerId);
|
|
672
|
+
return 0;
|
|
673
|
+
}
|
|
674
|
+
const ask = this.#host.standingAskOf(base, consumerId);
|
|
675
|
+
if (!ask) {
|
|
676
|
+
return 0;
|
|
677
|
+
}
|
|
678
|
+
const onScreen = this.#host.stepOnScreenOf(base, consumerId);
|
|
679
|
+
const current = onScreen ? this.#host.outputs.get(onScreen) : base;
|
|
680
|
+
if (this.#host.outputs.variantHeightOf(current ?? base) === ask.height) {
|
|
681
|
+
this.#host.dropAskOf(base, consumerId); // this viewer is there; nothing left to ask for
|
|
682
|
+
return 0;
|
|
683
|
+
}
|
|
684
|
+
return ask.height;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Whether the request standing for this viewer is urgent — their buffer
|
|
689
|
+
* would run dry before anything else could arrive.
|
|
690
|
+
*
|
|
691
|
+
* @param {HlsSession} named
|
|
692
|
+
* @param {string} [consumerId]
|
|
693
|
+
* @returns {boolean}
|
|
694
|
+
*/
|
|
695
|
+
standingAskIsUrgent(named, consumerId = "") {
|
|
696
|
+
if (this.standingAskFor(named, consumerId) === 0) {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
return this.#host.standingAskOf(this.#host.outputs.pictureOf(named), consumerId)?.urgent === true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
heightsOnScreen(base) {
|
|
703
|
+
return [...this.variantsOnScreen(base)]
|
|
704
|
+
.map((id) => this.#host.outputs.get(id))
|
|
705
|
+
.filter((member) => member)
|
|
706
|
+
.map((member) => this.#host.outputs.variantHeightOf(member));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Every session of this family that a live viewer has on screen.
|
|
711
|
+
*
|
|
712
|
+
* The question "may this rung's encoder be stopped" has no single answer once
|
|
713
|
+
* two viewers watch one picture at two qualities: the rung one of them left is
|
|
714
|
+
* the rung the other is watching. Nothing may be stopped for being left unless
|
|
715
|
+
* nobody is left on it.
|
|
716
|
+
*
|
|
717
|
+
* @param {HlsSession} base
|
|
718
|
+
* @returns {Set<string>} Session ids.
|
|
719
|
+
*/
|
|
720
|
+
variantsOnScreen(base) {
|
|
721
|
+
const live = this.#host.liveConsumers(base);
|
|
722
|
+
const onScreen = new Set();
|
|
723
|
+
for (const consumerId of this.#host.consumersOn(base)) {
|
|
724
|
+
if (consumerId && live.size > 0 && !live.has(consumerId)) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
// No step chosen is the picture itself, which is on screen too.
|
|
728
|
+
onScreen.add(this.#host.stepOnScreenOf(base, consumerId) ?? base.id);
|
|
729
|
+
}
|
|
730
|
+
if (onScreen.size === 0) {
|
|
731
|
+
onScreen.add(base.id);
|
|
732
|
+
}
|
|
733
|
+
return onScreen;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The heights this session's file will be served at, largest first, and the
|
|
738
|
+
* two lists a file would be served at before any session exists.
|
|
739
|
+
*
|
|
740
|
+
* Both are the quality layer's answers and are computed there. What is left
|
|
741
|
+
* here is the door: a route holds the session manager and asks it, and these
|
|
742
|
+
* go the day the route can ask the layer directly.
|
|
743
|
+
*
|
|
744
|
+
* @param {HlsSession} session
|
|
745
|
+
* @returns {number[]}
|
|
746
|
+
*/
|
|
747
|
+
offeredHeights(session) {
|
|
748
|
+
return this.#host.qualityOffer.offeredHeights(session);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* @param {object} mediaInfo
|
|
753
|
+
* @returns {{ copy: number[], transcode: number[] } | null}
|
|
754
|
+
*/
|
|
755
|
+
predictOfferedHeights(mediaInfo) {
|
|
756
|
+
return this.#host.qualityOffer.predictOfferedHeights(mediaInfo);
|
|
757
|
+
}
|
|
758
|
+
}
|