@torrent-tv/proxy 2.87.8 → 2.87.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +4 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +129 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +361 -361
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2162 -2162
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1083 -1083
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +266 -266
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +1 -1
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1146 -1146
- package/services/server/ViewerRequests.js +522 -522
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +983 -983
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +657 -657
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +35 -35
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +161 -161
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/utils/torrent-source-key.js +38 -38
|
@@ -1,657 +1,657 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One person watching, and everything that is true of them alone.
|
|
3
|
-
*
|
|
4
|
-
* A viewer is not a property of the material. What they are listening to, which
|
|
5
|
-
* quality step they have on screen, what is being prepared for them, where they
|
|
6
|
-
* are and what their link can carry — none of it changes a byte of what any
|
|
7
|
-
* encoder produces, and none of it belongs to a session, which is a description
|
|
8
|
-
* of an OUTPUT.
|
|
9
|
-
*
|
|
10
|
-
* It was six parallel maps hung on the session, each keyed by consumer id:
|
|
11
|
-
* `audioChoiceByConsumer`, `activeVariantByConsumer`,
|
|
12
|
-
* `warmingVariantByConsumer`, `warmingAudioByConsumer`, `consumerHeads`,
|
|
13
|
-
* `netReports`. Six places to remember to update and six to remember to forget,
|
|
14
|
-
* and the forgetting was already wrong — releasing a consumer emptied none of
|
|
15
|
-
* them, so a viewer who had left went on counting as wanting their soundtrack
|
|
16
|
-
* until their head expired, and their entries stayed for the life of the
|
|
17
|
-
* session.
|
|
18
|
-
*
|
|
19
|
-
* One object, one map, one thing to remove.
|
|
20
|
-
*
|
|
21
|
-
* **TWO INDEPENDENT FACTS, NOT ONE.** A viewer is somewhere, and a viewer is
|
|
22
|
-
* either still here or gone. Until 2026-09-05 both were answered by one field —
|
|
23
|
-
* the position, which is written only when a segment is requested — so a viewer
|
|
24
|
-
* who had just arrived counted as absent, and an output all of whose viewers
|
|
25
|
-
* count as absent has every encoder on it stopped. That is exactly what
|
|
26
|
-
* happened on 2026-09-05: a soundtrack's encoder was stopped 1.25 s after it
|
|
27
|
-
* started, having produced nothing, its `init.mp4` was therefore never made,
|
|
28
|
-
* and the picture could not be played without it. The browser could not rescue
|
|
29
|
-
* itself either, because the only thing that would have marked the viewer
|
|
30
|
-
* present was a request for a segment — which needs the `init.mp4` that the
|
|
31
|
-
* stopped encoder was going to make.
|
|
32
|
-
*
|
|
33
|
-
* So: **position is known from the moment a viewer arrives** — it is in the
|
|
34
|
-
* request that created the output, as a time on the source, and it is either
|
|
35
|
-
* zero or what the address bar carried. And **presence is a fact of the
|
|
36
|
-
* connection**, not of the last file asked for.
|
|
37
|
-
*
|
|
38
|
-
* **Nothing here knows about ffmpeg, the disk or the torrent.** A viewer states
|
|
39
|
-
* what they want and where they are; what to make of that is the orchestrator's
|
|
40
|
-
* question, and it reads a union of viewers rather than any one of them.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import { Assignments } from "./Assignments.js";
|
|
44
|
-
import { bufferTrend, trendRunStart } from "./buffer-trend.js";
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* How long a generation this viewer has left behind goes on accepting NEW
|
|
48
|
-
* requests.
|
|
49
|
-
*
|
|
50
|
-
* A CHOSEN NUMBER, and it is named here rather than buried so that it reads as
|
|
51
|
-
* one. It cannot be derived today: it should come from the player's own retry
|
|
52
|
-
* policy, and this product overrides that policy on its own page while the
|
|
53
|
-
* native path in Safari has none at all that can be read. Until the retries a
|
|
54
|
-
* real player actually makes are measured, the value is given and the checks
|
|
55
|
-
* are written against what is given.
|
|
56
|
-
*
|
|
57
|
-
* What it protects: a request made just before a seek, still in flight when the
|
|
58
|
-
* seek lands, is answered by the output it was made against instead of being
|
|
59
|
-
* decided again against a position the viewer has already left.
|
|
60
|
-
*/
|
|
61
|
-
export const ACCEPT_WINDOW_MS = 30_000;
|
|
62
|
-
|
|
63
|
-
export class Viewer {
|
|
64
|
-
/**
|
|
65
|
-
* @param {string} id - The consumer id the browser sends with every request
|
|
66
|
-
* that means "this viewer".
|
|
67
|
-
* @param {number} [now] - When they arrived. Presence starts here, so a
|
|
68
|
-
* viewer counts as watching from the instant they are known.
|
|
69
|
-
*/
|
|
70
|
-
constructor(id, now = Date.now()) {
|
|
71
|
-
this.id = String(id ?? "");
|
|
72
|
-
/**
|
|
73
|
-
* Which soundtrack this viewer is listening to and whether their browser
|
|
74
|
-
* needs it re-encoded. Theirs alone: two viewers of one picture may have
|
|
75
|
-
* chosen different languages, and one browser may decode a track another
|
|
76
|
-
* cannot.
|
|
77
|
-
* @type {{ trackIndex: number, transcode: boolean }}
|
|
78
|
-
*/
|
|
79
|
-
this.audio = { trackIndex: 0, transcode: false };
|
|
80
|
-
/** The quality step on their screen. Null means the base session. @type {string | null} */
|
|
81
|
-
this.activeVariantId = null;
|
|
82
|
-
/** A step being prepared for a switch they have not made yet. @type {string | null} */
|
|
83
|
-
this.warmingVariantId = null;
|
|
84
|
-
/**
|
|
85
|
-
* When the warming of that step began, so the time a move takes to be
|
|
86
|
-
* ready can be kept (roadmap item 97, step 14). @type {number | null}
|
|
87
|
-
*/
|
|
88
|
-
this.warmingSince = null;
|
|
89
|
-
/** A soundtrack being prepared for the same reason. @type {string | null} */
|
|
90
|
-
this.warmingAudioId = null;
|
|
91
|
-
/**
|
|
92
|
-
* An output of the height on their screen, at another bitrate limit, being
|
|
93
|
-
* prepared so that their NEXT segments come from it (roadmap item 97, step
|
|
94
|
-
* 12). Not `warmingVariantId`: that is a step their player will switch to,
|
|
95
|
-
* and the page says when it has. Here the address does not change, the
|
|
96
|
-
* player is never told, and the switch is made on this side once the
|
|
97
|
-
* segment they will ask for next is closed on the new output.
|
|
98
|
-
*
|
|
99
|
-
* `askedHeights` are the heights their player asks for under which the
|
|
100
|
-
* output on screen is chosen — a step is chosen under the height asked and
|
|
101
|
-
* named after the height produced, so there can be more than one — and the
|
|
102
|
-
* move replaces the choice under each; `direction` is why it was started.
|
|
103
|
-
*
|
|
104
|
-
* @type {{ askedHeights: number[], outputId: string, outputKey: string, direction: "down" | "up", reason: string, since: number } | null}
|
|
105
|
-
*/
|
|
106
|
-
this.sameHeightSwitch = null;
|
|
107
|
-
/**
|
|
108
|
-
* Where their picture stood when they last said so, and when they said it.
|
|
109
|
-
*
|
|
110
|
-
* ONE FACT WITH ONE WRITER: the viewer. It is set when they arrive — from
|
|
111
|
-
* the position their own create request named — and moved only by what they
|
|
112
|
-
* state afterwards, a seek or a report carrying their playhead. Never null
|
|
113
|
-
* for a viewer this process has met, because a viewer arrives by asking for
|
|
114
|
-
* a position.
|
|
115
|
-
*
|
|
116
|
-
* A REQUEST FOR A SEGMENT IS NOT A POSITION and never writes here. It says
|
|
117
|
-
* how far their buffer has reached, which is a different quantity and one
|
|
118
|
-
* nothing in the plan needs: a segment either exists and is served, or does
|
|
119
|
-
* not and is waited for.
|
|
120
|
-
*
|
|
121
|
-
* Two writers is what this replaced, and the cost is measured. Until
|
|
122
|
-
* 2026-09-13 a seek wrote `seeked` while a request wrote `seconds`, and
|
|
123
|
-
* `positionSeconds` preferred whichever was set — so the two took turns and
|
|
124
|
-
* the priority map jumped back and forth by one or two segments several
|
|
125
|
-
* times a second. Field that day, one output over one second:
|
|
126
|
-
* `p100:#9..#10`, `p100:#18..#20`, `p100:#16..#18`, `p100:#18..#20`, with
|
|
127
|
-
* the second viewer stopped dead at 1673.6 s throughout. The encoder
|
|
128
|
-
* placement followed the map, as it must, and the machine spent six minutes
|
|
129
|
-
* on 77 starts and 141 stops against one normal end.
|
|
130
|
-
*
|
|
131
|
-
* @type {{ seconds: number, at: number } | null}
|
|
132
|
-
*/
|
|
133
|
-
this.position = null;
|
|
134
|
-
/**
|
|
135
|
-
* When this viewer was last known to be there.
|
|
136
|
-
*
|
|
137
|
-
* Every piece of evidence refreshes it: a request of any kind, a link
|
|
138
|
-
* report, an echo of a delivery probe. It is NOT the position's timestamp —
|
|
139
|
-
* a viewer with a full buffer legitimately asks for nothing for a minute
|
|
140
|
-
* and is no less present for it.
|
|
141
|
-
*
|
|
142
|
-
* @type {number}
|
|
143
|
-
*/
|
|
144
|
-
this.lastSeenAt = now;
|
|
145
|
-
/**
|
|
146
|
-
* Set when something has SAID this viewer is gone — the browser released
|
|
147
|
-
* the session, or their connection closed. Silence never sets it: a
|
|
148
|
-
* paused viewer, a viewer whose tab is hidden and whose timers the browser
|
|
149
|
-
* has throttled, and a viewer holding two minutes of buffer are all silent
|
|
150
|
-
* and all still watching.
|
|
151
|
-
*
|
|
152
|
-
* @type {boolean}
|
|
153
|
-
*/
|
|
154
|
-
this.gone = false;
|
|
155
|
-
/** What their link was last measured to carry. @type {object | null} */
|
|
156
|
-
this.netReport = null;
|
|
157
|
-
/**
|
|
158
|
-
* "manual" while the size on their screen was picked by hand, "auto" while
|
|
159
|
-
* it is the automatic choice, null until their page has said.
|
|
160
|
-
*
|
|
161
|
-
* @type {"auto" | "manual" | null}
|
|
162
|
-
*/
|
|
163
|
-
this.qualityMode = null;
|
|
164
|
-
/**
|
|
165
|
-
* The height the quality budget last asked THIS viewer's player to move
|
|
166
|
-
* to, while the request stands. Created only in AUTO (`askQuality`), read
|
|
167
|
-
* back only by this viewer's own progress poll, and let go once they are
|
|
168
|
-
* there, once it has run out, or once they are no longer in AUTO.
|
|
169
|
-
*
|
|
170
|
-
* `urgent` when their buffer would run dry before anything else could
|
|
171
|
-
* arrive: their page then switches without waiting for a cushion.
|
|
172
|
-
*
|
|
173
|
-
* @type {{ height: number, at: number, reason: string, urgent: boolean } | null}
|
|
174
|
-
*/
|
|
175
|
-
this.qualityAsk = null;
|
|
176
|
-
/**
|
|
177
|
-
* Every output this viewer is watching, by session id: the picture, the
|
|
178
|
-
* quality step on their screen, the soundtrack they chose.
|
|
179
|
-
*
|
|
180
|
-
* THE ONLY STORED FORM of the relation "this person watches this output".
|
|
181
|
-
* The question from the other end — who watches this output — is answered
|
|
182
|
-
* by `Viewers.forOutput`, which derives it from these sets. It used to be a
|
|
183
|
-
* second index kept beside this one, and two stored copies of one relation
|
|
184
|
-
* are two answers that can disagree. The registry is small, so deriving is
|
|
185
|
-
* cheaper than keeping the copies in step.
|
|
186
|
-
*
|
|
187
|
-
* This is what replaced a film object. There is no "film" anywhere in this
|
|
188
|
-
* proxy — its parts are born at different times, die at different times and
|
|
189
|
-
* are addressed separately — and the three link fields that stood in for one
|
|
190
|
-
* could not say how many people were listening to a soundtrack.
|
|
191
|
-
*
|
|
192
|
-
* @type {Set<string>}
|
|
193
|
-
*/
|
|
194
|
-
this.outputs = new Set();
|
|
195
|
-
/**
|
|
196
|
-
* Which output answers THIS viewer, and for how long that stands.
|
|
197
|
-
*
|
|
198
|
-
* Their own, which is the whole of roadmap item 97: the record it replaces
|
|
199
|
-
* — one entry per file and height, read by everybody — meant a viewer on a
|
|
200
|
-
* thin link decided for a viewer on a thick one. What it holds and why each
|
|
201
|
-
* fact is there is in `Assignments.js`.
|
|
202
|
-
*
|
|
203
|
-
* @type {Assignments}
|
|
204
|
-
*/
|
|
205
|
-
this.assignments = new Assignments({ acceptWindowMs: ACCEPT_WINDOW_MS });
|
|
206
|
-
/**
|
|
207
|
-
* On what the output this viewer was last given was judged: the verdict of
|
|
208
|
-
* their own link against its whole load, and the figures. `estimated to
|
|
209
|
-
* fit` is admitted and NOT confirmed — the figure is an average, not a
|
|
210
|
-
* bound — and the page is told so rather than left to assume it.
|
|
211
|
-
* @type {object | null}
|
|
212
|
-
*/
|
|
213
|
-
this.servingVerdict = null;
|
|
214
|
-
/**
|
|
215
|
-
* The files this viewer has subtitles switched on for, as
|
|
216
|
-
* `sourceKey:fileIndex`.
|
|
217
|
-
*
|
|
218
|
-
* A subscription is a fact about a PERSON, not about a channel. Held on the
|
|
219
|
-
* channel — as it was until now — it dies with the channel, so a seamless
|
|
220
|
-
* reconnect silently lost subtitles for the rest of the session, and a
|
|
221
|
-
* transport that rotates its association on purpose would lose them every
|
|
222
|
-
* time it rotated. Held here it survives both by construction: whatever
|
|
223
|
-
* channel this viewer is reachable on next, they are still subscribed.
|
|
224
|
-
*
|
|
225
|
-
* @type {Set<string>}
|
|
226
|
-
*/
|
|
227
|
-
this.wantsCuesFor = new Set();
|
|
228
|
-
// WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
|
|
229
|
-
// BLOCKED ON US. Two booleans, because there are three states and one
|
|
230
|
-
// boolean cannot hold them:
|
|
231
|
-
//
|
|
232
|
-
// playing the picture advances, a second of film per second;
|
|
233
|
-
// waiting it does not advance because we have not delivered;
|
|
234
|
-
// neither the viewer stopped it themselves.
|
|
235
|
-
//
|
|
236
|
-
// Collapsed into one, the middle state read as the third: a viewer frozen
|
|
237
|
-
// for want of a segment counted as somebody who had chosen to stop, so
|
|
238
|
-
// nothing in front of them fell due and the work went to whoever was
|
|
239
|
-
// playing. That is backwards — a viewer waiting on us is the most urgent
|
|
240
|
-
// there is. The page tells the two apart exactly (its element says whether
|
|
241
|
-
// the picture advances, and the component owns the viewer's own pause) and
|
|
242
|
-
// states both.
|
|
243
|
-
//
|
|
244
|
-
// `playing` STARTS FALSE, and that is the whole of the fault of
|
|
245
|
-
// 2026-09-14. It used to start true, and the position is carried forward by
|
|
246
|
-
// the clock at that rate, so a viewer who had never said anything walked
|
|
247
|
-
// 146 seconds into a film they had not begun: the tab was hidden, the page
|
|
248
|
-
// deliberately held start-up until it was shown, and this end assumed the
|
|
249
|
-
// film was running the entire time. A statement about somebody else's
|
|
250
|
-
// machine is theirs to make. Until they make it the rate is zero.
|
|
251
|
-
//
|
|
252
|
-
// `waiting` STARTS TRUE, which is what a viewer arriving is: they have
|
|
253
|
-
// asked for a film and hold none of it. So their work is made first from
|
|
254
|
-
// the instant they arrive, without the position ever being invented.
|
|
255
|
-
this.playing = false;
|
|
256
|
-
this.waiting = true;
|
|
257
|
-
// Whether the page carrying this viewer is ON SCREEN, and whether the
|
|
258
|
-
// picture has been pulled out of it.
|
|
259
|
-
//
|
|
260
|
-
// Two facts, not one, and the second is why the first is not enough: a
|
|
261
|
-
// hidden tab has its timers throttled by the browser — 800 ms of event-loop
|
|
262
|
-
// lag measured in the field — so it asks for nothing and looks exactly like
|
|
263
|
-
// a viewer holding a full cushion. Delivery stood still for the last six
|
|
264
|
-
// minutes of the session of 2026-09-08 and nothing anywhere said the tab had
|
|
265
|
-
// gone away. But a picture in picture-in-picture is watched WHILE the tab is
|
|
266
|
-
// hidden, so hiding alone cannot mean "not watching".
|
|
267
|
-
//
|
|
268
|
-
// A page that says nothing is on screen, which is what every page meant
|
|
269
|
-
// before it could say otherwise.
|
|
270
|
-
this.onScreen = true;
|
|
271
|
-
this.inPictureInPicture = false;
|
|
272
|
-
// Seconds of film held ahead of the picture, as the page last said. It is
|
|
273
|
-
// measured at the moment the position beside it was, which is what makes it
|
|
274
|
-
// the bound on carrying that position forward — see `positionSeconds`.
|
|
275
|
-
this.bufferedSeconds = null;
|
|
276
|
-
/**
|
|
277
|
-
* What the page said it held, and when, since this viewer last moved to
|
|
278
|
-
* another place in the film — what the buffer's trend is read from
|
|
279
|
-
* (`buffer-trend.js`). A seek empties the buffer, so what came before it
|
|
280
|
-
* says nothing about what comes after.
|
|
281
|
-
*
|
|
282
|
-
* @type {Array<{ at: number, seconds: number }>}
|
|
283
|
-
*/
|
|
284
|
-
this.bufferReadings = [];
|
|
285
|
-
/** The span the trend was last asked over; readings older than its run are let go. @type {number} */
|
|
286
|
-
this.trendSpanSec = 0;
|
|
287
|
-
/**
|
|
288
|
-
* The picture as this viewer sees it, in physical pixels: the frame that
|
|
289
|
-
* would be shown without being enlarged, as their page measured it. The
|
|
290
|
-
* upper bound of the height of a re-encoded output made for them (roadmap
|
|
291
|
-
* item 98). Null until their page has said.
|
|
292
|
-
*
|
|
293
|
-
* @type {{ width: number, height: number } | null}
|
|
294
|
-
*/
|
|
295
|
-
this.visiblePicture = null;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Where they are, in SECONDS of film, and nothing else.
|
|
300
|
-
*
|
|
301
|
-
* A segment number cannot live here: the picture and the soundtrack of one
|
|
302
|
-
* film are cut independently and into different numbers of pieces — 454
|
|
303
|
-
* against 401 on the field file of 2026-09-05 — so piece 48 of one is not the
|
|
304
|
-
* same moment as piece 48 of the other. Whoever holds a cut grid turns these
|
|
305
|
-
* seconds into their own numbers.
|
|
306
|
-
*
|
|
307
|
-
* @param {number} seconds
|
|
308
|
-
* @param {number} [now]
|
|
309
|
-
*/
|
|
310
|
-
moveTo(seconds, now = Date.now()) {
|
|
311
|
-
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
this.#place(seconds, now);
|
|
315
|
-
// The buffer's history belongs to the place they left.
|
|
316
|
-
this.bufferReadings = [];
|
|
317
|
-
// WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
|
|
318
|
-
// ARRIVED AT. A seek empties the buffer by construction — the player
|
|
319
|
-
// discards what it holds and fetches from the new position — so carrying
|
|
320
|
-
// the old cushion over would license the position to run forward again from
|
|
321
|
-
// material that no longer exists. Zero is the truthful floor until they say
|
|
322
|
-
// otherwise, and a viewer who has just seeked says so within a tick.
|
|
323
|
-
this.bufferedSeconds = 0;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Where they are, stated — without anything a move to another place means.
|
|
328
|
-
*
|
|
329
|
-
* @param {number} seconds
|
|
330
|
-
* @param {number} now
|
|
331
|
-
* @returns {void}
|
|
332
|
-
*/
|
|
333
|
-
#place(seconds, now) {
|
|
334
|
-
this.position = { seconds, at: now };
|
|
335
|
-
this.lastSeenAt = now;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Which way their buffer is going, over the shortest run of their reports
|
|
340
|
-
* that spans `spanSec`, and the interval between their last two reports.
|
|
341
|
-
* Readings older than that run are let go: the run only ever moves forward.
|
|
342
|
-
*
|
|
343
|
-
* @param {number} spanSec - The segment duration of the output they watch.
|
|
344
|
-
* @returns {{ slope: number, reportGapSec: number } | null}
|
|
345
|
-
*/
|
|
346
|
-
bufferTrend(spanSec) {
|
|
347
|
-
this.trendSpanSec = spanSec;
|
|
348
|
-
const start = trendRunStart(this.bufferReadings, spanSec);
|
|
349
|
-
if (start > 0) {
|
|
350
|
-
this.bufferReadings = this.bufferReadings.slice(start);
|
|
351
|
-
}
|
|
352
|
-
return bufferTrend(this.bufferReadings, spanSec);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Everything a viewer says about itself, in one statement.
|
|
357
|
-
*
|
|
358
|
-
* The page sends four things together — how fast its link measured, how much
|
|
359
|
-
* film it holds, where the picture is, and whether the picture is moving —
|
|
360
|
-
* and all four are facts about this viewer. Taking them apart and assigning
|
|
361
|
-
* them one by one somewhere else is how they came to be spread over five
|
|
362
|
-
* places, two of them on a session shared with other people.
|
|
363
|
-
*
|
|
364
|
-
* @param {object} report
|
|
365
|
-
* @param {number} [report.linkMbps] - What their link last measured. Absent
|
|
366
|
-
* until something measurable has crossed it, and then the rest of the
|
|
367
|
-
* report still stands: this is a statement about a viewer, not about a link.
|
|
368
|
-
* @param {number} report.bufferedAheadSec
|
|
369
|
-
* @param {number | null} [report.positionSeconds] - Null from a page that
|
|
370
|
-
* does not say; then the position stands as it was.
|
|
371
|
-
* @param {boolean} [report.playing] - Whether the picture is advancing.
|
|
372
|
-
* Absent means it was not stated, and nothing may be assumed about somebody
|
|
373
|
-
* else's machine: the rate is then zero.
|
|
374
|
-
* @param {boolean} [report.waiting] - Whether this viewer is blocked on
|
|
375
|
-
* material we owe them. Absent from a page one release behind; then it is
|
|
376
|
-
* derived from the two facts that page does state.
|
|
377
|
-
* @param {boolean} [report.onScreen] - Whether the page is visible, or the
|
|
378
|
-
* picture is in picture-in-picture. Absent means on screen.
|
|
379
|
-
* @param {boolean} [report.inPictureInPicture] - Whether the picture has been
|
|
380
|
-
* pulled out of the page, which is watching it with the tab hidden.
|
|
381
|
-
* @param {number} [now]
|
|
382
|
-
*/
|
|
383
|
-
report(
|
|
384
|
-
{
|
|
385
|
-
linkMbps,
|
|
386
|
-
bufferedAheadSec,
|
|
387
|
-
positionSeconds = null,
|
|
388
|
-
playing,
|
|
389
|
-
waiting,
|
|
390
|
-
onScreen,
|
|
391
|
-
inPictureInPicture,
|
|
392
|
-
qualityMode,
|
|
393
|
-
visiblePicture
|
|
394
|
-
},
|
|
395
|
-
now = Date.now()
|
|
396
|
-
) {
|
|
397
|
-
this.noteVisiblePicture(visiblePicture);
|
|
398
|
-
// Whether the size on their screen was picked by hand or is the automatic
|
|
399
|
-
// choice. Kept only when stated: a page one release behind says nothing,
|
|
400
|
-
// and is taken as picking, which is what every page was before.
|
|
401
|
-
if (qualityMode === "auto" || qualityMode === "manual") {
|
|
402
|
-
this.qualityMode = qualityMode;
|
|
403
|
-
}
|
|
404
|
-
const held = Number.isFinite(bufferedAheadSec) && bufferedAheadSec > 0 ? bufferedAheadSec : 0;
|
|
405
|
-
// A LINK FIGURE IS ONE FIELD OF THIS REPORT AND NOT ITS TICKET. A page that
|
|
406
|
-
// has transferred nothing measurable has nothing to say about its link and
|
|
407
|
-
// everything to say about its viewer, and that is the cold open exactly —
|
|
408
|
-
// the moment the position matters most. Held as a precondition, in three
|
|
409
|
-
// places at once, it silenced every statement of the session of 2026-09-14.
|
|
410
|
-
if (Number.isFinite(linkMbps) && linkMbps > 0) {
|
|
411
|
-
this.netReport = {
|
|
412
|
-
linkMbps,
|
|
413
|
-
bufferedAheadSec: held,
|
|
414
|
-
positionSeconds:
|
|
415
|
-
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
|
|
416
|
-
at: now
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
// Where the picture is now. A report states a place the viewer has played
|
|
420
|
-
// to, not a place they moved to: their buffer's history stands.
|
|
421
|
-
if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
|
|
422
|
-
this.#place(/** @type {number} */ (positionSeconds), now);
|
|
423
|
-
}
|
|
424
|
-
this.bufferedSeconds = held;
|
|
425
|
-
this.bufferReadings.push({ at: now, seconds: held });
|
|
426
|
-
// Kept only as far back as the last trend asked needs: its run only moves
|
|
427
|
-
// forward. Before a trend has been asked for, nothing is let go.
|
|
428
|
-
if (this.trendSpanSec > 0) {
|
|
429
|
-
const start = trendRunStart(this.bufferReadings, this.trendSpanSec);
|
|
430
|
-
if (start > 0) {
|
|
431
|
-
this.bufferReadings = this.bufferReadings.slice(start);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
this.playing = playing === true;
|
|
435
|
-
// A page that states `playing` and not `waiting` is one release behind this
|
|
436
|
-
// proxy, which is the ordinary state of a rolling pool — the proxy ships
|
|
437
|
-
// first by rule. It says `false` for both a viewer who stopped the picture
|
|
438
|
-
// and a viewer starved of material, so the two are told apart by the one
|
|
439
|
-
// quantity it does state: a stopped viewer holding film chose to stop, a
|
|
440
|
-
// stopped viewer holding nothing is waiting on us. It errs toward making
|
|
441
|
-
// material, which is the direction that costs an encoder rather than a
|
|
442
|
-
// viewer.
|
|
443
|
-
this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
|
|
444
|
-
this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
|
|
445
|
-
this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
|
|
446
|
-
this.seen(now);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Whether this viewer is here.
|
|
451
|
-
*
|
|
452
|
-
* PRESENCE IS THE CONNECTION, AND ONLY THE CONNECTION. They are here from the
|
|
453
|
-
* moment they are known until something SAYS they are gone: the browser
|
|
454
|
-
* releasing the session, or their connection closing. Nothing else, and in
|
|
455
|
-
* particular not silence.
|
|
456
|
-
*
|
|
457
|
-
* Silence used to end it, after an interval longer than "any silence a
|
|
458
|
-
* watching viewer can produce" — and there is no such interval. A viewer who
|
|
459
|
-
* has paused, whose tab is hidden and whose timers the browser has throttled,
|
|
460
|
-
* or who holds two minutes of cushion, all say nothing for as long as they
|
|
461
|
-
* like and are all still watching. Deciding presence from that made it a
|
|
462
|
-
* guess with a threshold; deciding it from the connection makes it a fact
|
|
463
|
-
* with an owner.
|
|
464
|
-
*
|
|
465
|
-
* @returns {boolean}
|
|
466
|
-
*/
|
|
467
|
-
/**
|
|
468
|
-
* What this viewer's link was last measured to carry, or null when it has
|
|
469
|
-
* never been measured.
|
|
470
|
-
*
|
|
471
|
-
* IT DOES NOT EXPIRE, and the reasoning is the correction of 2026-09-14. A
|
|
472
|
-
* reading older than a chosen thirty seconds used to be discarded here, which
|
|
473
|
-
* put two facts under one number: how fast the link is, and whether we are
|
|
474
|
-
* still hearing from this page.
|
|
475
|
-
*
|
|
476
|
-
* The speed persists. A link does not stop being what it was measured to be
|
|
477
|
-
* because nobody measured it for a minute, and the page says as much by
|
|
478
|
-
* keeping its own last figure for ever rather than reporting nothing. It is
|
|
479
|
-
* measured once when the connection comes up and corrected by every transfer
|
|
480
|
-
* afterwards — one estimate, continuously refined, never a fact with a
|
|
481
|
-
* deadline.
|
|
482
|
-
*
|
|
483
|
-
* Whether the reading is still somebody's is PRESENCE, which has its own
|
|
484
|
-
* owner: a viewer who has gone is removed from the output, and the readings
|
|
485
|
-
* walked for a decision are only those of people still there. Answering both
|
|
486
|
-
* questions in one place is what stopped a soundtrack's encoder on
|
|
487
|
-
* 2026-09-05.
|
|
488
|
-
*
|
|
489
|
-
* @returns {{ linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number } | null}
|
|
490
|
-
*/
|
|
491
|
-
linkReading() {
|
|
492
|
-
return this.netReport;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Whether this viewer wants film NOW — either watching it or waiting for it.
|
|
497
|
-
*
|
|
498
|
-
* The one predicate every reading about urgency asks, and it is a union of
|
|
499
|
-
* two states rather than the negation of a pause:
|
|
500
|
-
*
|
|
501
|
-
* playing consuming, and will run dry when their cushion does;
|
|
502
|
-
* waiting consuming nothing only because we have delivered nothing;
|
|
503
|
-
* neither they stopped the picture themselves.
|
|
504
|
-
*
|
|
505
|
-
* A viewer who has stopped it keeps their place in the priority map — they
|
|
506
|
-
* are still there and will want the film in front of them again — but nothing
|
|
507
|
-
* of theirs falls due, so the work goes to whoever is watching or waiting.
|
|
508
|
-
*
|
|
509
|
-
* A page that is not on screen is not consuming: its timers are throttled and
|
|
510
|
-
* it asks for nothing, which is indistinguishable from a full cushion.
|
|
511
|
-
* Picture-in-picture is watching with the tab hidden, and the page folds that
|
|
512
|
-
* into `onScreen` before saying it, so it needs no case of its own here.
|
|
513
|
-
*
|
|
514
|
-
* @returns {boolean}
|
|
515
|
-
*/
|
|
516
|
-
wantsFilmNow() {
|
|
517
|
-
return (this.playing === true || this.waiting === true) && this.onScreen !== false;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
isPresent() {
|
|
521
|
-
return this.gone !== true;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* This viewer has gone, and everything that means.
|
|
526
|
-
*
|
|
527
|
-
* ONE STATEMENT rather than a field three callers set, because it is no
|
|
528
|
-
* longer only a field: the outputs their assignments were holding must stop
|
|
529
|
-
* being held, and a caller that set `gone` alone would leave an output alive
|
|
530
|
-
* with nobody left to serve. A response still in flight goes with them —
|
|
531
|
-
* the connection it was being sent over is what went.
|
|
532
|
-
*
|
|
533
|
-
* @returns {void}
|
|
534
|
-
*/
|
|
535
|
-
markGone() {
|
|
536
|
-
this.gone = true;
|
|
537
|
-
this.assignments.clear();
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Ask this viewer's player to move to another height.
|
|
542
|
-
*
|
|
543
|
-
* Refused outside AUTO, and that refusal is the rule itself rather than a
|
|
544
|
-
* check at each caller: a height picked by hand is never moved by the budget,
|
|
545
|
-
* and a page that has not said its mode is treated as a manual one.
|
|
546
|
-
*
|
|
547
|
-
* @param {number} height
|
|
548
|
-
* @param {string} reason
|
|
549
|
-
* @param {number} now
|
|
550
|
-
* @param {boolean} [urgent] - Their buffer would run dry before anything else
|
|
551
|
-
* could arrive.
|
|
552
|
-
* @returns {boolean} Whether a request now stands.
|
|
553
|
-
*/
|
|
554
|
-
askQuality(height, reason, now, urgent = false) {
|
|
555
|
-
if (this.qualityMode !== "auto") {
|
|
556
|
-
this.qualityAsk = null;
|
|
557
|
-
return false;
|
|
558
|
-
}
|
|
559
|
-
this.qualityAsk = { height, at: now, reason, urgent: urgent === true };
|
|
560
|
-
return true;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Take the picture as their page measured it. A size that is not a positive
|
|
565
|
-
* width and height leaves the one they had.
|
|
566
|
-
*
|
|
567
|
-
* @param {unknown} value
|
|
568
|
-
* @returns {boolean} Whether it changed.
|
|
569
|
-
*/
|
|
570
|
-
noteVisiblePicture(value) {
|
|
571
|
-
const width = Math.round(Number(/** @type {{ width?: unknown }} */ (value)?.width));
|
|
572
|
-
const height = Math.round(Number(/** @type {{ height?: unknown }} */ (value)?.height));
|
|
573
|
-
if (!(width > 0 && height > 0)) {
|
|
574
|
-
return false;
|
|
575
|
-
}
|
|
576
|
-
if (this.visiblePicture && this.visiblePicture.width === width && this.visiblePicture.height === height) {
|
|
577
|
-
return false;
|
|
578
|
-
}
|
|
579
|
-
this.visiblePicture = { width, height };
|
|
580
|
-
return true;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Let the standing request go.
|
|
585
|
-
*
|
|
586
|
-
* @returns {void}
|
|
587
|
-
*/
|
|
588
|
-
dropQualityAsk() {
|
|
589
|
-
this.qualityAsk = null;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Note that this viewer has been heard from.
|
|
594
|
-
*
|
|
595
|
-
* @param {number} [now]
|
|
596
|
-
* @returns {void}
|
|
597
|
-
*/
|
|
598
|
-
seen(now = Date.now()) {
|
|
599
|
-
this.lastSeenAt = now;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Where this viewer is NOW, in seconds of film.
|
|
604
|
-
*
|
|
605
|
-
* A FUNCTION OF TIME, not a stored number. A viewer whose picture is moving
|
|
606
|
-
* covers a second of film every second, so between the moments they speak
|
|
607
|
-
* their position is known exactly — it is where they last were plus the time
|
|
608
|
-
* since. A viewer whose picture is stopped covers nothing and stays where
|
|
609
|
-
* they are, which is the same formula with the rate at zero.
|
|
610
|
-
*
|
|
611
|
-
* position(now) = stated + rate * min(now - statedAt, held),
|
|
612
|
-
* rate = playing ? 1 : 0
|
|
613
|
-
*
|
|
614
|
-
* **THE SECOND TERM IS BOUNDED BY WHAT THEY HELD**, and the bound is stated
|
|
615
|
-
* by the viewer rather than chosen here: nobody can play past the end of what
|
|
616
|
-
* they have. A viewer who said "I hold thirty seconds" and has said nothing
|
|
617
|
-
* for two minutes has played at most thirty of those seconds; the rest of the
|
|
618
|
-
* silence is a viewer who ran dry, or one we are no longer hearing from, and
|
|
619
|
-
* in both cases they are not where the clock alone would put them.
|
|
620
|
-
*
|
|
621
|
-
* Unbounded, it walks a viewer off the end of a film they never started. On
|
|
622
|
-
* 2026-09-14 the tab was hidden for 145 seconds while the page deliberately
|
|
623
|
-
* held start-up until it was shown, the rate was assumed to be one, and this
|
|
624
|
-
* end placed the viewer at 145.979 s — the exact `-ss` the soundtrack's
|
|
625
|
-
* encoder was given, while the browser was asking for segment #0 and getting
|
|
626
|
-
* 503 for sixty seconds. Their cushion was 0.0 s throughout, so this bound
|
|
627
|
-
* alone would have held them at zero whatever the rate said.
|
|
628
|
-
*
|
|
629
|
-
* The bound binds only where the clock has outrun the cushion — a report lost,
|
|
630
|
-
* or a viewer starving — and there it under-states the position. That is the
|
|
631
|
-
* safe direction and deliberately so: under-stating makes material a viewer
|
|
632
|
-
* has already passed, which costs an encoder; over-stating skips material
|
|
633
|
-
* they are about to need, which costs the viewer the picture.
|
|
634
|
-
*
|
|
635
|
-
* Held as a stored number instead, it was a staircase: flat for the ten
|
|
636
|
-
* seconds between reports and then a jump, so everything derived from it —
|
|
637
|
-
* the priority map, and through the map every encoder — changed in steps for
|
|
638
|
-
* a viewer who was moving smoothly. And because two writers were filling that
|
|
639
|
-
* number in turn, the steps went backwards as often as forwards.
|
|
640
|
-
*
|
|
641
|
-
* There is nothing to tune here and no state to keep: one reading, one clock,
|
|
642
|
-
* one rate the page itself states.
|
|
643
|
-
*
|
|
644
|
-
* @param {number} [now]
|
|
645
|
-
* @returns {number | null} Null only for a viewer never placed, which for one
|
|
646
|
-
* created through `Viewers.of` cannot happen.
|
|
647
|
-
*/
|
|
648
|
-
positionSeconds(now = Date.now()) {
|
|
649
|
-
if (this.position === null) {
|
|
650
|
-
return null;
|
|
651
|
-
}
|
|
652
|
-
const rate = this.playing === true ? 1 : 0;
|
|
653
|
-
const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
|
|
654
|
-
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
655
|
-
return this.position.seconds + rate * Math.min(elapsedSec, held);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file One person watching, and everything that is true of them alone.
|
|
3
|
+
*
|
|
4
|
+
* A viewer is not a property of the material. What they are listening to, which
|
|
5
|
+
* quality step they have on screen, what is being prepared for them, where they
|
|
6
|
+
* are and what their link can carry — none of it changes a byte of what any
|
|
7
|
+
* encoder produces, and none of it belongs to a session, which is a description
|
|
8
|
+
* of an OUTPUT.
|
|
9
|
+
*
|
|
10
|
+
* It was six parallel maps hung on the session, each keyed by consumer id:
|
|
11
|
+
* `audioChoiceByConsumer`, `activeVariantByConsumer`,
|
|
12
|
+
* `warmingVariantByConsumer`, `warmingAudioByConsumer`, `consumerHeads`,
|
|
13
|
+
* `netReports`. Six places to remember to update and six to remember to forget,
|
|
14
|
+
* and the forgetting was already wrong — releasing a consumer emptied none of
|
|
15
|
+
* them, so a viewer who had left went on counting as wanting their soundtrack
|
|
16
|
+
* until their head expired, and their entries stayed for the life of the
|
|
17
|
+
* session.
|
|
18
|
+
*
|
|
19
|
+
* One object, one map, one thing to remove.
|
|
20
|
+
*
|
|
21
|
+
* **TWO INDEPENDENT FACTS, NOT ONE.** A viewer is somewhere, and a viewer is
|
|
22
|
+
* either still here or gone. Until 2026-09-05 both were answered by one field —
|
|
23
|
+
* the position, which is written only when a segment is requested — so a viewer
|
|
24
|
+
* who had just arrived counted as absent, and an output all of whose viewers
|
|
25
|
+
* count as absent has every encoder on it stopped. That is exactly what
|
|
26
|
+
* happened on 2026-09-05: a soundtrack's encoder was stopped 1.25 s after it
|
|
27
|
+
* started, having produced nothing, its `init.mp4` was therefore never made,
|
|
28
|
+
* and the picture could not be played without it. The browser could not rescue
|
|
29
|
+
* itself either, because the only thing that would have marked the viewer
|
|
30
|
+
* present was a request for a segment — which needs the `init.mp4` that the
|
|
31
|
+
* stopped encoder was going to make.
|
|
32
|
+
*
|
|
33
|
+
* So: **position is known from the moment a viewer arrives** — it is in the
|
|
34
|
+
* request that created the output, as a time on the source, and it is either
|
|
35
|
+
* zero or what the address bar carried. And **presence is a fact of the
|
|
36
|
+
* connection**, not of the last file asked for.
|
|
37
|
+
*
|
|
38
|
+
* **Nothing here knows about ffmpeg, the disk or the torrent.** A viewer states
|
|
39
|
+
* what they want and where they are; what to make of that is the orchestrator's
|
|
40
|
+
* question, and it reads a union of viewers rather than any one of them.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Assignments } from "./Assignments.js";
|
|
44
|
+
import { bufferTrend, trendRunStart } from "./buffer-trend.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How long a generation this viewer has left behind goes on accepting NEW
|
|
48
|
+
* requests.
|
|
49
|
+
*
|
|
50
|
+
* A CHOSEN NUMBER, and it is named here rather than buried so that it reads as
|
|
51
|
+
* one. It cannot be derived today: it should come from the player's own retry
|
|
52
|
+
* policy, and this product overrides that policy on its own page while the
|
|
53
|
+
* native path in Safari has none at all that can be read. Until the retries a
|
|
54
|
+
* real player actually makes are measured, the value is given and the checks
|
|
55
|
+
* are written against what is given.
|
|
56
|
+
*
|
|
57
|
+
* What it protects: a request made just before a seek, still in flight when the
|
|
58
|
+
* seek lands, is answered by the output it was made against instead of being
|
|
59
|
+
* decided again against a position the viewer has already left.
|
|
60
|
+
*/
|
|
61
|
+
export const ACCEPT_WINDOW_MS = 30_000;
|
|
62
|
+
|
|
63
|
+
export class Viewer {
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} id - The consumer id the browser sends with every request
|
|
66
|
+
* that means "this viewer".
|
|
67
|
+
* @param {number} [now] - When they arrived. Presence starts here, so a
|
|
68
|
+
* viewer counts as watching from the instant they are known.
|
|
69
|
+
*/
|
|
70
|
+
constructor(id, now = Date.now()) {
|
|
71
|
+
this.id = String(id ?? "");
|
|
72
|
+
/**
|
|
73
|
+
* Which soundtrack this viewer is listening to and whether their browser
|
|
74
|
+
* needs it re-encoded. Theirs alone: two viewers of one picture may have
|
|
75
|
+
* chosen different languages, and one browser may decode a track another
|
|
76
|
+
* cannot.
|
|
77
|
+
* @type {{ trackIndex: number, transcode: boolean }}
|
|
78
|
+
*/
|
|
79
|
+
this.audio = { trackIndex: 0, transcode: false };
|
|
80
|
+
/** The quality step on their screen. Null means the base session. @type {string | null} */
|
|
81
|
+
this.activeVariantId = null;
|
|
82
|
+
/** A step being prepared for a switch they have not made yet. @type {string | null} */
|
|
83
|
+
this.warmingVariantId = null;
|
|
84
|
+
/**
|
|
85
|
+
* When the warming of that step began, so the time a move takes to be
|
|
86
|
+
* ready can be kept (roadmap item 97, step 14). @type {number | null}
|
|
87
|
+
*/
|
|
88
|
+
this.warmingSince = null;
|
|
89
|
+
/** A soundtrack being prepared for the same reason. @type {string | null} */
|
|
90
|
+
this.warmingAudioId = null;
|
|
91
|
+
/**
|
|
92
|
+
* An output of the height on their screen, at another bitrate limit, being
|
|
93
|
+
* prepared so that their NEXT segments come from it (roadmap item 97, step
|
|
94
|
+
* 12). Not `warmingVariantId`: that is a step their player will switch to,
|
|
95
|
+
* and the page says when it has. Here the address does not change, the
|
|
96
|
+
* player is never told, and the switch is made on this side once the
|
|
97
|
+
* segment they will ask for next is closed on the new output.
|
|
98
|
+
*
|
|
99
|
+
* `askedHeights` are the heights their player asks for under which the
|
|
100
|
+
* output on screen is chosen — a step is chosen under the height asked and
|
|
101
|
+
* named after the height produced, so there can be more than one — and the
|
|
102
|
+
* move replaces the choice under each; `direction` is why it was started.
|
|
103
|
+
*
|
|
104
|
+
* @type {{ askedHeights: number[], outputId: string, outputKey: string, direction: "down" | "up", reason: string, since: number } | null}
|
|
105
|
+
*/
|
|
106
|
+
this.sameHeightSwitch = null;
|
|
107
|
+
/**
|
|
108
|
+
* Where their picture stood when they last said so, and when they said it.
|
|
109
|
+
*
|
|
110
|
+
* ONE FACT WITH ONE WRITER: the viewer. It is set when they arrive — from
|
|
111
|
+
* the position their own create request named — and moved only by what they
|
|
112
|
+
* state afterwards, a seek or a report carrying their playhead. Never null
|
|
113
|
+
* for a viewer this process has met, because a viewer arrives by asking for
|
|
114
|
+
* a position.
|
|
115
|
+
*
|
|
116
|
+
* A REQUEST FOR A SEGMENT IS NOT A POSITION and never writes here. It says
|
|
117
|
+
* how far their buffer has reached, which is a different quantity and one
|
|
118
|
+
* nothing in the plan needs: a segment either exists and is served, or does
|
|
119
|
+
* not and is waited for.
|
|
120
|
+
*
|
|
121
|
+
* Two writers is what this replaced, and the cost is measured. Until
|
|
122
|
+
* 2026-09-13 a seek wrote `seeked` while a request wrote `seconds`, and
|
|
123
|
+
* `positionSeconds` preferred whichever was set — so the two took turns and
|
|
124
|
+
* the priority map jumped back and forth by one or two segments several
|
|
125
|
+
* times a second. Field that day, one output over one second:
|
|
126
|
+
* `p100:#9..#10`, `p100:#18..#20`, `p100:#16..#18`, `p100:#18..#20`, with
|
|
127
|
+
* the second viewer stopped dead at 1673.6 s throughout. The encoder
|
|
128
|
+
* placement followed the map, as it must, and the machine spent six minutes
|
|
129
|
+
* on 77 starts and 141 stops against one normal end.
|
|
130
|
+
*
|
|
131
|
+
* @type {{ seconds: number, at: number } | null}
|
|
132
|
+
*/
|
|
133
|
+
this.position = null;
|
|
134
|
+
/**
|
|
135
|
+
* When this viewer was last known to be there.
|
|
136
|
+
*
|
|
137
|
+
* Every piece of evidence refreshes it: a request of any kind, a link
|
|
138
|
+
* report, an echo of a delivery probe. It is NOT the position's timestamp —
|
|
139
|
+
* a viewer with a full buffer legitimately asks for nothing for a minute
|
|
140
|
+
* and is no less present for it.
|
|
141
|
+
*
|
|
142
|
+
* @type {number}
|
|
143
|
+
*/
|
|
144
|
+
this.lastSeenAt = now;
|
|
145
|
+
/**
|
|
146
|
+
* Set when something has SAID this viewer is gone — the browser released
|
|
147
|
+
* the session, or their connection closed. Silence never sets it: a
|
|
148
|
+
* paused viewer, a viewer whose tab is hidden and whose timers the browser
|
|
149
|
+
* has throttled, and a viewer holding two minutes of buffer are all silent
|
|
150
|
+
* and all still watching.
|
|
151
|
+
*
|
|
152
|
+
* @type {boolean}
|
|
153
|
+
*/
|
|
154
|
+
this.gone = false;
|
|
155
|
+
/** What their link was last measured to carry. @type {object | null} */
|
|
156
|
+
this.netReport = null;
|
|
157
|
+
/**
|
|
158
|
+
* "manual" while the size on their screen was picked by hand, "auto" while
|
|
159
|
+
* it is the automatic choice, null until their page has said.
|
|
160
|
+
*
|
|
161
|
+
* @type {"auto" | "manual" | null}
|
|
162
|
+
*/
|
|
163
|
+
this.qualityMode = null;
|
|
164
|
+
/**
|
|
165
|
+
* The height the quality budget last asked THIS viewer's player to move
|
|
166
|
+
* to, while the request stands. Created only in AUTO (`askQuality`), read
|
|
167
|
+
* back only by this viewer's own progress poll, and let go once they are
|
|
168
|
+
* there, once it has run out, or once they are no longer in AUTO.
|
|
169
|
+
*
|
|
170
|
+
* `urgent` when their buffer would run dry before anything else could
|
|
171
|
+
* arrive: their page then switches without waiting for a cushion.
|
|
172
|
+
*
|
|
173
|
+
* @type {{ height: number, at: number, reason: string, urgent: boolean } | null}
|
|
174
|
+
*/
|
|
175
|
+
this.qualityAsk = null;
|
|
176
|
+
/**
|
|
177
|
+
* Every output this viewer is watching, by session id: the picture, the
|
|
178
|
+
* quality step on their screen, the soundtrack they chose.
|
|
179
|
+
*
|
|
180
|
+
* THE ONLY STORED FORM of the relation "this person watches this output".
|
|
181
|
+
* The question from the other end — who watches this output — is answered
|
|
182
|
+
* by `Viewers.forOutput`, which derives it from these sets. It used to be a
|
|
183
|
+
* second index kept beside this one, and two stored copies of one relation
|
|
184
|
+
* are two answers that can disagree. The registry is small, so deriving is
|
|
185
|
+
* cheaper than keeping the copies in step.
|
|
186
|
+
*
|
|
187
|
+
* This is what replaced a film object. There is no "film" anywhere in this
|
|
188
|
+
* proxy — its parts are born at different times, die at different times and
|
|
189
|
+
* are addressed separately — and the three link fields that stood in for one
|
|
190
|
+
* could not say how many people were listening to a soundtrack.
|
|
191
|
+
*
|
|
192
|
+
* @type {Set<string>}
|
|
193
|
+
*/
|
|
194
|
+
this.outputs = new Set();
|
|
195
|
+
/**
|
|
196
|
+
* Which output answers THIS viewer, and for how long that stands.
|
|
197
|
+
*
|
|
198
|
+
* Their own, which is the whole of roadmap item 97: the record it replaces
|
|
199
|
+
* — one entry per file and height, read by everybody — meant a viewer on a
|
|
200
|
+
* thin link decided for a viewer on a thick one. What it holds and why each
|
|
201
|
+
* fact is there is in `Assignments.js`.
|
|
202
|
+
*
|
|
203
|
+
* @type {Assignments}
|
|
204
|
+
*/
|
|
205
|
+
this.assignments = new Assignments({ acceptWindowMs: ACCEPT_WINDOW_MS });
|
|
206
|
+
/**
|
|
207
|
+
* On what the output this viewer was last given was judged: the verdict of
|
|
208
|
+
* their own link against its whole load, and the figures. `estimated to
|
|
209
|
+
* fit` is admitted and NOT confirmed — the figure is an average, not a
|
|
210
|
+
* bound — and the page is told so rather than left to assume it.
|
|
211
|
+
* @type {object | null}
|
|
212
|
+
*/
|
|
213
|
+
this.servingVerdict = null;
|
|
214
|
+
/**
|
|
215
|
+
* The files this viewer has subtitles switched on for, as
|
|
216
|
+
* `sourceKey:fileIndex`.
|
|
217
|
+
*
|
|
218
|
+
* A subscription is a fact about a PERSON, not about a channel. Held on the
|
|
219
|
+
* channel — as it was until now — it dies with the channel, so a seamless
|
|
220
|
+
* reconnect silently lost subtitles for the rest of the session, and a
|
|
221
|
+
* transport that rotates its association on purpose would lose them every
|
|
222
|
+
* time it rotated. Held here it survives both by construction: whatever
|
|
223
|
+
* channel this viewer is reachable on next, they are still subscribed.
|
|
224
|
+
*
|
|
225
|
+
* @type {Set<string>}
|
|
226
|
+
*/
|
|
227
|
+
this.wantsCuesFor = new Set();
|
|
228
|
+
// WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
|
|
229
|
+
// BLOCKED ON US. Two booleans, because there are three states and one
|
|
230
|
+
// boolean cannot hold them:
|
|
231
|
+
//
|
|
232
|
+
// playing the picture advances, a second of film per second;
|
|
233
|
+
// waiting it does not advance because we have not delivered;
|
|
234
|
+
// neither the viewer stopped it themselves.
|
|
235
|
+
//
|
|
236
|
+
// Collapsed into one, the middle state read as the third: a viewer frozen
|
|
237
|
+
// for want of a segment counted as somebody who had chosen to stop, so
|
|
238
|
+
// nothing in front of them fell due and the work went to whoever was
|
|
239
|
+
// playing. That is backwards — a viewer waiting on us is the most urgent
|
|
240
|
+
// there is. The page tells the two apart exactly (its element says whether
|
|
241
|
+
// the picture advances, and the component owns the viewer's own pause) and
|
|
242
|
+
// states both.
|
|
243
|
+
//
|
|
244
|
+
// `playing` STARTS FALSE, and that is the whole of the fault of
|
|
245
|
+
// 2026-09-14. It used to start true, and the position is carried forward by
|
|
246
|
+
// the clock at that rate, so a viewer who had never said anything walked
|
|
247
|
+
// 146 seconds into a film they had not begun: the tab was hidden, the page
|
|
248
|
+
// deliberately held start-up until it was shown, and this end assumed the
|
|
249
|
+
// film was running the entire time. A statement about somebody else's
|
|
250
|
+
// machine is theirs to make. Until they make it the rate is zero.
|
|
251
|
+
//
|
|
252
|
+
// `waiting` STARTS TRUE, which is what a viewer arriving is: they have
|
|
253
|
+
// asked for a film and hold none of it. So their work is made first from
|
|
254
|
+
// the instant they arrive, without the position ever being invented.
|
|
255
|
+
this.playing = false;
|
|
256
|
+
this.waiting = true;
|
|
257
|
+
// Whether the page carrying this viewer is ON SCREEN, and whether the
|
|
258
|
+
// picture has been pulled out of it.
|
|
259
|
+
//
|
|
260
|
+
// Two facts, not one, and the second is why the first is not enough: a
|
|
261
|
+
// hidden tab has its timers throttled by the browser — 800 ms of event-loop
|
|
262
|
+
// lag measured in the field — so it asks for nothing and looks exactly like
|
|
263
|
+
// a viewer holding a full cushion. Delivery stood still for the last six
|
|
264
|
+
// minutes of the session of 2026-09-08 and nothing anywhere said the tab had
|
|
265
|
+
// gone away. But a picture in picture-in-picture is watched WHILE the tab is
|
|
266
|
+
// hidden, so hiding alone cannot mean "not watching".
|
|
267
|
+
//
|
|
268
|
+
// A page that says nothing is on screen, which is what every page meant
|
|
269
|
+
// before it could say otherwise.
|
|
270
|
+
this.onScreen = true;
|
|
271
|
+
this.inPictureInPicture = false;
|
|
272
|
+
// Seconds of film held ahead of the picture, as the page last said. It is
|
|
273
|
+
// measured at the moment the position beside it was, which is what makes it
|
|
274
|
+
// the bound on carrying that position forward — see `positionSeconds`.
|
|
275
|
+
this.bufferedSeconds = null;
|
|
276
|
+
/**
|
|
277
|
+
* What the page said it held, and when, since this viewer last moved to
|
|
278
|
+
* another place in the film — what the buffer's trend is read from
|
|
279
|
+
* (`buffer-trend.js`). A seek empties the buffer, so what came before it
|
|
280
|
+
* says nothing about what comes after.
|
|
281
|
+
*
|
|
282
|
+
* @type {Array<{ at: number, seconds: number }>}
|
|
283
|
+
*/
|
|
284
|
+
this.bufferReadings = [];
|
|
285
|
+
/** The span the trend was last asked over; readings older than its run are let go. @type {number} */
|
|
286
|
+
this.trendSpanSec = 0;
|
|
287
|
+
/**
|
|
288
|
+
* The picture as this viewer sees it, in physical pixels: the frame that
|
|
289
|
+
* would be shown without being enlarged, as their page measured it. The
|
|
290
|
+
* upper bound of the height of a re-encoded output made for them (roadmap
|
|
291
|
+
* item 98). Null until their page has said.
|
|
292
|
+
*
|
|
293
|
+
* @type {{ width: number, height: number } | null}
|
|
294
|
+
*/
|
|
295
|
+
this.visiblePicture = null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Where they are, in SECONDS of film, and nothing else.
|
|
300
|
+
*
|
|
301
|
+
* A segment number cannot live here: the picture and the soundtrack of one
|
|
302
|
+
* film are cut independently and into different numbers of pieces — 454
|
|
303
|
+
* against 401 on the field file of 2026-09-05 — so piece 48 of one is not the
|
|
304
|
+
* same moment as piece 48 of the other. Whoever holds a cut grid turns these
|
|
305
|
+
* seconds into their own numbers.
|
|
306
|
+
*
|
|
307
|
+
* @param {number} seconds
|
|
308
|
+
* @param {number} [now]
|
|
309
|
+
*/
|
|
310
|
+
moveTo(seconds, now = Date.now()) {
|
|
311
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.#place(seconds, now);
|
|
315
|
+
// The buffer's history belongs to the place they left.
|
|
316
|
+
this.bufferReadings = [];
|
|
317
|
+
// WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
|
|
318
|
+
// ARRIVED AT. A seek empties the buffer by construction — the player
|
|
319
|
+
// discards what it holds and fetches from the new position — so carrying
|
|
320
|
+
// the old cushion over would license the position to run forward again from
|
|
321
|
+
// material that no longer exists. Zero is the truthful floor until they say
|
|
322
|
+
// otherwise, and a viewer who has just seeked says so within a tick.
|
|
323
|
+
this.bufferedSeconds = 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Where they are, stated — without anything a move to another place means.
|
|
328
|
+
*
|
|
329
|
+
* @param {number} seconds
|
|
330
|
+
* @param {number} now
|
|
331
|
+
* @returns {void}
|
|
332
|
+
*/
|
|
333
|
+
#place(seconds, now) {
|
|
334
|
+
this.position = { seconds, at: now };
|
|
335
|
+
this.lastSeenAt = now;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Which way their buffer is going, over the shortest run of their reports
|
|
340
|
+
* that spans `spanSec`, and the interval between their last two reports.
|
|
341
|
+
* Readings older than that run are let go: the run only ever moves forward.
|
|
342
|
+
*
|
|
343
|
+
* @param {number} spanSec - The segment duration of the output they watch.
|
|
344
|
+
* @returns {{ slope: number, reportGapSec: number } | null}
|
|
345
|
+
*/
|
|
346
|
+
bufferTrend(spanSec) {
|
|
347
|
+
this.trendSpanSec = spanSec;
|
|
348
|
+
const start = trendRunStart(this.bufferReadings, spanSec);
|
|
349
|
+
if (start > 0) {
|
|
350
|
+
this.bufferReadings = this.bufferReadings.slice(start);
|
|
351
|
+
}
|
|
352
|
+
return bufferTrend(this.bufferReadings, spanSec);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Everything a viewer says about itself, in one statement.
|
|
357
|
+
*
|
|
358
|
+
* The page sends four things together — how fast its link measured, how much
|
|
359
|
+
* film it holds, where the picture is, and whether the picture is moving —
|
|
360
|
+
* and all four are facts about this viewer. Taking them apart and assigning
|
|
361
|
+
* them one by one somewhere else is how they came to be spread over five
|
|
362
|
+
* places, two of them on a session shared with other people.
|
|
363
|
+
*
|
|
364
|
+
* @param {object} report
|
|
365
|
+
* @param {number} [report.linkMbps] - What their link last measured. Absent
|
|
366
|
+
* until something measurable has crossed it, and then the rest of the
|
|
367
|
+
* report still stands: this is a statement about a viewer, not about a link.
|
|
368
|
+
* @param {number} report.bufferedAheadSec
|
|
369
|
+
* @param {number | null} [report.positionSeconds] - Null from a page that
|
|
370
|
+
* does not say; then the position stands as it was.
|
|
371
|
+
* @param {boolean} [report.playing] - Whether the picture is advancing.
|
|
372
|
+
* Absent means it was not stated, and nothing may be assumed about somebody
|
|
373
|
+
* else's machine: the rate is then zero.
|
|
374
|
+
* @param {boolean} [report.waiting] - Whether this viewer is blocked on
|
|
375
|
+
* material we owe them. Absent from a page one release behind; then it is
|
|
376
|
+
* derived from the two facts that page does state.
|
|
377
|
+
* @param {boolean} [report.onScreen] - Whether the page is visible, or the
|
|
378
|
+
* picture is in picture-in-picture. Absent means on screen.
|
|
379
|
+
* @param {boolean} [report.inPictureInPicture] - Whether the picture has been
|
|
380
|
+
* pulled out of the page, which is watching it with the tab hidden.
|
|
381
|
+
* @param {number} [now]
|
|
382
|
+
*/
|
|
383
|
+
report(
|
|
384
|
+
{
|
|
385
|
+
linkMbps,
|
|
386
|
+
bufferedAheadSec,
|
|
387
|
+
positionSeconds = null,
|
|
388
|
+
playing,
|
|
389
|
+
waiting,
|
|
390
|
+
onScreen,
|
|
391
|
+
inPictureInPicture,
|
|
392
|
+
qualityMode,
|
|
393
|
+
visiblePicture
|
|
394
|
+
},
|
|
395
|
+
now = Date.now()
|
|
396
|
+
) {
|
|
397
|
+
this.noteVisiblePicture(visiblePicture);
|
|
398
|
+
// Whether the size on their screen was picked by hand or is the automatic
|
|
399
|
+
// choice. Kept only when stated: a page one release behind says nothing,
|
|
400
|
+
// and is taken as picking, which is what every page was before.
|
|
401
|
+
if (qualityMode === "auto" || qualityMode === "manual") {
|
|
402
|
+
this.qualityMode = qualityMode;
|
|
403
|
+
}
|
|
404
|
+
const held = Number.isFinite(bufferedAheadSec) && bufferedAheadSec > 0 ? bufferedAheadSec : 0;
|
|
405
|
+
// A LINK FIGURE IS ONE FIELD OF THIS REPORT AND NOT ITS TICKET. A page that
|
|
406
|
+
// has transferred nothing measurable has nothing to say about its link and
|
|
407
|
+
// everything to say about its viewer, and that is the cold open exactly —
|
|
408
|
+
// the moment the position matters most. Held as a precondition, in three
|
|
409
|
+
// places at once, it silenced every statement of the session of 2026-09-14.
|
|
410
|
+
if (Number.isFinite(linkMbps) && linkMbps > 0) {
|
|
411
|
+
this.netReport = {
|
|
412
|
+
linkMbps,
|
|
413
|
+
bufferedAheadSec: held,
|
|
414
|
+
positionSeconds:
|
|
415
|
+
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
|
|
416
|
+
at: now
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// Where the picture is now. A report states a place the viewer has played
|
|
420
|
+
// to, not a place they moved to: their buffer's history stands.
|
|
421
|
+
if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
|
|
422
|
+
this.#place(/** @type {number} */ (positionSeconds), now);
|
|
423
|
+
}
|
|
424
|
+
this.bufferedSeconds = held;
|
|
425
|
+
this.bufferReadings.push({ at: now, seconds: held });
|
|
426
|
+
// Kept only as far back as the last trend asked needs: its run only moves
|
|
427
|
+
// forward. Before a trend has been asked for, nothing is let go.
|
|
428
|
+
if (this.trendSpanSec > 0) {
|
|
429
|
+
const start = trendRunStart(this.bufferReadings, this.trendSpanSec);
|
|
430
|
+
if (start > 0) {
|
|
431
|
+
this.bufferReadings = this.bufferReadings.slice(start);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
this.playing = playing === true;
|
|
435
|
+
// A page that states `playing` and not `waiting` is one release behind this
|
|
436
|
+
// proxy, which is the ordinary state of a rolling pool — the proxy ships
|
|
437
|
+
// first by rule. It says `false` for both a viewer who stopped the picture
|
|
438
|
+
// and a viewer starved of material, so the two are told apart by the one
|
|
439
|
+
// quantity it does state: a stopped viewer holding film chose to stop, a
|
|
440
|
+
// stopped viewer holding nothing is waiting on us. It errs toward making
|
|
441
|
+
// material, which is the direction that costs an encoder rather than a
|
|
442
|
+
// viewer.
|
|
443
|
+
this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
|
|
444
|
+
this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
|
|
445
|
+
this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
|
|
446
|
+
this.seen(now);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Whether this viewer is here.
|
|
451
|
+
*
|
|
452
|
+
* PRESENCE IS THE CONNECTION, AND ONLY THE CONNECTION. They are here from the
|
|
453
|
+
* moment they are known until something SAYS they are gone: the browser
|
|
454
|
+
* releasing the session, or their connection closing. Nothing else, and in
|
|
455
|
+
* particular not silence.
|
|
456
|
+
*
|
|
457
|
+
* Silence used to end it, after an interval longer than "any silence a
|
|
458
|
+
* watching viewer can produce" — and there is no such interval. A viewer who
|
|
459
|
+
* has paused, whose tab is hidden and whose timers the browser has throttled,
|
|
460
|
+
* or who holds two minutes of cushion, all say nothing for as long as they
|
|
461
|
+
* like and are all still watching. Deciding presence from that made it a
|
|
462
|
+
* guess with a threshold; deciding it from the connection makes it a fact
|
|
463
|
+
* with an owner.
|
|
464
|
+
*
|
|
465
|
+
* @returns {boolean}
|
|
466
|
+
*/
|
|
467
|
+
/**
|
|
468
|
+
* What this viewer's link was last measured to carry, or null when it has
|
|
469
|
+
* never been measured.
|
|
470
|
+
*
|
|
471
|
+
* IT DOES NOT EXPIRE, and the reasoning is the correction of 2026-09-14. A
|
|
472
|
+
* reading older than a chosen thirty seconds used to be discarded here, which
|
|
473
|
+
* put two facts under one number: how fast the link is, and whether we are
|
|
474
|
+
* still hearing from this page.
|
|
475
|
+
*
|
|
476
|
+
* The speed persists. A link does not stop being what it was measured to be
|
|
477
|
+
* because nobody measured it for a minute, and the page says as much by
|
|
478
|
+
* keeping its own last figure for ever rather than reporting nothing. It is
|
|
479
|
+
* measured once when the connection comes up and corrected by every transfer
|
|
480
|
+
* afterwards — one estimate, continuously refined, never a fact with a
|
|
481
|
+
* deadline.
|
|
482
|
+
*
|
|
483
|
+
* Whether the reading is still somebody's is PRESENCE, which has its own
|
|
484
|
+
* owner: a viewer who has gone is removed from the output, and the readings
|
|
485
|
+
* walked for a decision are only those of people still there. Answering both
|
|
486
|
+
* questions in one place is what stopped a soundtrack's encoder on
|
|
487
|
+
* 2026-09-05.
|
|
488
|
+
*
|
|
489
|
+
* @returns {{ linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number } | null}
|
|
490
|
+
*/
|
|
491
|
+
linkReading() {
|
|
492
|
+
return this.netReport;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Whether this viewer wants film NOW — either watching it or waiting for it.
|
|
497
|
+
*
|
|
498
|
+
* The one predicate every reading about urgency asks, and it is a union of
|
|
499
|
+
* two states rather than the negation of a pause:
|
|
500
|
+
*
|
|
501
|
+
* playing consuming, and will run dry when their cushion does;
|
|
502
|
+
* waiting consuming nothing only because we have delivered nothing;
|
|
503
|
+
* neither they stopped the picture themselves.
|
|
504
|
+
*
|
|
505
|
+
* A viewer who has stopped it keeps their place in the priority map — they
|
|
506
|
+
* are still there and will want the film in front of them again — but nothing
|
|
507
|
+
* of theirs falls due, so the work goes to whoever is watching or waiting.
|
|
508
|
+
*
|
|
509
|
+
* A page that is not on screen is not consuming: its timers are throttled and
|
|
510
|
+
* it asks for nothing, which is indistinguishable from a full cushion.
|
|
511
|
+
* Picture-in-picture is watching with the tab hidden, and the page folds that
|
|
512
|
+
* into `onScreen` before saying it, so it needs no case of its own here.
|
|
513
|
+
*
|
|
514
|
+
* @returns {boolean}
|
|
515
|
+
*/
|
|
516
|
+
wantsFilmNow() {
|
|
517
|
+
return (this.playing === true || this.waiting === true) && this.onScreen !== false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
isPresent() {
|
|
521
|
+
return this.gone !== true;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* This viewer has gone, and everything that means.
|
|
526
|
+
*
|
|
527
|
+
* ONE STATEMENT rather than a field three callers set, because it is no
|
|
528
|
+
* longer only a field: the outputs their assignments were holding must stop
|
|
529
|
+
* being held, and a caller that set `gone` alone would leave an output alive
|
|
530
|
+
* with nobody left to serve. A response still in flight goes with them —
|
|
531
|
+
* the connection it was being sent over is what went.
|
|
532
|
+
*
|
|
533
|
+
* @returns {void}
|
|
534
|
+
*/
|
|
535
|
+
markGone() {
|
|
536
|
+
this.gone = true;
|
|
537
|
+
this.assignments.clear();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Ask this viewer's player to move to another height.
|
|
542
|
+
*
|
|
543
|
+
* Refused outside AUTO, and that refusal is the rule itself rather than a
|
|
544
|
+
* check at each caller: a height picked by hand is never moved by the budget,
|
|
545
|
+
* and a page that has not said its mode is treated as a manual one.
|
|
546
|
+
*
|
|
547
|
+
* @param {number} height
|
|
548
|
+
* @param {string} reason
|
|
549
|
+
* @param {number} now
|
|
550
|
+
* @param {boolean} [urgent] - Their buffer would run dry before anything else
|
|
551
|
+
* could arrive.
|
|
552
|
+
* @returns {boolean} Whether a request now stands.
|
|
553
|
+
*/
|
|
554
|
+
askQuality(height, reason, now, urgent = false) {
|
|
555
|
+
if (this.qualityMode !== "auto") {
|
|
556
|
+
this.qualityAsk = null;
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
this.qualityAsk = { height, at: now, reason, urgent: urgent === true };
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Take the picture as their page measured it. A size that is not a positive
|
|
565
|
+
* width and height leaves the one they had.
|
|
566
|
+
*
|
|
567
|
+
* @param {unknown} value
|
|
568
|
+
* @returns {boolean} Whether it changed.
|
|
569
|
+
*/
|
|
570
|
+
noteVisiblePicture(value) {
|
|
571
|
+
const width = Math.round(Number(/** @type {{ width?: unknown }} */ (value)?.width));
|
|
572
|
+
const height = Math.round(Number(/** @type {{ height?: unknown }} */ (value)?.height));
|
|
573
|
+
if (!(width > 0 && height > 0)) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
if (this.visiblePicture && this.visiblePicture.width === width && this.visiblePicture.height === height) {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
this.visiblePicture = { width, height };
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Let the standing request go.
|
|
585
|
+
*
|
|
586
|
+
* @returns {void}
|
|
587
|
+
*/
|
|
588
|
+
dropQualityAsk() {
|
|
589
|
+
this.qualityAsk = null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Note that this viewer has been heard from.
|
|
594
|
+
*
|
|
595
|
+
* @param {number} [now]
|
|
596
|
+
* @returns {void}
|
|
597
|
+
*/
|
|
598
|
+
seen(now = Date.now()) {
|
|
599
|
+
this.lastSeenAt = now;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Where this viewer is NOW, in seconds of film.
|
|
604
|
+
*
|
|
605
|
+
* A FUNCTION OF TIME, not a stored number. A viewer whose picture is moving
|
|
606
|
+
* covers a second of film every second, so between the moments they speak
|
|
607
|
+
* their position is known exactly — it is where they last were plus the time
|
|
608
|
+
* since. A viewer whose picture is stopped covers nothing and stays where
|
|
609
|
+
* they are, which is the same formula with the rate at zero.
|
|
610
|
+
*
|
|
611
|
+
* position(now) = stated + rate * min(now - statedAt, held),
|
|
612
|
+
* rate = playing ? 1 : 0
|
|
613
|
+
*
|
|
614
|
+
* **THE SECOND TERM IS BOUNDED BY WHAT THEY HELD**, and the bound is stated
|
|
615
|
+
* by the viewer rather than chosen here: nobody can play past the end of what
|
|
616
|
+
* they have. A viewer who said "I hold thirty seconds" and has said nothing
|
|
617
|
+
* for two minutes has played at most thirty of those seconds; the rest of the
|
|
618
|
+
* silence is a viewer who ran dry, or one we are no longer hearing from, and
|
|
619
|
+
* in both cases they are not where the clock alone would put them.
|
|
620
|
+
*
|
|
621
|
+
* Unbounded, it walks a viewer off the end of a film they never started. On
|
|
622
|
+
* 2026-09-14 the tab was hidden for 145 seconds while the page deliberately
|
|
623
|
+
* held start-up until it was shown, the rate was assumed to be one, and this
|
|
624
|
+
* end placed the viewer at 145.979 s — the exact `-ss` the soundtrack's
|
|
625
|
+
* encoder was given, while the browser was asking for segment #0 and getting
|
|
626
|
+
* 503 for sixty seconds. Their cushion was 0.0 s throughout, so this bound
|
|
627
|
+
* alone would have held them at zero whatever the rate said.
|
|
628
|
+
*
|
|
629
|
+
* The bound binds only where the clock has outrun the cushion — a report lost,
|
|
630
|
+
* or a viewer starving — and there it under-states the position. That is the
|
|
631
|
+
* safe direction and deliberately so: under-stating makes material a viewer
|
|
632
|
+
* has already passed, which costs an encoder; over-stating skips material
|
|
633
|
+
* they are about to need, which costs the viewer the picture.
|
|
634
|
+
*
|
|
635
|
+
* Held as a stored number instead, it was a staircase: flat for the ten
|
|
636
|
+
* seconds between reports and then a jump, so everything derived from it —
|
|
637
|
+
* the priority map, and through the map every encoder — changed in steps for
|
|
638
|
+
* a viewer who was moving smoothly. And because two writers were filling that
|
|
639
|
+
* number in turn, the steps went backwards as often as forwards.
|
|
640
|
+
*
|
|
641
|
+
* There is nothing to tune here and no state to keep: one reading, one clock,
|
|
642
|
+
* one rate the page itself states.
|
|
643
|
+
*
|
|
644
|
+
* @param {number} [now]
|
|
645
|
+
* @returns {number | null} Null only for a viewer never placed, which for one
|
|
646
|
+
* created through `Viewers.of` cannot happen.
|
|
647
|
+
*/
|
|
648
|
+
positionSeconds(now = Date.now()) {
|
|
649
|
+
if (this.position === null) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const rate = this.playing === true ? 1 : 0;
|
|
653
|
+
const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
|
|
654
|
+
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
655
|
+
return this.position.seconds + rate * Math.min(elapsedSec, held);
|
|
656
|
+
}
|
|
657
|
+
}
|