@torrent-tv/proxy 2.86.0 → 2.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2138 -1915
- package/CLAUDE.md +318 -197
- package/assets/calibration/NOTICE.md +2 -2
- package/bin/cli.js +684 -616
- package/biome.json +409 -233
- package/docs/container-architecture.md +218 -213
- package/docs/download-architecture.md +7 -7
- package/docs/encode-architecture.md +287 -11
- package/docs/encode-run-state.md +2 -2
- package/docs/logs.md +97 -97
- package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
- package/openspec/changes/adaptive-bitrate/design.md +2 -2
- package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
- package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
- package/openspec/changes/chunked-request-bodies/design.md +1 -1
- package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
- package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
- package/openspec/changes/cold-start/design.md +1 -1
- package/openspec/changes/disk-cap/proposal.md +1 -1
- package/openspec/changes/proxy-observability/proposal.md +2 -2
- package/openspec/changes/track-selection/proposal.md +1 -1
- package/openspec/changes/transcode-quality/proposal.md +1 -1
- package/package.json +2 -2
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/routes/api/playback-plan/post.js +2 -2
- package/routes/api/sources/files/get.js +1 -1
- package/routes/api/sources/stats/get.js +1 -1
- package/routes/api/sources/warm/post.js +10 -3
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
- package/routes/api/transcode-sessions/net-report/post.js +129 -94
- package/routes/api/transcode-sessions/post.js +160 -139
- package/routes/api/transcode-sessions/progress/get.js +3 -3
- package/routes/api/transcode-sessions/release/post.js +3 -3
- package/routes/api/transcode-sessions/seek/post.js +12 -3
- package/routes/stream/get.js +2 -2
- package/routes/transcode/audio-file/get.js +15 -6
- package/routes/transcode/audio-warm/get.js +8 -5
- package/routes/transcode/session-file/get.js +429 -268
- package/routes/transcode/variant-file/get.js +47 -7
- package/routes/transcode/variant-warm/get.js +18 -6
- package/scripts/render-run-graph.js +1 -1
- package/server.js +720 -465
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -0
- package/services/encode/EncodeAdmission.js +332 -0
- package/services/{orchestrators → encode}/EncodeOrchestrator.js +285 -17
- package/services/encode/EncodeRun.js +16 -9
- package/services/encode/EncodeRuns.js +1219 -0
- package/services/encode/Encoder.js +18 -2
- package/services/encode/EncoderSelection.js +38 -0
- package/services/encode/LocalObservations.js +359 -0
- package/services/encode/NvencEncoder.js +5 -0
- package/services/encode/OutputOpening.js +745 -0
- package/services/encode/OutputTimes.js +579 -0
- package/services/encode/Renditions.js +2162 -0
- package/services/encode/RunProgress.js +84 -0
- package/services/encode/SoftwareEncoder.js +12 -7
- package/services/encode/args.js +402 -186
- package/services/encode/calibration.js +361 -0
- package/services/encode/contention.js +128 -128
- package/services/encode/encode-run-state.js +16 -20
- package/services/encode/fingerprint.js +322 -0
- package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
- package/services/{learned-median.js → encode/learned-median.js} +11 -0
- package/services/encode/output/EncodedOutput.js +57 -0
- package/services/encode/output/Output.js +131 -0
- package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
- package/services/encode/output/OutputSpec.js +390 -0
- package/services/{output → encode/output}/Timeline.js +12 -37
- package/services/encode/output/index.js +20 -0
- package/services/encode/output/ladder.js +32 -0
- package/services/{output → encode/output}/playlists.js +3 -0
- package/services/encode/output-key-format.js +21 -0
- package/services/encode/quality/EncodeCost.js +1083 -0
- package/services/encode/quality/HostLoad.js +525 -0
- package/services/encode/quality/HostTimings.js +249 -0
- package/services/encode/quality/OutputQualityState.js +28 -0
- package/services/encode/quality/QualityController.js +931 -0
- package/services/encode/quality/QualityOffer.js +416 -0
- package/services/encode/quality/drain-threat.js +47 -0
- package/services/encode/quality/link-budget.js +254 -0
- package/services/encode/quality/output-format.js +266 -0
- package/services/encode/quality/serving-output.js +216 -0
- package/services/encode/quality/visible-rung.js +57 -0
- package/services/encode/run-command.js +23 -20
- package/services/encode/run-inputs.js +42 -0
- package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
- package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
- package/services/encode/segment-formats/init-compat.js +324 -0
- package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
- package/services/encode/throughput.js +97 -0
- package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
- package/services/media/KeyframeTables.js +316 -0
- package/services/{source → media}/SourceFile.js +7 -55
- package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
- package/services/media/SubtitleOrchestrator.js +167 -0
- package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
- package/services/{container → media/container}/AviContainer.js +266 -266
- package/services/{container → media/container}/Container.js +596 -601
- package/services/media/container/ContainerFactory.js +56 -0
- package/services/media/container/KeyframeTable.js +272 -0
- package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
- package/services/{container → media/container}/Mp4Container.js +1244 -1244
- package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
- package/services/media/keyframe-probe.js +157 -0
- package/services/media/media-info-probe.js +93 -0
- package/services/{playback-planner.js → media/playback-planner.js} +82 -39
- package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
- package/services/{tracks → media/tracks}/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -0
- package/services/server/SegmentServing.js +1146 -0
- package/services/server/ViewerRequests.js +522 -0
- package/services/server/controllers/PlaybackController.js +36 -0
- package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
- package/services/server/wire-outputs.js +958 -0
- package/services/storage/Diagnostics.js +202 -0
- package/services/storage/MachineBudget.js +235 -0
- package/services/{piece-store → storage}/allowance.js +107 -107
- package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
- package/services/{files → storage/files}/CompletedFiles.js +116 -1
- package/services/{disk → storage}/free.js +33 -0
- package/services/storage/machine-memory.js +56 -0
- package/services/{memory-report.js → storage/memory-report.js} +575 -596
- package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
- package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
- package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
- package/services/storage/wire.js +175 -0
- package/services/{download → torrent/download}/SwarmSelection.js +2 -2
- package/services/{download → torrent/download}/registry.js +2 -2
- package/services/torrent/files.js +1 -1
- package/services/torrent/piece-store-of.js +29 -0
- package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
- package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
- package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
- package/services/{torrent-worker → torrent/worker}/client.js +77 -58
- package/services/torrent/worker/held-bytes.js +155 -0
- package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
- package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
- package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
- package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
- package/services/torrent/worker/resume-warm.js +107 -0
- package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
- package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
- package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
- package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
- package/services/{health-collector.js → transport/health-collector.js} +6 -35
- package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
- package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
- package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
- package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
- package/services/viewer/Assignments.js +340 -0
- package/services/{priority → viewer}/PriorityMap.js +390 -390
- package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
- package/services/viewer/Viewer.js +657 -483
- package/services/viewer/Viewers.js +368 -257
- package/services/viewer/active-output.js +37 -54
- package/services/viewer/audio-start.js +4 -3
- package/services/viewer/buffer-trend.js +81 -0
- package/services/viewer/choices.js +647 -0
- package/services/viewer/positions.js +126 -120
- package/services/viewer/report-intake.js +46 -45
- package/test/asset-paths.test.js +28 -0
- package/test/audio-inventory.test.js +3 -3
- package/test/audio-track-bitrate.test.js +40 -0
- package/test/auto-quality-step.test.js +707 -515
- package/test/available-share.test.js +1 -1
- package/test/background-fill.test.js +1 -1
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +1 -1
- package/test/concurrent-cost.test.js +13 -3
- package/test/container-media-info.test.js +3 -3
- package/test/core-dumps.test.js +1 -1
- package/test/coverage-follows-the-disk.test.js +40 -8
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
- package/test/cut-times-timeline.test.js +2 -2
- package/test/cuts-follow-published-grid.test.js +9 -8
- package/test/data-channel-frame.test.js +1 -1
- package/test/dead-torrent-handle.test.js +1 -1
- package/test/declared-rates.test.js +2 -2
- package/test/declared-tracks.test.js +2 -2
- package/test/decode-cost-fit.test.js +1 -1
- package/test/decode-cost.test.js +34 -25
- package/test/delivery-probe.test.js +1 -1
- package/test/demand-register.test.js +4 -4
- package/test/dht-bootstrap.test.js +1 -1
- package/test/drain-threat.test.js +65 -0
- package/test/duplicate-add.test.js +1 -1
- package/test/encode-admission.test.js +146 -0
- package/test/encode-cost.test.js +199 -10
- package/test/encode-orchestrator.test.js +39 -1
- package/test/encode-plan-viewers.test.js +2 -2
- package/test/encode-run.test.js +2 -2
- package/test/encode-slope.test.js +1 -1
- package/test/encoded-output.test.js +39 -0
- package/test/encoder-readings.test.js +1 -1
- package/test/every-claimant-is-told-its-share.test.js +152 -0
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
- package/test/failed-start-breaker.test.js +81 -30
- package/test/fastest-wires.test.js +1 -1
- package/test/ffmpeg-stream-counts.test.js +1 -1
- package/test/file-edges.test.js +3 -3
- package/test/fmp4-self-contained.test.js +1 -1
- package/test/fmp4-timestamps.test.js +1 -1
- package/test/fmp4-track-completeness.test.js +1 -1
- package/test/format-keyed-outputs.test.js +78 -0
- package/test/health-metrics.test.js +3 -2
- package/test/held-request-width.test.js +22 -23
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -0
- package/test/helpers/manager.js +57 -0
- package/test/helpers/output-spec.js +63 -0
- package/test/host-load.test.js +1 -1
- package/test/index-accuracy-is-about-the-file.test.js +128 -0
- package/test/init-compat.test.js +219 -0
- package/test/input-lost-quiets-the-plan.test.js +1 -1
- package/test/input-unavailable.test.js +1 -1
- package/test/keeping-period.test.js +4 -4
- package/test/keyframe-index-accuracy.test.js +97 -45
- package/test/keyframe-table-budget.test.js +28 -33
- package/test/keyframes-belong-to-the-file.test.js +174 -57
- package/test/learned-median.test.js +1 -1
- package/test/letting-go-asks-who-is-watching.test.js +191 -0
- package/test/limit-row-by-area.test.js +60 -0
- package/test/link-budget.test.js +111 -0
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -0
- package/test/look-ahead-coverage.test.js +1 -1
- package/test/manager-surface.test.js +294 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +1 -1
- package/test/memory-budget.test.js +2 -2
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +1 -1
- package/test/one-authority.test.js +45 -41
- package/test/one-budget.test.js +149 -0
- package/test/one-output-one-session.test.js +204 -0
- package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
- package/test/one-serving-output-per-height.test.js +458 -0
- package/test/orchestrator-wired.test.js +46 -45
- package/test/output-catalog.test.js +39 -0
- package/test/output-shape.test.js +4 -51
- package/test/output-spec.test.js +251 -157
- package/test/output-speed.test.js +17 -15
- package/test/outputs-being-prepared.test.js +40 -0
- package/test/packet-witness-ring.test.js +2 -2
- package/test/packet-witness.test.js +1 -1
- package/test/piece-disk-store.test.js +2 -2
- package/test/piece-from-whole-file.test.js +1 -1
- package/test/piece-lru.test.js +1 -1
- package/test/piece-reader.test.js +2 -2
- package/test/piece-store-eviction.test.js +1 -1
- package/test/piece-store-never-refuses.test.js +1 -1
- package/test/piece-store-reservations.test.js +1 -1
- package/test/piece-store-slow-disk.test.js +1 -1
- package/test/piece-tail.test.js +1 -1
- package/test/plan-host-timings.test.js +1 -1
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +4 -4
- package/test/priority-map-emptied.test.js +204 -205
- package/test/priority-map-per-output.test.js +215 -209
- package/test/priority-map.test.js +1 -1
- package/test/probe-wedge-certainty.test.js +1 -1
- package/test/produced-copy-choice.test.js +15 -60
- package/test/quality-offer.test.js +162 -0
- package/test/quality-variants.test.js +1229 -1167
- package/test/rate-control-in-the-output.test.js +180 -0
- package/test/read-survives-withdrawal.test.js +2 -2
- package/test/read-window.test.js +10 -3
- package/test/resume-warm.test.js +1 -1
- package/test/run-inputs.test.js +80 -0
- package/test/run-position-follows-published-grid.test.js +13 -11
- package/test/run-progress.test.js +65 -0
- package/test/same-height-switch.test.js +664 -0
- package/test/seek-frees-the-viewers-requests.test.js +217 -0
- package/test/seek-landing.test.js +13 -13
- package/test/seek-target-not-superseded.test.js +28 -29
- package/test/segment-closed-on-both-branches.test.js +96 -0
- package/test/segment-cut-times.test.js +1 -1
- package/test/segment-hold-supersede.test.js +28 -35
- package/test/segment-judged-by-its-own-header.test.js +221 -0
- package/test/segment-serve-wiring.test.js +130 -39
- package/test/segment-store-eviction.test.js +22 -7
- package/test/segment-store.test.js +98 -14
- package/test/segment-true-start.test.js +1 -1
- package/test/segments-are-shared.test.js +17 -16
- package/test/send-chunk-bytes.test.js +1 -1
- package/test/serving-existing-output.test.js +102 -0
- package/test/serving-output.test.js +102 -0
- package/test/shared-piece-store.test.js +1 -1
- package/test/source-file.test.js +1 -1
- package/test/stale-request-after-seek.test.js +17 -21
- package/test/startup-readings.test.js +10 -5
- package/test/subtitle-cue-framing.test.js +5 -5
- package/test/subtitle-cue-source.test.js +36 -38
- package/test/subtitle-cue-walk.test.js +11 -10
- package/test/subtitle-defaults.test.js +1 -1
- package/test/subtitle-language.test.js +4 -4
- package/test/subtitle-track-numbering.test.js +5 -4
- package/test/supply-margin.test.js +1 -1
- package/test/swarm-follows-readers.test.js +3 -3
- package/test/swarm-reach.test.js +1 -1
- package/test/swarm-selection.test.js +3 -3
- package/test/tail-duplication.test.js +1 -1
- package/test/the-channel-has-both-ends.test.js +91 -0
- package/test/the-torrent-thread-serves-bytes.test.js +107 -0
- package/test/thread-pool.test.js +3 -3
- package/test/timeline.test.js +1 -12
- package/test/torrent-cost.test.js +1 -1
- package/test/tracks-begin-together.test.js +38 -24
- package/test/tunnel-renewal.test.js +1 -1
- package/test/two-viewers-one-picture.test.js +120 -87
- package/test/upload-hurry.test.js +3 -3
- package/test/usrsctp-state.test.js +1 -1
- package/test/video-facts.test.js +1 -1
- package/test/viewer-assignments.test.js +143 -0
- package/test/viewer-outputs.test.js +60 -52
- package/test/viewer-presence.test.js +22 -25
- package/test/viewer-requests-admission.test.js +89 -0
- package/test/viewer-states.test.js +189 -202
- package/test/viewer-subtitle-subscription.test.js +1 -1
- package/test/viewer.test.js +139 -136
- package/test/viewing-generations.test.js +390 -0
- package/test/visible-rung.test.js +37 -0
- package/test/wait-ledger.test.js +1 -1
- package/test/webrtc-shim.test.js +3 -3
- package/test/wedge-certainty.test.js +2 -2
- package/test/withdraw-piece-claim.test.js +2 -2
- package/test/worker-channel.test.js +1 -31
- package/utils/client-log-file.js +151 -151
- package/scripts/read-container-index.mjs +0 -108
- package/services/container/ContainerFactory.js +0 -122
- package/services/controllers/PlaybackController.js +0 -33
- package/services/disk/DiskSpace.js +0 -150
- package/services/disk/wire.js +0 -61
- package/services/hls-session-manager.js +0 -9608
- package/services/orchestrators/SubtitleOrchestrator.js +0 -160
- package/services/output/Output.js +0 -94
- package/services/output/OutputSpec.js +0 -202
- package/services/output/index.js +0 -1
- package/services/output/ladder.js +0 -26
- package/services/produced-index.js +0 -222
- package/services/quality/EncodeCost.js +0 -555
- package/services/torrent-worker/container-tracks.js +0 -414
- package/services/viewer/link-readings.js +0 -56
- package/test/produced-index.test.js +0 -142
- /package/{services → bin}/thread-pool.js +0 -0
- /package/services/{available-share.js → encode/available-share.js} +0 -0
- /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
- /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
- /package/services/{host-load.js → encode/host-load.js} +0 -0
- /package/services/{output → encode/output}/cut-grid.js +0 -0
- /package/services/{output → encode/output}/rates.js +0 -0
- /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
- /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
- /package/services/{container → media/container}/ebml-reader.js +0 -0
- /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
- /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
- /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
- /package/services/{tracks → media/tracks}/index.js +0 -0
- /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
- /package/services/{disk → storage}/keep.js +0 -0
- /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
- /package/services/{disk → storage}/returns.js +0 -0
- /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
- /package/services/{demand → torrent/demand}/Urgency.js +0 -0
- /package/services/{demand → torrent/demand}/Window.js +0 -0
- /package/services/{demand → torrent/demand}/index.js +0 -0
- /package/services/{demand → torrent/demand}/pieces.js +0 -0
- /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
- /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
- /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
- /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
- /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
- /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
- /package/services/{registry-api.js → transport/registry-api.js} +0 -0
- /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
- /package/services/{priority → viewer}/WaitLedger.js +0 -0
|
@@ -0,0 +1,2162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The steps of a picture and its soundtracks.
|
|
3
|
+
*
|
|
4
|
+
* A step is an output of the same picture at another size, a soundtrack an
|
|
5
|
+
* output of one audio track; each is made the first time it is asked for, and an
|
|
6
|
+
* output already producing the same thing answers instead. The master playlist
|
|
7
|
+
* lists them. Which viewer is on which is the viewer's fact, stated here and
|
|
8
|
+
* read back through the host.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isOutputName, OUTPUT_UNAVAILABLE } from "./output/index.js";
|
|
12
|
+
import { masterPlaylistText, PLAYLIST_FILE_NAME } from "./output/playlists.js";
|
|
13
|
+
import { variantHeightsFor } from "./output/ladder.js";
|
|
14
|
+
import { isSameMaterial, outputSuits } from "./quality/serving-output.js";
|
|
15
|
+
import { audioLoadOf, linkAnswerFigures, linkCouldCarry, loadOf, videoLoadOfLimit, videoLoadOfSpec } from "./quality/link-budget.js";
|
|
16
|
+
import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor } from "./args.js";
|
|
17
|
+
import { encoderInputs } from "./run-inputs.js";
|
|
18
|
+
/**
|
|
19
|
+
* How a base files the audio renditions it has made.
|
|
20
|
+
*
|
|
21
|
+
* By the track AND by how it is produced, because those are two different
|
|
22
|
+
* encodes of it: a browser that can decode the track as it stands is served a
|
|
23
|
+
* copy, and one that cannot is served AAC. Two viewers of one picture can
|
|
24
|
+
* legitimately need both.
|
|
25
|
+
*
|
|
26
|
+
* @param {number} trackIndex
|
|
27
|
+
* @param {boolean} transcode
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function audioRenditionKey(trackIndex, transcode) {
|
|
31
|
+
return `${Number(trackIndex) || 0}:${transcode === true ? "aac" : "copy"}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Renditions {
|
|
35
|
+
#variantPending = new WeakMap();
|
|
36
|
+
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
37
|
+
#host;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `segmentClosed`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
|
|
41
|
+
*/
|
|
42
|
+
constructor(host) {
|
|
43
|
+
this.#host = host;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The session that produces a given height for the same file, created on
|
|
48
|
+
* first request.
|
|
49
|
+
*
|
|
50
|
+
* A variant IS a session — same source, same file, a different encode — so
|
|
51
|
+
* this makes one rather than inventing a parallel object. It is created only
|
|
52
|
+
* when its playlist is actually asked for, which is what keeps a weak host
|
|
53
|
+
* running one encoder: with the player's own bitrate adaptation off, no
|
|
54
|
+
* variant is ever requested unless the viewer picked it.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} baseSessionId
|
|
57
|
+
* @param {number} height - Encode height; must be one of the offered rungs.
|
|
58
|
+
* @returns {Promise<HlsSession | null>} Null when the base session is unknown,
|
|
59
|
+
* or the height is not offered for it.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* The output chosen for THIS viewer at this height, if it is still here.
|
|
63
|
+
*
|
|
64
|
+
* THE VIEWER'S OWN CHOICE, and nothing else (roadmap item 97, step 11). It
|
|
65
|
+
* used to be one record per file and height, read by everybody watching the
|
|
66
|
+
* file, and then a step NAMED after the height: the first viewer's answer
|
|
67
|
+
* became every viewer's, whatever their link or their mode. Now the choice is
|
|
68
|
+
* recorded on the viewer, and only the suitability rule ever makes one.
|
|
69
|
+
*
|
|
70
|
+
* A choice whose output has gone is no answer; the height is decided again
|
|
71
|
+
* by the same rule, never by looking for the first output of that height.
|
|
72
|
+
* The recorded key is looked up among the outputs of THIS picture's material,
|
|
73
|
+
* so a step of another container can never be the answer.
|
|
74
|
+
*
|
|
75
|
+
* @param {HlsSession} base
|
|
76
|
+
* @param {number} height
|
|
77
|
+
* @param {string} consumerId
|
|
78
|
+
* @returns {HlsSession | null}
|
|
79
|
+
*/
|
|
80
|
+
servingOutputFor(base, height, consumerId) {
|
|
81
|
+
if (!base || !Number.isInteger(height) || height <= 0 || !consumerId) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const key = this.#host.chosenOutputOf(consumerId, height);
|
|
85
|
+
if (!key) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return this.#interchangeableWith(base, base.spec).find((other) => other.outputKey === key) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The rule has chosen `output` for this viewer at this height: record the
|
|
93
|
+
* choice, and on what it was judged, for their progress report.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} consumerId
|
|
96
|
+
* @param {number} height
|
|
97
|
+
* @param {HlsSession} output
|
|
98
|
+
* @param {HlsSession} base
|
|
99
|
+
* @returns {void}
|
|
100
|
+
*/
|
|
101
|
+
#choose(consumerId, height, output, base) {
|
|
102
|
+
this.#host.chooseOutput(consumerId, height, output.outputKey ?? "");
|
|
103
|
+
const answer = this.#suitsViewer(output, base, consumerId).answer;
|
|
104
|
+
this.#host.noteServingVerdict(
|
|
105
|
+
consumerId,
|
|
106
|
+
answer ? { ...linkAnswerFigures(answer), outputKey: output.outputKey ?? "", height } : null
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether an output may serve this viewer, by the one rule every path asks
|
|
112
|
+
* (`serving-output.js`, `outputSuits`): the picture's material, the size a
|
|
113
|
+
* viewer who picked by hand requires, and a whole load their own link admits.
|
|
114
|
+
*
|
|
115
|
+
* @param {HlsSession} output
|
|
116
|
+
* @param {HlsSession} base
|
|
117
|
+
* @param {string} consumerId
|
|
118
|
+
* @param {{ width: number, height: number } | null} [wanted]
|
|
119
|
+
* @returns {{ suits: boolean, answer: object | null, reason: string }}
|
|
120
|
+
*/
|
|
121
|
+
#suitsViewer(output, base, consumerId, wanted = null) {
|
|
122
|
+
const linkMbps = this.#host.linkMbpsOf(base, consumerId);
|
|
123
|
+
const audioLoad = audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
|
|
124
|
+
const encode = output.spec?.video?.encode ?? null;
|
|
125
|
+
return outputSuits({
|
|
126
|
+
spec: output.spec,
|
|
127
|
+
pictureSpec: base.spec,
|
|
128
|
+
mode: this.#host.qualityModeOf(base, consumerId),
|
|
129
|
+
size: encode
|
|
130
|
+
? { width: encode.width, height: encode.height }
|
|
131
|
+
: { width: Number(base.file.width) || 0, height: Number(base.file.height) || 0 },
|
|
132
|
+
wanted,
|
|
133
|
+
judge: (spec) => linkCouldCarry(
|
|
134
|
+
linkMbps,
|
|
135
|
+
loadOf(videoLoadOfSpec(spec, base.file.decode?.megabitsPerSecond ?? null, this.#host.observedPeakMbps?.(spec) ?? null), audioLoad)
|
|
136
|
+
)
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The soundtrack part of the load this viewer's link carries.
|
|
142
|
+
*
|
|
143
|
+
* @param {HlsSession} base
|
|
144
|
+
* @param {string} consumerId
|
|
145
|
+
* @returns {import("./quality/link-budget.js").LoadPart | null}
|
|
146
|
+
*/
|
|
147
|
+
viewerAudioLoadOf(base, consumerId) {
|
|
148
|
+
return audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The soundtrack THIS viewer receives with the picture, as the link sees it:
|
|
153
|
+
* whether it is re-encoded, and the rate the file states for it.
|
|
154
|
+
*
|
|
155
|
+
* Their own choice where they have made one, else the picture's own track.
|
|
156
|
+
* Counted whether it travels inside the picture or as a stream of its own:
|
|
157
|
+
* it crosses the same link either way.
|
|
158
|
+
*
|
|
159
|
+
* @param {HlsSession} base
|
|
160
|
+
* @param {string} consumerId
|
|
161
|
+
* @returns {{ transcode: boolean, bitrateKbps: number | null }}
|
|
162
|
+
*/
|
|
163
|
+
#viewerAudioOf(base, consumerId) {
|
|
164
|
+
const choice = this.#host.audioChoiceOf(base, consumerId) ??
|
|
165
|
+
{ trackIndex: this.#flatAudioTrackOf(base), transcode: base.spec.transcodesAudio };
|
|
166
|
+
const inventory = this.#host.getCachedAudioTracks?.({
|
|
167
|
+
sourceKey: base.file.sourceKey,
|
|
168
|
+
fileIndex: base.file.fileIndex
|
|
169
|
+
}) ?? [];
|
|
170
|
+
const entry = Array.isArray(inventory) ? inventory.find((one) => one?.index === choice.trackIndex) : null;
|
|
171
|
+
return {
|
|
172
|
+
transcode: choice.transcode === true,
|
|
173
|
+
bitrateKbps: Number.isFinite(entry?.bitrateKbps) ? entry.bitrateKbps : null
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async resolveVariantSession(baseSessionId, height, wantedIndex = -1, consumerId = "") {
|
|
178
|
+
if (!isOutputName(baseSessionId)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const base = this.#host.outputs.get(baseSessionId);
|
|
182
|
+
if (!base) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (!Number.isInteger(height) || height <= 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
// Only the heights the master offers. Anything else is a made-up request,
|
|
189
|
+
// and honouring it would let a client start encoder runs at will. The
|
|
190
|
+
// MASTER's list, not the live one: a rung is published for the session's
|
|
191
|
+
// whole life, and refusing what we published is how a quality switch became
|
|
192
|
+
// a 404 storm across every level.
|
|
193
|
+
if (!this.#host.outputs.splicableHeights(base).includes(height)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
// A height is chosen FOR somebody. A request naming nobody has no link, no
|
|
197
|
+
// mode and no soundtrack to be judged by, so it is refused rather than
|
|
198
|
+
// answered by whatever somebody else was given.
|
|
199
|
+
if (!consumerId) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const existing = this.servingOutputFor(base, height, consumerId);
|
|
203
|
+
if (existing) {
|
|
204
|
+
this.#host.outputs.touch(existing);
|
|
205
|
+
return existing;
|
|
206
|
+
}
|
|
207
|
+
// hls.js asks for a new level's playlist, its init and its first segments
|
|
208
|
+
// within the same moment. Without this every one of them would build its
|
|
209
|
+
// own session, and the ones that lost would encode for nobody. Per VIEWER:
|
|
210
|
+
// two viewers of one height may be given two different outputs.
|
|
211
|
+
//
|
|
212
|
+
// A PREPARATION HOLDS THE DECISION IT WAS MADE ON: the link reading and the
|
|
213
|
+
// mode at its start. A request of theirs arriving meanwhile joins it and is
|
|
214
|
+
// told on what it was decided; a newer reading is weighed by the next
|
|
215
|
+
// decision, once this one has ended.
|
|
216
|
+
const pendingByHeight = this.#variantPending.get(base) ?? new Map();
|
|
217
|
+
this.#variantPending.set(base, pendingByHeight);
|
|
218
|
+
const pendingKey = `${height}:${consumerId}`;
|
|
219
|
+
const pending = pendingByHeight.get(pendingKey);
|
|
220
|
+
if (pending) {
|
|
221
|
+
this.#host.logger.info(
|
|
222
|
+
`transcode ${base.id} ${height}p for ${consumerId} joins the preparation decided on ` +
|
|
223
|
+
`link=${pending.linkMbps ?? "unmeasured"} mode=${pending.mode}`
|
|
224
|
+
);
|
|
225
|
+
return pending.creation;
|
|
226
|
+
}
|
|
227
|
+
const mode = this.#host.qualityModeOf(base, consumerId);
|
|
228
|
+
const linkMbps = this.#host.linkMbpsOf(base, consumerId);
|
|
229
|
+
if (height === this.#host.outputs.variantHeightOf(base)) {
|
|
230
|
+
// The picture's own height is answered by the picture only when it suits
|
|
231
|
+
// this viewer, by the same rule as any other output. A picture that does
|
|
232
|
+
// not is not handed over for being the one addressed.
|
|
233
|
+
const verdict = this.#suitsViewer(base, base, consumerId);
|
|
234
|
+
if (verdict.suits) {
|
|
235
|
+
this.#choose(consumerId, height, base, base);
|
|
236
|
+
return base;
|
|
237
|
+
}
|
|
238
|
+
this.#host.logger.info(
|
|
239
|
+
`transcode ${base.id} the picture's own ${height}p does not suit ${consumerId} (${verdict.reason}); deciding again`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const creation = this.#host.createOrGetSession(this.#stepOpening(base, {
|
|
243
|
+
height,
|
|
244
|
+
// Where this variant must begin. The segment the player asked it for when
|
|
245
|
+
// it can be known — that is the player stating outright where it will
|
|
246
|
+
// start fetching, and it is the only figure that cannot be stale.
|
|
247
|
+
//
|
|
248
|
+
// The other rung's read head is NOT that figure, and using it cost a
|
|
249
|
+
// stuck session on 2026-08-11: a 240p rung encoding at 5-6x had read 56 s
|
|
250
|
+
// further than the picture had played, so switching back to 400p placed
|
|
251
|
+
// that run at 3084 s while the player needed 3028 s, and no segment it
|
|
252
|
+
// wanted was ever produced.
|
|
253
|
+
startSeconds: this.#variantStartSeconds(
|
|
254
|
+
base,
|
|
255
|
+
Number.isInteger(wantedIndex) && wantedIndex >= 0 ? this.#host.outputTimes.segmentStartTime(base, wantedIndex) : undefined,
|
|
256
|
+
consumerId
|
|
257
|
+
),
|
|
258
|
+
mode,
|
|
259
|
+
linkMbps,
|
|
260
|
+
consumerId,
|
|
261
|
+
capKbps: null
|
|
262
|
+
}))
|
|
263
|
+
.then(async (variant) => {
|
|
264
|
+
// Making a session takes seconds — a probe and a keyframe index — and
|
|
265
|
+
// the viewer can leave inside that window. A variant registered onto a
|
|
266
|
+
// disposed base is reachable by nobody: the browser never learns its
|
|
267
|
+
// id, so nothing would release it and it would hold an encoder, a temp
|
|
268
|
+
// directory and a claim on the torrent until its own idle timer noticed
|
|
269
|
+
// half an hour later.
|
|
270
|
+
if (!this.#host.encodeRuns.isLive(base)) {
|
|
271
|
+
await this.#letGoIfNobodyIsOn(
|
|
272
|
+
variant,
|
|
273
|
+
`the picture it was made for ended while it was being made`
|
|
274
|
+
);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
// Served by the picture itself, which does not become a step. RECORDED
|
|
278
|
+
// like any other answer: a path that answers without recording leaves
|
|
279
|
+
// the next request to decide the same question again, and the four
|
|
280
|
+
// addressings of one step then name different outputs.
|
|
281
|
+
if (variant === base) {
|
|
282
|
+
this.#choose(consumerId, height, base, base);
|
|
283
|
+
return base;
|
|
284
|
+
}
|
|
285
|
+
const incumbent = await this.#adoptIfAlreadyProduced(base, height, variant, consumerId);
|
|
286
|
+
if (incumbent) {
|
|
287
|
+
this.#choose(consumerId, height, incumbent, base);
|
|
288
|
+
return incumbent;
|
|
289
|
+
}
|
|
290
|
+
// WHATEVER IT TURNED OUT TO BE is the answer for the height asked for.
|
|
291
|
+
// It used to be recorded only where the produced height equalled the
|
|
292
|
+
// one asked for — which is the case that needs no record at all: a step
|
|
293
|
+
// produced at another size is NAMED after that size, so nothing could
|
|
294
|
+
// find it by the height it answers, and every later request decided
|
|
295
|
+
// again.
|
|
296
|
+
const produced = this.#host.outputs.producedHeightOf(variant);
|
|
297
|
+
variant.variantHeight ??= produced > 0 ? produced : height;
|
|
298
|
+
// How it came to be: a step of a picture, not a picture a browser
|
|
299
|
+
// opened. Read where a step needs the facts of the file rather than of
|
|
300
|
+
// its own encode.
|
|
301
|
+
this.#host.outputs.markStep(variant);
|
|
302
|
+
this.#choose(consumerId, height, variant, base);
|
|
303
|
+
return variant;
|
|
304
|
+
})
|
|
305
|
+
.finally(() => {
|
|
306
|
+
pendingByHeight.delete(pendingKey);
|
|
307
|
+
});
|
|
308
|
+
pendingByHeight.set(pendingKey, { creation, linkMbps, mode, startedAt: Date.now() });
|
|
309
|
+
return creation;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* What opening a step of this picture asks of `OutputOpening`, for one
|
|
314
|
+
* viewer, at one height and, when named, one bitrate limit.
|
|
315
|
+
*
|
|
316
|
+
* One place for it, because two paths open steps — a height asked for, and a
|
|
317
|
+
* move to another limit of the height on screen — and the two must agree
|
|
318
|
+
* about everything but the size and the limit: the cuts, where the sound
|
|
319
|
+
* travels, the container. A step that differed from its picture in any of
|
|
320
|
+
* those would not be a step of it.
|
|
321
|
+
*
|
|
322
|
+
* @param {HlsSession} base
|
|
323
|
+
* @param {{ height: number, startSeconds: number, mode: "auto" | "manual", linkMbps: number | null, consumerId: string, capKbps: number | null }} params
|
|
324
|
+
* @returns {object}
|
|
325
|
+
*/
|
|
326
|
+
#stepOpening(base, { height, startSeconds, mode, linkMbps, consumerId, capKbps }) {
|
|
327
|
+
return {
|
|
328
|
+
sourceKey: base.file.sourceKey,
|
|
329
|
+
fileIndex: base.file.fileIndex,
|
|
330
|
+
transcodeVideo: true,
|
|
331
|
+
transcodeAudio: base.spec.transcodesAudio,
|
|
332
|
+
fileName: base.file.name,
|
|
333
|
+
// Opened by nobody in particular: the viewer who wants this step is
|
|
334
|
+
// registered on it by whoever asked for it.
|
|
335
|
+
consumerId: "",
|
|
336
|
+
targetWidth: 0,
|
|
337
|
+
targetHeight: height,
|
|
338
|
+
// Floored onto the ten-second grid that session keys are bucketed to:
|
|
339
|
+
// rounding is what that bucket does, and a position rounded UP starts the
|
|
340
|
+
// run past the viewer, so the run just spawned is killed and restarted
|
|
341
|
+
// before it has produced anything.
|
|
342
|
+
startPositionSeconds: Math.floor(startSeconds / 10) * 10,
|
|
343
|
+
audioTrackIndex: this.#flatAudioTrackOf(base),
|
|
344
|
+
// A rung is produced at exactly the size it names and the realtime budget
|
|
345
|
+
// does not move it — otherwise two rungs could drift onto the same height
|
|
346
|
+
// and the choice between them would mean nothing. True of EVERY rung,
|
|
347
|
+
// including one the player moved itself onto.
|
|
348
|
+
exactSize: true,
|
|
349
|
+
// Whose request this is decides whether an output already here may serve
|
|
350
|
+
// it: a size picked by hand is served exactly, the automatic choice by the
|
|
351
|
+
// quality rules. A viewer whose page does not say is taken as picking.
|
|
352
|
+
servingMode: mode,
|
|
353
|
+
viewerLinkMbps: linkMbps,
|
|
354
|
+
// A limit named outright, or null for the rule to choose one. Named, it
|
|
355
|
+
// is answered with that limit or not at all (`output-format.js`).
|
|
356
|
+
capKbps,
|
|
357
|
+
// The soundtrack THIS viewer hears, which is part of what their link
|
|
358
|
+
// has to carry and need not be the one the picture was opened with.
|
|
359
|
+
viewerAudio: this.#viewerAudioOf(base, consumerId),
|
|
360
|
+
// A rung of a session whose audio is published separately carries no
|
|
361
|
+
// audio either — every rung of one master must agree about that, or
|
|
362
|
+
// switching rung would start or stop a second copy of the same track.
|
|
363
|
+
audioRenditions: this.servesAudioSeparately(base),
|
|
364
|
+
// Not re-decided here: asked on its own, a variant would answer about the
|
|
365
|
+
// rungs IT would be offered at — a 540p rung of a copied 1080p source is
|
|
366
|
+
// offered nothing but itself, so it would conclude "audio muxed" and
|
|
367
|
+
// start carrying a second copy of a track the player is already fetching
|
|
368
|
+
// from the rendition.
|
|
369
|
+
inheritedAudioSeparate: this.servesAudioSeparately(base),
|
|
370
|
+
segmentFormatId: base.segmentFormat?.id ?? "",
|
|
371
|
+
// Cut where the base is cut. Only for a base on the source's own keyframe
|
|
372
|
+
// grid — a copy — where the variant has to land on those exact times to
|
|
373
|
+
// be interchangeable with it. A base on the uniform grid needs nothing
|
|
374
|
+
// passed: the variant computes the same even grid from the same duration.
|
|
375
|
+
inheritedGrid: base.timeline.cutGrid === "keyframe"
|
|
376
|
+
? {
|
|
377
|
+
// The table as it stands NOW, corrections included — not the index
|
|
378
|
+
// it was first built from. This is what the new session CUTS at.
|
|
379
|
+
boundaries: base.timeline.boundaries,
|
|
380
|
+
// And this is what it must SAY, which is not the same thing: every
|
|
381
|
+
// member of a family has to publish one timeline, or two sessions
|
|
382
|
+
// stamp the same moment differently and the picture and the sound
|
|
383
|
+
// drift apart by exactly the corrections made between their two
|
|
384
|
+
// creations (field 2026-08-17, corrections of 0.6-2.9 s).
|
|
385
|
+
published: base.timeline.published
|
|
386
|
+
}
|
|
387
|
+
: null
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Begin moving this viewer onto another output of the height on their
|
|
393
|
+
* screen, at another bitrate limit (roadmap item 97, step 12).
|
|
394
|
+
*
|
|
395
|
+
* THE ADDRESS DOES NOT CHANGE. Their player goes on asking `v/<height>/…`;
|
|
396
|
+
* what changes is which output this side answers that address with, and it
|
|
397
|
+
* changes only once the segment they will ask for next is closed on the new
|
|
398
|
+
* output (`noteSegmentPublished`). Until then they are given what they were
|
|
399
|
+
* given, so a move that never completes costs them nothing.
|
|
400
|
+
*
|
|
401
|
+
* WHAT IS MOVED BETWEEN: outputs of the same material and the same produced
|
|
402
|
+
* size whose bounds are two neighbouring limits of `limitsFor(frame)` — the
|
|
403
|
+
* row of the frame on screen, chosen by its area, the same row the output was
|
|
404
|
+
* opened in (`output-format.js`). Down,
|
|
405
|
+
* the highest lower limit their link admits; up, the next limit only, and
|
|
406
|
+
* only if their link admits it. A copy has no limit and a hardware encode is
|
|
407
|
+
* given none, so neither has anywhere to move to here, and the caller's other
|
|
408
|
+
* lever — another height — is what remains.
|
|
409
|
+
*
|
|
410
|
+
* The output prepared may be one another viewer is already watching at that
|
|
411
|
+
* limit: it is found by its key, and opening an output that exists returns it.
|
|
412
|
+
*
|
|
413
|
+
* @param {HlsSession} named - The output the browser addresses.
|
|
414
|
+
* @param {string} consumerId
|
|
415
|
+
* @param {"down" | "up"} direction
|
|
416
|
+
* @param {string} reasonText - Why, for the log.
|
|
417
|
+
* @returns {Promise<{ started: boolean, reason: string, noPlace?: boolean }>}
|
|
418
|
+
* `noPlace` when the refusal is the machine's: it holds no more encoders.
|
|
419
|
+
*/
|
|
420
|
+
async prepareSameHeightSwitch(named, consumerId, direction, reasonText) {
|
|
421
|
+
const base = named ? this.#host.outputs.pictureOf(named) : null;
|
|
422
|
+
const refuse = (reason) => ({ started: false, reason });
|
|
423
|
+
if (!consumerId || !base) {
|
|
424
|
+
return refuse("nobody named, or no picture");
|
|
425
|
+
}
|
|
426
|
+
// Only a master's variants go through the choice: without one, the player
|
|
427
|
+
// addresses a single output by name and there is nothing to answer with
|
|
428
|
+
// anything else.
|
|
429
|
+
if (!this.#host.outputs.publishesVariants(base)) {
|
|
430
|
+
return refuse("this stream publishes no variants");
|
|
431
|
+
}
|
|
432
|
+
if (this.#host.sameHeightSwitchOf(consumerId)) {
|
|
433
|
+
return refuse("a move is already being prepared for this viewer");
|
|
434
|
+
}
|
|
435
|
+
const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
|
|
436
|
+
const encode = current?.spec?.video?.encode ?? null;
|
|
437
|
+
if (!encode || !encode.rateControl) {
|
|
438
|
+
return refuse("the output on screen has no limit to change (a copy, or a hardware encode)");
|
|
439
|
+
}
|
|
440
|
+
const askedHeights = this.#host.heightsChosenAs(consumerId, current.outputKey ?? "");
|
|
441
|
+
if (askedHeights.length === 0) {
|
|
442
|
+
return refuse("no height is chosen as the output on screen");
|
|
443
|
+
}
|
|
444
|
+
const limits = this.#host.limitsFor({ width: encode.width, height: encode.height });
|
|
445
|
+
const at = limits.findIndex((limit) => maxrateKbpsFor(limit) === encode.rateControl.maxrateKbps);
|
|
446
|
+
if (at < 0) {
|
|
447
|
+
return refuse(`the limit on screen (maxrate ${encode.rateControl.maxrateKbps}k) is not one of ${encode.height}p's`);
|
|
448
|
+
}
|
|
449
|
+
const linkMbps = this.#host.linkMbpsOf(base, consumerId);
|
|
450
|
+
const audioLoad = this.viewerAudioLoadOf(base, consumerId);
|
|
451
|
+
const admits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad)).admitted;
|
|
452
|
+
const target = direction === "down"
|
|
453
|
+
? limits.slice(at + 1).find(admits)
|
|
454
|
+
: (at > 0 && admits(limits[at - 1]) ? limits[at - 1] : undefined);
|
|
455
|
+
if (target === undefined) {
|
|
456
|
+
return refuse(direction === "down"
|
|
457
|
+
? `no lower limit of ${encode.height}p is admitted by this viewer's link`
|
|
458
|
+
: `no higher limit of ${encode.height}p is admitted by this viewer's link, or ${encode.height}p is at its highest`);
|
|
459
|
+
}
|
|
460
|
+
let prepared;
|
|
461
|
+
try {
|
|
462
|
+
prepared = await this.#host.createOrGetSession(this.#stepOpening(base, {
|
|
463
|
+
height: encode.height,
|
|
464
|
+
startSeconds: this.#host.viewerPositionOf(base.id, consumerId),
|
|
465
|
+
// Exactly the size on screen: a move between limits never changes it,
|
|
466
|
+
// and an automatic answer could.
|
|
467
|
+
mode: "manual",
|
|
468
|
+
linkMbps,
|
|
469
|
+
consumerId,
|
|
470
|
+
capKbps: target
|
|
471
|
+
}));
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (error?.code === OUTPUT_UNAVAILABLE) {
|
|
474
|
+
return refuse(`the ${target}kbps output of ${encode.height}p does not suit this viewer: ${error.details?.reason ?? ""}`);
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
// The picture and the viewer may both have moved while it was being made.
|
|
479
|
+
if (!this.#host.encodeRuns.isLive(base) || !prepared) {
|
|
480
|
+
return refuse("the picture ended while the output was being made");
|
|
481
|
+
}
|
|
482
|
+
const preparedEncode = prepared.spec?.video?.encode ?? null;
|
|
483
|
+
if (
|
|
484
|
+
prepared === current ||
|
|
485
|
+
preparedEncode?.height !== encode.height ||
|
|
486
|
+
preparedEncode?.width !== encode.width ||
|
|
487
|
+
preparedEncode?.rateControl?.maxrateKbps !== maxrateKbpsFor(target) ||
|
|
488
|
+
!isSameMaterial(prepared.spec, base.spec)
|
|
489
|
+
) {
|
|
490
|
+
return refuse(`what was opened (${prepared.outputKey}) is not ${encode.height}p at ${target}kbps of this picture`);
|
|
491
|
+
}
|
|
492
|
+
// An output whose encoding has failed for good will close no segment, so a
|
|
493
|
+
// move onto it would wait for ever — and, cancelled on that failure, be
|
|
494
|
+
// prepared again on the next budget pass, once per cooldown.
|
|
495
|
+
if (this.#host.encodeRuns.hasFailed(prepared)) {
|
|
496
|
+
return refuse(`encoding ${prepared.outputKey} has failed`);
|
|
497
|
+
}
|
|
498
|
+
if (this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }) !== current ||
|
|
499
|
+
this.#host.sameHeightSwitchOf(consumerId)) {
|
|
500
|
+
return refuse("the output on this viewer's screen changed while the move was being prepared");
|
|
501
|
+
}
|
|
502
|
+
// A PLACE ON THIS MACHINE, asked before anything is recorded and in the same
|
|
503
|
+
// synchronous stretch as the record (roadmap item 97, step 13). Two moves
|
|
504
|
+
// onto two different outputs cannot then both be told there is room for
|
|
505
|
+
// one: the second is asked after the first is written, and is counted.
|
|
506
|
+
// Refused, nothing is recorded, and the caller goes to its other lever.
|
|
507
|
+
const place = this.#host.admitsPreparation(prepared);
|
|
508
|
+
if (!place.admitted) {
|
|
509
|
+
await this.#letGoIfNobodyIsOn(prepared, `a move onto it was refused a place: ${place.reason}`);
|
|
510
|
+
// Said as its own kind of refusal: "no limit to move to" sends the budget
|
|
511
|
+
// to another height, and "no place on the machine" must not send it UP,
|
|
512
|
+
// where the encoder would cost more still.
|
|
513
|
+
return { ...refuse(`the machine has no place for ${prepared.outputKey}: ${place.reason}`), noPlace: true };
|
|
514
|
+
}
|
|
515
|
+
// A STEP OF THIS PICTURE, as any step made for a height is: without it the
|
|
516
|
+
// output is a picture of its own, `pictureOf` answers with it rather than
|
|
517
|
+
// with the picture, and the choice about to point at it is looked up among
|
|
518
|
+
// this picture's steps and not found.
|
|
519
|
+
if (prepared !== base) {
|
|
520
|
+
prepared.variantHeight ??= encode.height;
|
|
521
|
+
this.#host.outputs.markStep(prepared);
|
|
522
|
+
}
|
|
523
|
+
this.#host.noteSameHeightSwitch(consumerId, {
|
|
524
|
+
askedHeights,
|
|
525
|
+
outputId: prepared.id,
|
|
526
|
+
outputKey: prepared.outputKey ?? "",
|
|
527
|
+
direction,
|
|
528
|
+
reason: reasonText,
|
|
529
|
+
since: Date.now()
|
|
530
|
+
});
|
|
531
|
+
// On it for as long as the move is prepared: that is what buys it an
|
|
532
|
+
// encoder, from where they stand. `watches` and not `placeOn`: a viewer is
|
|
533
|
+
// one record whose position is already known, and placing them again would
|
|
534
|
+
// restate it and clear the cushion they reported with it.
|
|
535
|
+
this.#host.watches(prepared, consumerId);
|
|
536
|
+
this.#host.planEncodersSoon();
|
|
537
|
+
this.#host.logger.info(
|
|
538
|
+
`transcode ${base.id} preparing ${consumerId}'s move ${direction} from ${current.outputKey} to ` +
|
|
539
|
+
`${prepared.outputKey} (${encode.height}p, ${target}kbps): ${reasonText}`
|
|
540
|
+
);
|
|
541
|
+
// It may already be made there — another viewer on it, or material kept.
|
|
542
|
+
this.#reconsiderSameHeightSwitch(base, consumerId, null);
|
|
543
|
+
const active = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
|
|
544
|
+
if (active === prepared || this.#host.sameHeightSwitchOf(consumerId)) {
|
|
545
|
+
return { started: true, reason: "" };
|
|
546
|
+
}
|
|
547
|
+
return refuse("the prepared output was not usable for this viewer");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Whether a move to another limit is being prepared for this viewer.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} consumerId
|
|
554
|
+
* @returns {boolean}
|
|
555
|
+
*/
|
|
556
|
+
sameHeightSwitchPending(consumerId) {
|
|
557
|
+
return Boolean(this.#host.sameHeightSwitchOf(consumerId));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Which way the move being prepared for this viewer goes, or null.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} consumerId
|
|
564
|
+
* @returns {"down" | "up" | null}
|
|
565
|
+
*/
|
|
566
|
+
sameHeightSwitchDirection(consumerId) {
|
|
567
|
+
return this.#host.sameHeightSwitchOf(consumerId)?.direction ?? null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Stop preparing this viewer's move, for a reason the quality budget found:
|
|
572
|
+
* the conditions a move up was started under have gone back (roadmap item
|
|
573
|
+
* 98).
|
|
574
|
+
*
|
|
575
|
+
* @param {string} consumerId
|
|
576
|
+
* @param {string} reason
|
|
577
|
+
* @returns {void}
|
|
578
|
+
*/
|
|
579
|
+
cancelSameHeightSwitch(consumerId, reason) {
|
|
580
|
+
const move = consumerId ? this.#host.sameHeightSwitchOf(consumerId) : null;
|
|
581
|
+
const prepared = move ? this.#host.outputs.get(move.outputId) : null;
|
|
582
|
+
if (!move || !prepared) {
|
|
583
|
+
if (move) {
|
|
584
|
+
this.#host.noteSameHeightSwitch(consumerId, null);
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
this.#cancelSameHeightSwitch(this.#host.outputs.pictureOf(prepared), consumerId, reason);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Whether a step of this picture at `height` is READY for this viewer: it
|
|
593
|
+
* exists and the piece they will ask for next is closed on it (roadmap item
|
|
594
|
+
* 98). Ready means that piece — not the whole film.
|
|
595
|
+
*
|
|
596
|
+
* @param {HlsSession} base
|
|
597
|
+
* @param {string} consumerId
|
|
598
|
+
* @param {number} height
|
|
599
|
+
* @returns {boolean}
|
|
600
|
+
*/
|
|
601
|
+
heightReadyFor(base, consumerId, height) {
|
|
602
|
+
const step = this.#interchangeableWith(base, base.spec)
|
|
603
|
+
.find((output) => this.#host.outputs.variantHeightOf(output) === height);
|
|
604
|
+
if (!step) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
|
|
608
|
+
const askedHeights = current ? this.#host.heightsChosenAs(consumerId, current.outputKey ?? "") : [];
|
|
609
|
+
const next = this.#nextSegmentOf(base, consumerId, askedHeights, step);
|
|
610
|
+
return next < step.timeline.segmentCount && this.#host.segmentClosed(step.outputKey ?? "", next);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A segment was closed on some output. If a viewer's move is being prepared
|
|
615
|
+
* onto it, whether that move can be made now is asked again.
|
|
616
|
+
*
|
|
617
|
+
* Told by `SegmentStore.onPublished`, and nothing here polls: a move waits
|
|
618
|
+
* for exactly the event that makes it possible.
|
|
619
|
+
*
|
|
620
|
+
* @param {string} key - The output the segment was closed on.
|
|
621
|
+
* @param {number} index
|
|
622
|
+
* @returns {void}
|
|
623
|
+
*/
|
|
624
|
+
noteSegmentPublished(key, index) {
|
|
625
|
+
if (!key) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
for (const output of this.#host.outputs.values()) {
|
|
629
|
+
if (output.outputKey !== key) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const base = this.#host.outputs.pictureOf(output);
|
|
633
|
+
for (const consumerId of this.#host.switchingOnto(output)) {
|
|
634
|
+
this.#reconsiderSameHeightSwitch(base, consumerId, index);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* A viewer reported on themselves. If a move is being prepared for them,
|
|
641
|
+
* whether it is still wanted is asked again.
|
|
642
|
+
*
|
|
643
|
+
* WHY THIS EVENT. A publication is the only other one, and it comes only
|
|
644
|
+
* from an output that is producing. A move waiting on an output that closes
|
|
645
|
+
* nothing would otherwise stand for ever: a link that recovered would not
|
|
646
|
+
* cancel it, and the quality budget, which leaves alone a viewer with a move
|
|
647
|
+
* being prepared, would never decide for them again. The report is what
|
|
648
|
+
* carries a change of their link, so it is where that change is judged.
|
|
649
|
+
*
|
|
650
|
+
* The move can also be made here, and only on the condition every other
|
|
651
|
+
* trigger uses: the segment they will ask for next is closed on the new
|
|
652
|
+
* output. The report changes when the question is asked, not its answer.
|
|
653
|
+
*
|
|
654
|
+
* @param {string} sessionId - The output the page addressed.
|
|
655
|
+
* @param {string} consumerId
|
|
656
|
+
* @returns {void}
|
|
657
|
+
*/
|
|
658
|
+
noteViewerReported(sessionId, consumerId) {
|
|
659
|
+
const move = consumerId ? this.#host.sameHeightSwitchOf(consumerId) : null;
|
|
660
|
+
if (!move) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
// The picture the move belongs to, read from the output being prepared: the
|
|
664
|
+
// page may address a picture of another film, and a move is about one.
|
|
665
|
+
const prepared = this.#host.outputs.get(move.outputId);
|
|
666
|
+
const named = prepared ?? (sessionId ? this.#host.outputs.get(sessionId) : null);
|
|
667
|
+
const base = named ? this.#host.outputs.pictureOf(named) : null;
|
|
668
|
+
if (!base) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
this.#reconsiderSameHeightSwitch(base, consumerId, null);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Encoding an output has failed for good. Every move being prepared onto it
|
|
676
|
+
* is asked again, which cancels it: nothing will close the segment it waits
|
|
677
|
+
* for, and no publication will come to say so.
|
|
678
|
+
*
|
|
679
|
+
* Told by `EncodeRuns` at the moment the failure is recorded.
|
|
680
|
+
*
|
|
681
|
+
* @param {HlsSession} output
|
|
682
|
+
* @returns {void}
|
|
683
|
+
*/
|
|
684
|
+
noteProductionFailed(output) {
|
|
685
|
+
if (!output) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const base = this.#host.outputs.pictureOf(output);
|
|
689
|
+
for (const consumerId of this.#host.switchingOnto(output)) {
|
|
690
|
+
this.#reconsiderSameHeightSwitch(base, consumerId, null);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The segment this viewer will ask for next at these heights.
|
|
696
|
+
*
|
|
697
|
+
* Their player fetches one level's segments in order within one viewing, so
|
|
698
|
+
* the next is one past the highest they were given in the viewing they are in
|
|
699
|
+
* NOW. Given nothing yet in it — straight after a seek — the next is the one
|
|
700
|
+
* where they stand. Read at the moment of asking, so a decision taken on an
|
|
701
|
+
* event that arrived late is taken about where they are.
|
|
702
|
+
*
|
|
703
|
+
* @param {HlsSession} base
|
|
704
|
+
* @param {string} consumerId
|
|
705
|
+
* @param {number[]} askedHeights
|
|
706
|
+
* @param {HlsSession} prepared
|
|
707
|
+
* @returns {number}
|
|
708
|
+
*/
|
|
709
|
+
#nextSegmentOf(base, consumerId, askedHeights, prepared) {
|
|
710
|
+
const highest = Math.max(-1, ...askedHeights.map((height) => this.#host.highestGivenSegmentOf(consumerId, height)));
|
|
711
|
+
if (highest >= 0) {
|
|
712
|
+
return highest + 1;
|
|
713
|
+
}
|
|
714
|
+
return this.#host.outputTimes.segmentIndexForTime(prepared, this.#host.viewerPositionOf(base.id, consumerId));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Make the move being prepared for this viewer, if it can be made now; cancel
|
|
719
|
+
* it, if it should not be made at all; otherwise leave it waiting.
|
|
720
|
+
*
|
|
721
|
+
* @param {HlsSession} base
|
|
722
|
+
* @param {string} consumerId
|
|
723
|
+
* @param {number | null} publishedIndex - The segment whose closing caused
|
|
724
|
+
* this, or null when asked for no event in particular.
|
|
725
|
+
* @returns {void}
|
|
726
|
+
*/
|
|
727
|
+
#reconsiderSameHeightSwitch(base, consumerId, publishedIndex) {
|
|
728
|
+
const move = this.#host.sameHeightSwitchOf(consumerId);
|
|
729
|
+
if (!move) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const prepared = this.#host.outputs.get(move.outputId);
|
|
733
|
+
if (!prepared || prepared.outputKey !== move.outputKey) {
|
|
734
|
+
this.#cancelSameHeightSwitch(base, consumerId, "the output being prepared has gone");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
|
|
738
|
+
if (current === prepared) {
|
|
739
|
+
this.#host.noteSameHeightSwitch(consumerId, null);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (this.#host.encodeRuns.hasFailed(prepared)) {
|
|
743
|
+
this.#cancelSameHeightSwitch(base, consumerId, "encoding the output being prepared has failed");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!this.#suitsViewer(prepared, base, consumerId).suits) {
|
|
747
|
+
this.#cancelSameHeightSwitch(base, consumerId, "the output being prepared no longer suits this viewer's link");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (move.direction === "down" && this.#suitsViewer(current, base, consumerId).suits) {
|
|
751
|
+
this.#cancelSameHeightSwitch(base, consumerId, "this viewer's link carries the output on screen again");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const next = this.#nextSegmentOf(base, consumerId, move.askedHeights, prepared);
|
|
755
|
+
if (next >= prepared.timeline.segmentCount) {
|
|
756
|
+
this.#cancelSameHeightSwitch(base, consumerId, "there is nothing left of the film to give from it");
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
// A segment closed BEHIND the one they will ask for says nothing about it:
|
|
760
|
+
// the move would hand them an output that does not yet have what they need.
|
|
761
|
+
if (publishedIndex !== null && publishedIndex < next) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (!this.#host.segmentClosed(prepared.outputKey ?? "", next)) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
// THE CUSHION, for a move that is not urgent (roadmap item 98): a move up
|
|
768
|
+
// is made once the viewer holds the cushion this file needs, and their
|
|
769
|
+
// next report asks again until they do. A move DOWN is made at once — it
|
|
770
|
+
// was started because their buffer would run dry, and a cushion that is
|
|
771
|
+
// shrinking would be waited for for ever. A file with no cushion stated has
|
|
772
|
+
// none to wait for.
|
|
773
|
+
if (move.direction !== "down") {
|
|
774
|
+
const needed = this.#host.minimumBufferSecondsFor(prepared);
|
|
775
|
+
if (Number.isFinite(needed) && needed > 0 && this.#host.bufferedSecondsOf(consumerId) < needed) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// The player fetches the fMP4 init once and decodes every later fragment
|
|
780
|
+
// against it. Changing bitrate outputs is safe only when the prepared
|
|
781
|
+
// output's init says the same thing to that decoder. The new segment is
|
|
782
|
+
// closed here, so its init is available for this check. MPEG-TS has no init
|
|
783
|
+
// and is self-contained, so it does not use this gate.
|
|
784
|
+
if (
|
|
785
|
+
current &&
|
|
786
|
+
current.outputKey !== prepared.outputKey &&
|
|
787
|
+
current.segmentFormat?.initFileName !== null &&
|
|
788
|
+
prepared.segmentFormat?.initFileName !== null
|
|
789
|
+
) {
|
|
790
|
+
const verdict = this.#host.headersCompatible(current.outputKey, prepared.outputKey);
|
|
791
|
+
if (verdict?.compatible !== true) {
|
|
792
|
+
const differences = verdict?.differences?.join("; ") || "the headers are missing or incompatible";
|
|
793
|
+
this.#cancelSameHeightSwitch(base, consumerId, `the prepared output has an incompatible init: ${differences}`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
this.#completeSameHeightSwitch(base, consumerId, move, prepared, current, next);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* The move itself.
|
|
802
|
+
*
|
|
803
|
+
* ONE SYNCHRONOUS STRETCH, with no `await` anywhere in it. The choice, the
|
|
804
|
+
* output on screen and where the viewer is registered are three records of
|
|
805
|
+
* one fact, and a suspension between them would let a request be answered by
|
|
806
|
+
* the new choice while the viewer is still registered only on the old output,
|
|
807
|
+
* or the plan run on the old output's map with the viewer already gone from
|
|
808
|
+
* the choice.
|
|
809
|
+
*
|
|
810
|
+
* THE OUTPUT LEFT IS NOT DISPOSED. The viewer stops watching it, its priority
|
|
811
|
+
* map loses them, and the plan stops its encoder when nobody is left on it;
|
|
812
|
+
* the output itself goes by the ordinary idle expiry, which keeps it while an
|
|
813
|
+
* assignment stands — a response still being sent from it, or a segment of it
|
|
814
|
+
* given in a viewing still inside its window. A repeat of such an address is
|
|
815
|
+
* answered by it, because `given` is read before `chosen`.
|
|
816
|
+
*
|
|
817
|
+
* @param {HlsSession} base
|
|
818
|
+
* @param {string} consumerId
|
|
819
|
+
* @param {{ askedHeights: number[], direction: string, reason: string }} move
|
|
820
|
+
* @param {HlsSession} prepared
|
|
821
|
+
* @param {HlsSession} current
|
|
822
|
+
* @param {number} next
|
|
823
|
+
* @returns {void}
|
|
824
|
+
*/
|
|
825
|
+
#completeSameHeightSwitch(base, consumerId, move, prepared, current, next) {
|
|
826
|
+
for (const height of move.askedHeights) {
|
|
827
|
+
this.#choose(consumerId, height, prepared, base);
|
|
828
|
+
}
|
|
829
|
+
this.#host.noteStepOnScreen(base, consumerId, prepared.id);
|
|
830
|
+
this.#host.watches(prepared, consumerId);
|
|
831
|
+
// The picture is never left: their soundtrack and their position are
|
|
832
|
+
// recorded on it (`#noteVariantActive`).
|
|
833
|
+
if (current !== base) {
|
|
834
|
+
this.#host.viewerLeaves(current, consumerId);
|
|
835
|
+
}
|
|
836
|
+
this.#host.noteSameHeightSwitch(consumerId, null);
|
|
837
|
+
this.#host.planEncodersSoon();
|
|
838
|
+
// How long this move took to be ready, and what the viewer held when it
|
|
839
|
+
// happened — kept for the next time this host decides when to start one.
|
|
840
|
+
if (Number.isFinite(move.since)) {
|
|
841
|
+
this.#host.notePreparation?.(prepared, (Date.now() - move.since) / 1000, this.#host.bufferedSecondsOf(consumerId));
|
|
842
|
+
}
|
|
843
|
+
this.#host.logger.info(
|
|
844
|
+
`transcode ${base.id} moved ${consumerId} ${move.direction} from ${current.outputKey} to ` +
|
|
845
|
+
`${prepared.outputKey} from segment #${next}: ${move.reason}`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Stop preparing this viewer's move.
|
|
851
|
+
*
|
|
852
|
+
* They stop watching the output being prepared — unless it is the one on
|
|
853
|
+
* their screen or the picture itself — and nothing else: whether its encoder
|
|
854
|
+
* goes on is the plan's, from who is left on it, and the output itself goes
|
|
855
|
+
* by the idle expiry.
|
|
856
|
+
*
|
|
857
|
+
* @param {HlsSession} base
|
|
858
|
+
* @param {string} consumerId
|
|
859
|
+
* @param {string} reason
|
|
860
|
+
* @returns {void}
|
|
861
|
+
*/
|
|
862
|
+
#cancelSameHeightSwitch(base, consumerId, reason) {
|
|
863
|
+
const move = this.#host.sameHeightSwitchOf(consumerId);
|
|
864
|
+
if (!move) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
this.#host.noteSameHeightSwitch(consumerId, null);
|
|
868
|
+
const prepared = this.#host.outputs.get(move.outputId);
|
|
869
|
+
const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
|
|
870
|
+
if (prepared && prepared !== current && prepared !== base) {
|
|
871
|
+
this.#host.viewerLeaves(prepared, consumerId);
|
|
872
|
+
}
|
|
873
|
+
// The place the move held on this machine ended with its record, so what
|
|
874
|
+
// may run elsewhere has changed whether or not anybody left an output.
|
|
875
|
+
this.#host.planEncodersSoon();
|
|
876
|
+
this.#host.logger.info(`transcode ${base.id} ${consumerId}'s move to ${move.outputKey} cancelled: ${reason}`);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* A session of this family already making exactly this picture, if there is
|
|
881
|
+
* one — so that a second request for it does not start a second encoder.
|
|
882
|
+
*
|
|
883
|
+
* WHY IT COMPARES THE HEIGHT PRODUCED. An output is named by its whole
|
|
884
|
+
* format, and a rung produced exactly at a height can still differ from one
|
|
885
|
+
* already running at that height by its speed setting or its encoder. For
|
|
886
|
+
* the viewer those two are the same picture, so the one already producing it
|
|
887
|
+
* is used rather than a second encoder beside it. It was written when a
|
|
888
|
+
* manual pick was clamped below the height named, which put three encoders on
|
|
889
|
+
* one identical picture on 2026-08-28
|
|
890
|
+
* (`research/session-pileup-variant-key-2026-08-28.md`); the clamp is gone,
|
|
891
|
+
* and choosing an existing output by quality replaces this comparison.
|
|
892
|
+
*
|
|
893
|
+
* @param {HlsSession} base
|
|
894
|
+
* @param {number} askedHeight
|
|
895
|
+
* @param {HlsSession} candidate - The session just created for `askedHeight`.
|
|
896
|
+
* @returns {Promise<HlsSession | null>} The incumbent to use instead, or null
|
|
897
|
+
* to keep the one just made.
|
|
898
|
+
*/
|
|
899
|
+
/**
|
|
900
|
+
* Let go of an output this call has finished with — unless somebody is on it.
|
|
901
|
+
*
|
|
902
|
+
* WHY IT IS ASKED AT THE MOMENT OF LETTING GO, and not decided in advance.
|
|
903
|
+
* Two of these disposals reasoned "nobody is on it yet", and neither could
|
|
904
|
+
* know it. An output is addressed by what it PRODUCES, so opening one returns
|
|
905
|
+
* the output that already makes that picture whenever there is one: what came
|
|
906
|
+
* back may be a step another viewer has been watching for an hour. And even a
|
|
907
|
+
* genuinely new one is only new for as long as nothing else has reached it —
|
|
908
|
+
* making a step takes a probe and a keyframe index, seconds in which a second
|
|
909
|
+
* viewer can ask for the same height and be registered onto it.
|
|
910
|
+
*
|
|
911
|
+
* So the question is the registry's, asked now: is anybody watching this. A
|
|
912
|
+
* step nobody is on is stopped by the plan on its next pass anyway, because
|
|
913
|
+
* its priority map is empty; what must never happen is taking away an output
|
|
914
|
+
* somebody is watching, which stops their encoder and empties their screen.
|
|
915
|
+
*
|
|
916
|
+
* @param {HlsSession} output
|
|
917
|
+
* @param {string} because
|
|
918
|
+
* @returns {Promise<boolean>} Whether it went.
|
|
919
|
+
*/
|
|
920
|
+
async #letGoIfNobodyIsOn(output, because) {
|
|
921
|
+
// NOBODY WATCHING IS NOT THE WHOLE QUESTION. A response already begun is
|
|
922
|
+
// still being sent from this output, and a request made moments ago may
|
|
923
|
+
// still be repeated and has to be answered by whatever answered it first.
|
|
924
|
+
// Both stand after the last viewer has stopped being registered on it.
|
|
925
|
+
if (this.#host.outputStillNeeded(output)) {
|
|
926
|
+
this.#host.logger.info(
|
|
927
|
+
`transcode ${output.id} kept although ${because}: ` +
|
|
928
|
+
`${this.#host.viewerCountOn(output)} viewer(s) watching it, and assignments may still stand`
|
|
929
|
+
);
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
this.#host.logger.info(`transcode ${output.id} disposed: ${because}`);
|
|
933
|
+
await this.#host.disposeSession(output.id);
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The outputs of this picture that could stand in for `spec` — the same
|
|
939
|
+
* material, so interchangeable as far as anything but picture size goes.
|
|
940
|
+
*
|
|
941
|
+
* ONE RULE, ASKED BY EVERY PATH THAT PICKS AN OUTPUT. Two of them used to
|
|
942
|
+
* gather the family for themselves, and only one checked the material: a
|
|
943
|
+
* 720p step in MPEG-TS could be found by produced height and handed to a
|
|
944
|
+
* picture cut for fMP4, whose player cannot even ask for its segment names.
|
|
945
|
+
* Fixing the one path left the other open inside the same operation, which
|
|
946
|
+
* is why the rule is stated here rather than at each caller.
|
|
947
|
+
*
|
|
948
|
+
* The picture itself is filtered like any other member. It passes trivially
|
|
949
|
+
* when compared against its own identity, and comparing it is what makes
|
|
950
|
+
* this one rule instead of two.
|
|
951
|
+
*
|
|
952
|
+
* @param {HlsSession} base
|
|
953
|
+
* @param {import("./output/OutputSpec.js").OutputSpec} spec - What the
|
|
954
|
+
* candidate must be interchangeable WITH: the picture's own identity when
|
|
955
|
+
* answering for a height, the created output's when replacing it.
|
|
956
|
+
* @returns {HlsSession[]}
|
|
957
|
+
*/
|
|
958
|
+
#interchangeableWith(base, spec) {
|
|
959
|
+
return [base, ...this.#host.outputs.stepsOf(base)]
|
|
960
|
+
.filter((other) => other && isSameMaterial(other.spec, spec));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The output that answered this address for this viewer earlier in the same
|
|
965
|
+
* generation, if it is still here and still interchangeable with the picture.
|
|
966
|
+
*
|
|
967
|
+
* An answer whose output has gone — disposed on a hardware encoder's failure,
|
|
968
|
+
* the one disposal that does not ask the assignments — is no answer, and the
|
|
969
|
+
* address is decided afresh.
|
|
970
|
+
*
|
|
971
|
+
* @param {HlsSession} base
|
|
972
|
+
* @param {string} consumerId
|
|
973
|
+
* @param {number} generation
|
|
974
|
+
* @param {number} height
|
|
975
|
+
* @param {number} segmentIndex - −1 for the init.
|
|
976
|
+
* @returns {{ state: "none" } | { state: "live", output: HlsSession } | { state: "gone", key: string }}
|
|
977
|
+
*/
|
|
978
|
+
#givenBefore(base, consumerId, generation, height, segmentIndex) {
|
|
979
|
+
const key = this.#host.givenOutputOf(consumerId, generation, height, segmentIndex);
|
|
980
|
+
if (!key) {
|
|
981
|
+
return { state: "none" };
|
|
982
|
+
}
|
|
983
|
+
const output = this.#interchangeableWith(base, base.spec).find((other) => other.outputKey === key) ?? null;
|
|
984
|
+
return output ? { state: "live", output } : { state: "gone", key };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* A repeat of an address whose output has gone.
|
|
989
|
+
*
|
|
990
|
+
* NEVER DECIDED AGAIN AS IF NEW. The player holds the header of the output
|
|
991
|
+
* that answered this address; a piece of another output under the same
|
|
992
|
+
* address may not decode under it. So, in order:
|
|
993
|
+
*
|
|
994
|
+
* 1. the very piece that was given, from what is stored under the gone
|
|
995
|
+
* output's key — its pieces and header outlive it until the disk needs the
|
|
996
|
+
* room;
|
|
997
|
+
* 2. a piece of the output this viewer would be given now, but only when its
|
|
998
|
+
* header is PROVEN compatible with the gone one (`init-compat.js`);
|
|
999
|
+
* 3. otherwise the address is lost, and the page is told so: it starts a new
|
|
1000
|
+
* viewing where the picture is, which is the one way out that does not
|
|
1001
|
+
* serve a piece under a header it may not match. Answering "retry" here
|
|
1002
|
+
* instead would be answered the same way for ever.
|
|
1003
|
+
*
|
|
1004
|
+
* @param {HlsSession} base
|
|
1005
|
+
* @param {number} height
|
|
1006
|
+
* @param {string} fileName
|
|
1007
|
+
* @param {number} segmentIndex
|
|
1008
|
+
* @param {string} consumerId
|
|
1009
|
+
* @param {number} generation
|
|
1010
|
+
* @param {string} goneKey
|
|
1011
|
+
* @returns {Promise<{ sessionId: string | null, recover?: { key: string, likeId: string }, lost?: object, unavailable?: object, error?: string }>}
|
|
1012
|
+
*/
|
|
1013
|
+
async #answerForLostAddress(base, height, fileName, segmentIndex, consumerId, generation, goneKey) {
|
|
1014
|
+
if (this.#host.storedPieceReady(goneKey, fileName)) {
|
|
1015
|
+
return { sessionId: null, recover: { key: goneKey, likeId: base.id } };
|
|
1016
|
+
}
|
|
1017
|
+
let current = null;
|
|
1018
|
+
try {
|
|
1019
|
+
current = await this.resolveVariantSession(base.id, height, segmentIndex, consumerId);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
if (error?.code !== OUTPUT_UNAVAILABLE) {
|
|
1022
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1023
|
+
return { sessionId: null, error: message };
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const verdict = current ? this.#host.headersCompatible(goneKey, current.outputKey ?? "") : null;
|
|
1027
|
+
if (current && verdict?.compatible) {
|
|
1028
|
+
this.#host.placeViewerOn(current, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
|
|
1029
|
+
this.#host.noteGivenOutput(consumerId, generation, height, segmentIndex, current.outputKey ?? "");
|
|
1030
|
+
this.#host.logger.info(
|
|
1031
|
+
`transcode ${base.id} ${fileName} for ${consumerId}: ${goneKey} has gone; served by ` +
|
|
1032
|
+
`${current.outputKey}, whose header is compatible with it`
|
|
1033
|
+
);
|
|
1034
|
+
return { sessionId: current.id };
|
|
1035
|
+
}
|
|
1036
|
+
const reason = current
|
|
1037
|
+
? `the output that answered it has gone, and the one that would answer now has a header not proven compatible (${(verdict?.differences ?? []).join("; ") || "no header yet"})`
|
|
1038
|
+
: "the output that answered it has gone, and nothing that could answer now suits this viewer";
|
|
1039
|
+
this.#host.logger.info(`transcode ${base.id} ${fileName} for ${consumerId}: assignment lost — ${reason}`);
|
|
1040
|
+
return { sessionId: null, lost: { reason, goneKey, height, segmentIndex, generation } };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async #adoptIfAlreadyProduced(base, askedHeight, candidate, consumerId) {
|
|
1044
|
+
const produced = this.#host.outputs.producedHeightOf(candidate);
|
|
1045
|
+
if (produced <= 0) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
const seen = new Set([candidate.id]);
|
|
1049
|
+
// The base belongs in this scan: it is a rung like any other, and when it
|
|
1050
|
+
// is itself a re-encode the clamp can land a variant right on top of it.
|
|
1051
|
+
// Compared against the CANDIDATE, because it is the candidate's place the
|
|
1052
|
+
// incumbent takes — the viewer asked for what the candidate is.
|
|
1053
|
+
for (const other of this.#interchangeableWith(base, candidate.spec)) {
|
|
1054
|
+
if (!other || seen.has(other.id)) {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
seen.add(other.id);
|
|
1058
|
+
if (this.#host.outputs.producedHeightOf(other) !== produced) {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
// THE SAME PICTURE IS NOT ENOUGH: it has to suit THIS viewer by the one
|
|
1062
|
+
// rule every path asks — their mode and a load their own link admits. Two
|
|
1063
|
+
// outputs of one size can differ in their limit, and the one already here
|
|
1064
|
+
// may be more than this viewer's link carries.
|
|
1065
|
+
const verdict = this.#suitsViewer(other, base, consumerId);
|
|
1066
|
+
if (!verdict.suits) {
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
// Same picture, already being made. Let go of the one just created — if
|
|
1070
|
+
// it IS one, and if it is still nobody's.
|
|
1071
|
+
await this.#letGoIfNobodyIsOn(candidate, `${other.id.slice(0, 8)} already produces this picture`);
|
|
1072
|
+
this.#host.logger.info(
|
|
1073
|
+
`transcode ${base.id.slice(0, 8)} the ${askedHeight}p rung encodes at ${produced}p on this ` +
|
|
1074
|
+
`machine, which ${other.id.slice(0, 8)} is already producing — serving it from there ` +
|
|
1075
|
+
`instead of starting a second encoder "${base.file.name}"`
|
|
1076
|
+
);
|
|
1077
|
+
return other;
|
|
1078
|
+
}
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Resolve one file request addressed to a variant: `v/<height>/<fileName>`
|
|
1084
|
+
* under a session.
|
|
1085
|
+
*
|
|
1086
|
+
* The single entry point for the variant route, so the policy — which variant
|
|
1087
|
+
* exists, which one the viewer is watching, which encoder runs — stays here
|
|
1088
|
+
* rather than being spread into a route handler.
|
|
1089
|
+
*
|
|
1090
|
+
* @param {string} baseSessionId
|
|
1091
|
+
* @param {number} height
|
|
1092
|
+
* @param {string} fileName
|
|
1093
|
+
* @param {string} [consumerId] - Which viewer is asking. One picture is shared
|
|
1094
|
+
* by everyone watching it, and the quality each of them chose is their own.
|
|
1095
|
+
* @param {number} [statedGeneration] - Which viewing of theirs the request was
|
|
1096
|
+
* made in, as the page stamped it; NaN when it states none.
|
|
1097
|
+
* @returns {Promise<{ sessionId: string | null, error?: string }>} The session
|
|
1098
|
+
* to serve the file from; a null id means there is no such variant.
|
|
1099
|
+
*/
|
|
1100
|
+
async resolveVariantFile(baseSessionId, height, fileName, consumerId = "", statedGeneration = Number.NaN) {
|
|
1101
|
+
if (!isOutputName(baseSessionId)) {
|
|
1102
|
+
return { sessionId: null };
|
|
1103
|
+
}
|
|
1104
|
+
const base = this.#host.outputs.get(baseSessionId);
|
|
1105
|
+
if (!base) {
|
|
1106
|
+
return { sessionId: null };
|
|
1107
|
+
}
|
|
1108
|
+
// A variant carries a media playlist, an init segment and segments. Nothing
|
|
1109
|
+
// else lives under that path — a master there would describe variants of a
|
|
1110
|
+
// variant.
|
|
1111
|
+
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
1112
|
+
const isInit = base.segmentFormat.initFileName !== null &&
|
|
1113
|
+
fileName === base.segmentFormat.initFileName;
|
|
1114
|
+
const isSegment = base.segmentFormat.isSegmentFileName(fileName);
|
|
1115
|
+
if (!isPlaylist && !isInit && !isSegment) {
|
|
1116
|
+
return { sessionId: null };
|
|
1117
|
+
}
|
|
1118
|
+
if (!this.#host.outputs.splicableHeights(base).includes(height)) {
|
|
1119
|
+
return { sessionId: null };
|
|
1120
|
+
}
|
|
1121
|
+
// Answered from the base, and no encoder is started for it. Every variant of
|
|
1122
|
+
// a file has the SAME media playlist — same duration, same boundaries, same
|
|
1123
|
+
// init name — because that is exactly what makes them interchangeable. The
|
|
1124
|
+
// player fetches a level's playlist to decide with, and creating a session
|
|
1125
|
+
// for one it may never switch to would leave a second encoder running on a
|
|
1126
|
+
// host that has capacity for one.
|
|
1127
|
+
if (isPlaylist) {
|
|
1128
|
+
return { sessionId: base.id };
|
|
1129
|
+
}
|
|
1130
|
+
// THE ADDRESS OF THIS ANSWER: the height asked for and the segment, the
|
|
1131
|
+
// init being segment −1. Within one generation of this viewer's viewing a
|
|
1132
|
+
// repeat of the same address is answered by what answered it first — not
|
|
1133
|
+
// decided again, which could now name another output than the one whose
|
|
1134
|
+
// bytes the player already holds for it.
|
|
1135
|
+
// A step is asked for BY somebody; see resolveVariantSession.
|
|
1136
|
+
if (!consumerId) {
|
|
1137
|
+
return { sessionId: null };
|
|
1138
|
+
}
|
|
1139
|
+
const segmentIndex = isSegment ? base.segmentFormat.segmentIndexFromName(fileName) : -1;
|
|
1140
|
+
const generation = this.#host.generationOfRequest(consumerId, statedGeneration);
|
|
1141
|
+
const given = this.#givenBefore(base, consumerId, generation, height, segmentIndex);
|
|
1142
|
+
if (given.state === "live") {
|
|
1143
|
+
this.#host.outputs.touch(given.output);
|
|
1144
|
+
this.#host.placeViewerOn(given.output, consumerId, this.#host.viewerPositionOf(baseSessionId, consumerId));
|
|
1145
|
+
return { sessionId: given.output.id };
|
|
1146
|
+
}
|
|
1147
|
+
if (given.state === "gone") {
|
|
1148
|
+
return this.#answerForLostAddress(base, height, fileName, segmentIndex, consumerId, generation, given.key);
|
|
1149
|
+
}
|
|
1150
|
+
let variant;
|
|
1151
|
+
try {
|
|
1152
|
+
variant = await this.resolveVariantSession(
|
|
1153
|
+
baseSessionId,
|
|
1154
|
+
height,
|
|
1155
|
+
segmentIndex,
|
|
1156
|
+
// WHOSE REQUEST THIS IS. Whether an output already here may serve a
|
|
1157
|
+
// height depends on the asking viewer's mode, and this path dropped the
|
|
1158
|
+
// name: the request was then taken as a size picked by hand, which is
|
|
1159
|
+
// served exactly and reuses nothing. Whichever of the two paths creates
|
|
1160
|
+
// the step first decides for both, so an automatic viewer whose player
|
|
1161
|
+
// fetched before the warm-up finished got a second encoder where an
|
|
1162
|
+
// output already here would have served them.
|
|
1163
|
+
consumerId
|
|
1164
|
+
);
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (error?.code === OUTPUT_UNAVAILABLE) {
|
|
1167
|
+
return { sessionId: null, unavailable: error.details };
|
|
1168
|
+
}
|
|
1169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1170
|
+
this.#host.logger.error(
|
|
1171
|
+
`transcode ${baseSessionId} could not prepare the ${height}p variant: ${message}` +
|
|
1172
|
+
(error instanceof Error && error.stack ? `\n${error.stack}` : "")
|
|
1173
|
+
);
|
|
1174
|
+
return { sessionId: null, error: message };
|
|
1175
|
+
}
|
|
1176
|
+
if (!variant) {
|
|
1177
|
+
return { sessionId: null };
|
|
1178
|
+
}
|
|
1179
|
+
if (consumerId) {
|
|
1180
|
+
// The same circle as a soundtrack's: the init has to be made before a
|
|
1181
|
+
// segment can be asked for, and nothing is made for an output nobody is
|
|
1182
|
+
// watching. Asking for any of its files is watching it.
|
|
1183
|
+
this.#host.placeViewerOn(variant, consumerId, this.#host.viewerPositionOf(baseSessionId, consumerId));
|
|
1184
|
+
// Recorded AFTER placing them: a viewer this request is the first sign of
|
|
1185
|
+
// exists only from that moment, and a record kept for nobody is lost.
|
|
1186
|
+
this.#host.noteGivenOutput(consumerId, generation, height, segmentIndex, variant.outputKey ?? "");
|
|
1187
|
+
}
|
|
1188
|
+
// WHICH RUNG IS ON THE SCREEN IS NOT READ OFF THIS REQUEST. The page says
|
|
1189
|
+
// so itself (`viewerPlays`), the moment the player has switched: a request
|
|
1190
|
+
// for a rung's segment is the player fetching, and it used to be taken as
|
|
1191
|
+
// the person having moved.
|
|
1192
|
+
return { sessionId: variant.id };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Prepare an audio track at a position, so a change of track is instant.
|
|
1197
|
+
*
|
|
1198
|
+
* The player, told to change track, discards the audio it holds and cannot
|
|
1199
|
+
* show a frame until the new track covers the playhead — so switching first
|
|
1200
|
+
* and producing second puts the whole of the track's cold start on screen as
|
|
1201
|
+
* a spinner. Measured 2026-08-15: the picture stopped for as long as the
|
|
1202
|
+
* first piece took. Prepared first, the player finds the bytes already there.
|
|
1203
|
+
*
|
|
1204
|
+
* The same shape as {@link prepareVariant}, and for the same reason.
|
|
1205
|
+
*
|
|
1206
|
+
* @param {string} baseSessionId
|
|
1207
|
+
* @param {number} trackIndex
|
|
1208
|
+
* @param {number} positionSeconds
|
|
1209
|
+
* @returns {Promise<{ sessionId: string, fileName: string } | null>}
|
|
1210
|
+
*/
|
|
1211
|
+
async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds, consumerId) {
|
|
1212
|
+
const base = this.#host.outputs.get(baseSessionId);
|
|
1213
|
+
// A track is prepared FOR somebody; a request naming nobody prepares nothing.
|
|
1214
|
+
if (!consumerId || !base || !this.servesAudioSeparately(base)) {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
if (!this.#audioRenditionsOf(base).some((track) => track.trackIndex === trackIndex)) {
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
const rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
|
|
1221
|
+
if (!rendition) {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
// A track prepared for a change the viewer did not make would otherwise
|
|
1225
|
+
// encode for nobody until its own idle timer noticed — the same trap
|
|
1226
|
+
// warming a quality rung has, and the same answer. Kept per viewer, because
|
|
1227
|
+
// one viewer's abandoned preparation must not stop a track another viewer
|
|
1228
|
+
// is listening to.
|
|
1229
|
+
const stillWarming = this.#host.audioBeingWarmedOf(base, consumerId);
|
|
1230
|
+
if (stillWarming && stillWarming !== rendition.id) {
|
|
1231
|
+
const abandoned = this.#host.outputs.get(stillWarming);
|
|
1232
|
+
const wanted = this.#liveAudioRenditionKeys(base);
|
|
1233
|
+
const wantedIds = new Set(
|
|
1234
|
+
this.#host.outputs.renditionsOf(base)
|
|
1235
|
+
.filter((other) => wanted.has(audioRenditionKey(
|
|
1236
|
+
this.#flatAudioTrackOf(other),
|
|
1237
|
+
other.spec.transcodesAudio
|
|
1238
|
+
)))
|
|
1239
|
+
.map((other) => other.id)
|
|
1240
|
+
);
|
|
1241
|
+
if (abandoned && !wantedIds.has(abandoned.id)) {
|
|
1242
|
+
// They are not listening to it, so they stop watching it. Its encoder
|
|
1243
|
+
// follows from that and is not commanded here: nobody left on an output
|
|
1244
|
+
// is a map with nothing in it, and the plan stops what is on it.
|
|
1245
|
+
this.#host.viewerLeaves(abandoned, consumerId);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
this.#host.noteAudioBeingWarmed(base, consumerId, rendition.id);
|
|
1249
|
+
// Being prepared for them is watching it: it is made for this viewer, and
|
|
1250
|
+
// when they leave it must be let go with everything else of theirs.
|
|
1251
|
+
// Where the switch will land. An existing track was left wherever the
|
|
1252
|
+
// viewer last was on it; saying where they are now is the whole of pointing
|
|
1253
|
+
// it there, because the encoder follows the person and not the request.
|
|
1254
|
+
this.#host.placeOn(rendition, consumerId, positionSeconds);
|
|
1255
|
+
this.#host.planEncodersSoon();
|
|
1256
|
+
const index = this.#host.outputTimes.segmentIndexForTime(rendition, positionSeconds);
|
|
1257
|
+
return { sessionId: rendition.id, fileName: rendition.segmentFormat.segmentFileName(index) };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async prepareVariant(baseSessionId, height, positionSeconds, consumerId) {
|
|
1261
|
+
// A step is warmed FOR somebody; a request naming nobody warms nothing.
|
|
1262
|
+
if (!consumerId || !isOutputName(baseSessionId)) {
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
const base = this.#host.outputs.get(baseSessionId);
|
|
1266
|
+
if (!base) {
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
if (!this.#host.qualityOffer.offeredHeightsFor(base).includes(height)) {
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
const index = this.#host.outputTimes.segmentIndexForTime(base, positionSeconds);
|
|
1273
|
+
const variant = await this.resolveVariantSession(baseSessionId, height, index, consumerId);
|
|
1274
|
+
if (!variant) {
|
|
1275
|
+
return null;
|
|
1276
|
+
}
|
|
1277
|
+
// A rung warmed for a switch that was never made. Nothing else would ever
|
|
1278
|
+
// stop it: only becoming active stops the rung being left, so a viewer
|
|
1279
|
+
// trying two rungs in a row would leave the first encoding for nobody until
|
|
1280
|
+
// the look-ahead cap suspended it — three encoders at once on a host sized
|
|
1281
|
+
// for one, which is the opposite of what warming is for.
|
|
1282
|
+
// Kept per viewer, and stopped only if nobody has it on screen: with two
|
|
1283
|
+
// viewers, what one of them abandons may be what the other is watching.
|
|
1284
|
+
const stillWarming = this.#host.stepBeingWarmedOf(base, consumerId);
|
|
1285
|
+
if (stillWarming && stillWarming !== variant.id) {
|
|
1286
|
+
const abandoned = this.#host.outputs.get(stillWarming);
|
|
1287
|
+
if (abandoned && !this.#host.quality.variantsOnScreen(base).has(abandoned.id)) {
|
|
1288
|
+
this.#host.viewerLeaves(abandoned, consumerId);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
// The base is not a rung being prepared for anybody — it is what the family
|
|
1292
|
+
// is named by — so warming its own height leaves nothing outstanding.
|
|
1293
|
+
if (variant.id === base.id) {
|
|
1294
|
+
this.#host.noteStepBeingWarmed(base, consumerId, null);
|
|
1295
|
+
} else {
|
|
1296
|
+
const onScreen = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
|
|
1297
|
+
if (variant.id !== onScreen.id) {
|
|
1298
|
+
// A PLACE ON THIS MACHINE (roadmap item 97, step 13). The step warmed
|
|
1299
|
+
// before this one stops holding its place first — the viewer asked for
|
|
1300
|
+
// another rung, so it is no longer theirs to wait for — and then the
|
|
1301
|
+
// place is asked for, in the same synchronous stretch as the record is
|
|
1302
|
+
// written. Refused, nothing is recorded: the rung on screen goes on
|
|
1303
|
+
// playing and the page is told why.
|
|
1304
|
+
this.#host.noteStepBeingWarmed(base, consumerId, null);
|
|
1305
|
+
const place = this.#host.admitsPreparation(variant);
|
|
1306
|
+
if (!place.admitted) {
|
|
1307
|
+
this.#host.logger.info(
|
|
1308
|
+
`transcode ${base.id} warming ${height}p for ${consumerId} refused a place: ${place.reason}`
|
|
1309
|
+
);
|
|
1310
|
+
const error = new Error(`No place on this machine for ${height}p: ${place.reason}.`);
|
|
1311
|
+
error.code = OUTPUT_UNAVAILABLE;
|
|
1312
|
+
error.details = {
|
|
1313
|
+
reason: `this machine cannot encode ${height}p beside what it already encodes`,
|
|
1314
|
+
figures: { machineSpeedX: place.speedX, height }
|
|
1315
|
+
};
|
|
1316
|
+
throw error;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
this.#host.noteStepBeingWarmed(base, consumerId, variant.id);
|
|
1320
|
+
}
|
|
1321
|
+
// An existing rung may be parked wherever it was left, so it is pointed at
|
|
1322
|
+
// the switch position exactly as an activation would — the difference is
|
|
1323
|
+
// only that the rung on screen keeps its own encoder meanwhile.
|
|
1324
|
+
this.#host.outputs.touch(variant);
|
|
1325
|
+
// Anything that is not the rung on screen has to be pointed at the switch
|
|
1326
|
+
// position — INCLUDING the base. Skipping it because it is the base was a
|
|
1327
|
+
// defect: the base is parked wherever it was when the viewer left it, and
|
|
1328
|
+
// its encoder was stopped then. Measured 2026-08-12, warming 400p at
|
|
1329
|
+
// 6506.5s found the base still at `run from #0`, so the segment the switch
|
|
1330
|
+
// needed was never produced and the viewer got nothing at all.
|
|
1331
|
+
// A rung that is not on their screen is parked where they last left it, so
|
|
1332
|
+
// being warmed begins with saying where they are. Their being ON it is what
|
|
1333
|
+
// buys it an encoder, and both halves are said here: a warmed rung is one
|
|
1334
|
+
// this person is watching for as long as the warm-up lasts, which is why
|
|
1335
|
+
// two encoders run through it.
|
|
1336
|
+
if (variant.id !== this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }).id) {
|
|
1337
|
+
this.#host.placeOn(variant, consumerId, this.#host.outputTimes.segmentStartTime(base, index));
|
|
1338
|
+
this.#host.planEncodersSoon();
|
|
1339
|
+
}
|
|
1340
|
+
this.#host.logger.info(
|
|
1341
|
+
`transcode ${base.id} warming ${height}p at ${positionSeconds.toFixed(1)}s (segment #${index})`
|
|
1342
|
+
);
|
|
1343
|
+
return { sessionId: variant.id, fileName: variant.segmentFormat.segmentFileName(index) };
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* The rung this viewer's player is playing, as the page states it.
|
|
1348
|
+
*
|
|
1349
|
+
* The page says so when the player has switched (`LEVEL_SWITCHED`), with the
|
|
1350
|
+
* height of that rung and where the picture is. A rung this proxy has not
|
|
1351
|
+
* made is not one the player can be playing, so a height with no output
|
|
1352
|
+
* behind it changes nothing.
|
|
1353
|
+
*
|
|
1354
|
+
* @param {string} baseSessionId - The output the browser addresses.
|
|
1355
|
+
* @param {string} consumerId
|
|
1356
|
+
* @param {number} height
|
|
1357
|
+
* @param {number} [positionSeconds] - Where the picture is, when stated.
|
|
1358
|
+
* @returns {boolean} Whether a rung was found for that height.
|
|
1359
|
+
*/
|
|
1360
|
+
viewerPlays(baseSessionId, consumerId, height, positionSeconds) {
|
|
1361
|
+
const named = this.#host.outputs.get(baseSessionId);
|
|
1362
|
+
if (!consumerId || !named || !(height > 0)) {
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
const base = this.#host.outputs.pictureOf(named);
|
|
1366
|
+
// THE CHOICE ALREADY MADE for this viewer at that height, read and never
|
|
1367
|
+
// made here: the page names a height, and a height does not say which of
|
|
1368
|
+
// two limits the viewer was given.
|
|
1369
|
+
const rung = this.servingOutputFor(base, height, consumerId);
|
|
1370
|
+
if (!rung) {
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
this.#noteVariantActive(base, rung, positionSeconds, consumerId);
|
|
1374
|
+
return true;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Record which variant the viewer is watching, and give it the encoder.
|
|
1379
|
+
*
|
|
1380
|
+
* The person is put on the new rung where their picture is, because a
|
|
1381
|
+
* segment request steers no encoder and a rung that was watched a minute ago
|
|
1382
|
+
* is parked wherever it was left.
|
|
1383
|
+
*
|
|
1384
|
+
* @param {HlsSession} base
|
|
1385
|
+
* @param {HlsSession} variant
|
|
1386
|
+
* @param {number} [positionSeconds] - Where the picture is, as the page
|
|
1387
|
+
* stated it with the switch.
|
|
1388
|
+
* @param {string} [consumerId] - Which viewer moved.
|
|
1389
|
+
* @returns {void}
|
|
1390
|
+
*/
|
|
1391
|
+
#noteVariantActive(base, variant, positionSeconds, consumerId) {
|
|
1392
|
+
// Which step is on a screen is a fact about a PERSON: a request that names
|
|
1393
|
+
// nobody, or names the family itself, says nothing about any screen.
|
|
1394
|
+
if (!consumerId) {
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const previous = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
|
|
1398
|
+
if (previous.id === variant.id) {
|
|
1399
|
+
// The rung on screen asking for more of itself, which it does every few
|
|
1400
|
+
// seconds. Nothing is being decided here — and deciding anything was the
|
|
1401
|
+
// defect: the warm-up was cancelled by the next segment the CURRENT rung
|
|
1402
|
+
// fetched, measured 2026-08-12 at 117 ms and 1.5 s after two warm-ups
|
|
1403
|
+
// began, so the rung being prepared was stopped before it had encoded
|
|
1404
|
+
// anything and the viewer waited out the full thirty-second warm-up for a
|
|
1405
|
+
// segment nobody was making, then waited again for the switch itself.
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
// Their player has moved to another rung, so a move between limits of the
|
|
1409
|
+
// one they were on is for a height they have left.
|
|
1410
|
+
this.#cancelSameHeightSwitch(base, consumerId, "the player moved to another rung");
|
|
1411
|
+
// A rung is being left, so whatever was warmed is decided: either it is the
|
|
1412
|
+
// rung now being switched to, or the viewer went somewhere else and it must
|
|
1413
|
+
// stop like any other rung nobody is watching. Nothing else would ever stop
|
|
1414
|
+
// it — only the rung being LEFT is stopped below.
|
|
1415
|
+
const warmed = this.#host.stepBeingWarmedOf(base, consumerId);
|
|
1416
|
+
const warmedSince = this.#host.stepBeingWarmedSinceOf?.(base, consumerId) ?? null;
|
|
1417
|
+
this.#host.noteStepBeingWarmed(base, consumerId, null);
|
|
1418
|
+
// The step warmed for this viewer is the one they moved onto: how long it
|
|
1419
|
+
// took, and what they held when they moved.
|
|
1420
|
+
if (warmed === variant.id && Number.isFinite(warmedSince)) {
|
|
1421
|
+
this.#host.notePreparation?.(variant, (Date.now() - warmedSince) / 1000, this.#host.bufferedSecondsOf(consumerId));
|
|
1422
|
+
}
|
|
1423
|
+
if (warmed && warmed !== variant.id && warmed !== previous.id) {
|
|
1424
|
+
const abandoned = this.#host.outputs.get(warmed);
|
|
1425
|
+
if (abandoned && !this.#host.quality.variantsOnScreen(base).has(abandoned.id)) {
|
|
1426
|
+
this.#host.viewerLeaves(abandoned, consumerId);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
const position = this.#variantStartSeconds(base, positionSeconds, consumerId);
|
|
1430
|
+
this.#host.noteStepOnScreen(base, consumerId, variant.id);
|
|
1431
|
+
// The step is an output of this viewer's now.
|
|
1432
|
+
this.#host.watches(variant, consumerId);
|
|
1433
|
+
// And the one they came off is not — unless it is the picture itself, which
|
|
1434
|
+
// they never stop watching: the browser addresses the picture, their chosen
|
|
1435
|
+
// soundtrack is recorded on it, and the plan reads their position from it.
|
|
1436
|
+
// Leaving it deleted their whole record, so a viewer who went down a step,
|
|
1437
|
+
// back to the picture's own height and down again lost the soundtrack they
|
|
1438
|
+
// had chosen, and the encoder making it was stopped as unwanted.
|
|
1439
|
+
if (previous !== base && previous !== variant) {
|
|
1440
|
+
this.#host.viewerLeaves(previous, consumerId);
|
|
1441
|
+
}
|
|
1442
|
+
this.#host.logger.info(
|
|
1443
|
+
`transcode ${base.id} variant now ${this.#host.outputs.variantHeightOf(variant)}p ` +
|
|
1444
|
+
`(was ${this.#host.outputs.variantHeightOf(previous)}p) at ${position.toFixed(1)}s` +
|
|
1445
|
+
(consumerId ? ` for ${consumerId}` : "")
|
|
1446
|
+
);
|
|
1447
|
+
// Requests still held on the rung they came off are for segments nobody
|
|
1448
|
+
// will produce now, and the player stopped waiting for them the moment it
|
|
1449
|
+
// switched. Answering "retry" at once frees them instead of holding each for
|
|
1450
|
+
// the full minute.
|
|
1451
|
+
//
|
|
1452
|
+
// WHETHER ITS ENCODER GOES ON IS NOT DECIDED HERE. It used to be stopped
|
|
1453
|
+
// from this line whenever no viewer had it on screen — the plan, handed the
|
|
1454
|
+
// whole film's priority map, wanted an encoder on every output of it and
|
|
1455
|
+
// started one again on the very next pass, which this viewer's own move had
|
|
1456
|
+
// just triggered. Each output is handed its own map now, so a rung nobody is
|
|
1457
|
+
// on has nothing in it and the plan stops what is on it, once.
|
|
1458
|
+
if (!this.#host.quality.variantsOnScreen(base).has(previous.id)) {
|
|
1459
|
+
this.#host.invalidateWaits(previous);
|
|
1460
|
+
}
|
|
1461
|
+
if (position > 0) {
|
|
1462
|
+
// The rung being switched TO, named literally: a warm-up may have left
|
|
1463
|
+
// the family pointing elsewhere, and forwarding would move that one
|
|
1464
|
+
// instead. Saying where this person is on it is the whole of pointing its
|
|
1465
|
+
// encoder there.
|
|
1466
|
+
this.#host.placeOn(variant, consumerId, position);
|
|
1467
|
+
}
|
|
1468
|
+
this.#host.planEncodersSoon();
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* The master playlist: every resolution this file can be served at, as HLS
|
|
1473
|
+
* variants.
|
|
1474
|
+
*
|
|
1475
|
+
* This is what makes a change of quality seamless. Our media playlist is VOD
|
|
1476
|
+
* and terminated with `#EXT-X-ENDLIST`, and hls.js only re-reads a playlist
|
|
1477
|
+
* that is live — so rewriting it underneath the player achieves nothing, and
|
|
1478
|
+
* a switch had to tear the player down and build a new session. Offered as
|
|
1479
|
+
* variants instead, the switch is the player's own: it fetches the other
|
|
1480
|
+
* variant, appends it after what is already buffered, and changes the
|
|
1481
|
+
* decoder's type if the codec parameters differ.
|
|
1482
|
+
*
|
|
1483
|
+
* Offered only where the variants can actually be joined, which is a question
|
|
1484
|
+
* about the CUT GRID and not about who produces the frames:
|
|
1485
|
+
*
|
|
1486
|
+
* - a re-encoded session on the uniform grid — its variants are re-encoded on
|
|
1487
|
+
* the same one, keyframes forced onto it;
|
|
1488
|
+
* - a session cut at the source's own keyframes — a copy, which has no other
|
|
1489
|
+
* choice — where the variants are re-encoded and forced onto those very
|
|
1490
|
+
* times, so a rung's segment covers the same span as the copy's.
|
|
1491
|
+
*
|
|
1492
|
+
* What is refused is a session whose own grid is a fiction: a copy with no
|
|
1493
|
+
* readable keyframe index falls back to an even grid that ffmpeg then does
|
|
1494
|
+
* not cut on, and nothing can be aligned to that.
|
|
1495
|
+
*
|
|
1496
|
+
* @param {string} sessionId
|
|
1497
|
+
* @returns {string | null} The playlist text, or null when there is nothing
|
|
1498
|
+
* to choose between, or nothing to align to.
|
|
1499
|
+
*/
|
|
1500
|
+
buildMasterPlaylist(sessionId, consumerId = "") {
|
|
1501
|
+
if (!isOutputName(sessionId)) {
|
|
1502
|
+
return null;
|
|
1503
|
+
}
|
|
1504
|
+
const session = this.#host.outputs.get(sessionId);
|
|
1505
|
+
if (!session) {
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
if (!this.#host.outputs.publishesVariants(session)) {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
// The audio tracks, published once for the whole file rather than muxed
|
|
1512
|
+
// into every rung. Two things follow from that: the same track is not
|
|
1513
|
+
// encoded once per rung on a host that struggles to encode it once, and
|
|
1514
|
+
// changing track becomes the player switching rendition instead of this
|
|
1515
|
+
// proxy rebuilding the session with another `audioTrackIndex`.
|
|
1516
|
+
//
|
|
1517
|
+
// Only for a session that asked for them. A browser that does not know
|
|
1518
|
+
// about renditions is served audio in its stream, as before, and gets no
|
|
1519
|
+
// `#EXT-X-MEDIA` lines to be confused by.
|
|
1520
|
+
const renditions = this.servesAudioSeparately(session)
|
|
1521
|
+
// Which track is marked DEFAULT is the ASKING viewer's business: one
|
|
1522
|
+
// picture is shared by everyone watching it, and each of them may have
|
|
1523
|
+
// chosen a different language. A default written from the session's own
|
|
1524
|
+
// field would start the second viewer in the first viewer's language.
|
|
1525
|
+
? this.#audioRenditionsOf(session, this.#audioChoiceOf(session, consumerId).trackIndex)
|
|
1526
|
+
: [];
|
|
1527
|
+
return masterPlaylistText({
|
|
1528
|
+
// The shape of the film and the rates it carries, asked of the layer that
|
|
1529
|
+
// holds both. What CAN be spliced, not what is worth offering this second:
|
|
1530
|
+
// the live judgement travels in `offeredHeights` and in every progress
|
|
1531
|
+
// report, and letting it decide the master's existence made a live session
|
|
1532
|
+
// answer 404 to its own published address.
|
|
1533
|
+
...this.#host.outputs.masterFactsOf(session),
|
|
1534
|
+
renditions,
|
|
1535
|
+
playlistFileName: PLAYLIST_FILE_NAME
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Whether this session's audio is published separately rather than muxed into
|
|
1541
|
+
* its picture.
|
|
1542
|
+
*
|
|
1543
|
+
* Two things have to hold, and the second is why this is asked here rather
|
|
1544
|
+
* than settled when the session was made. The browser must understand
|
|
1545
|
+
* renditions — it says so when it creates the session, and one that does not
|
|
1546
|
+
* has to be sent audio in the stream. AND there must be a master playlist to
|
|
1547
|
+
* publish them in: a stream served as a single media playlist has nowhere to
|
|
1548
|
+
* carry an `#EXT-X-MEDIA` line, so taking the audio out of it would leave the
|
|
1549
|
+
* viewer with a picture and silence.
|
|
1550
|
+
*
|
|
1551
|
+
* @param {HlsSession} session
|
|
1552
|
+
* @returns {boolean}
|
|
1553
|
+
*/
|
|
1554
|
+
servesAudioSeparately(session) {
|
|
1555
|
+
return session.spec.carries !== "audio-only" && session.spec.carriesAudioSeparately;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
inputOf(session) {
|
|
1559
|
+
return encoderInputs({
|
|
1560
|
+
picture: session.file,
|
|
1561
|
+
soundtrack: session.spec.audio
|
|
1562
|
+
? this.#host.sourceFiles.get(session.file.sourceKey, session.spec.audioFileIndex)
|
|
1563
|
+
: session.file,
|
|
1564
|
+
carries: session.spec.carries,
|
|
1565
|
+
audioSeparate: this.servesAudioSeparately(session),
|
|
1566
|
+
sessionId: session.id,
|
|
1567
|
+
readWindowBytes: this.#host.encodeRuns.readWindowFor(session),
|
|
1568
|
+
baseUrl: this.#host.localBaseUrl
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* One file of an audio rendition: its playlist, its init segment or one of
|
|
1574
|
+
* its segments.
|
|
1575
|
+
*
|
|
1576
|
+
* A rendition is an ordinary session underneath — same source, same file,
|
|
1577
|
+
* same cut grid, one audio track and no picture — created on the first
|
|
1578
|
+
* request for it, exactly as a quality variant is. What differs is that the
|
|
1579
|
+
* player fetches it ALONGSIDE a variant rather than instead of one, so both
|
|
1580
|
+
* encoders run: a rung and the audio it is played with.
|
|
1581
|
+
*
|
|
1582
|
+
* @param {string} baseSessionId
|
|
1583
|
+
* @param {number} trackIndex
|
|
1584
|
+
* @param {string} fileName
|
|
1585
|
+
* @param {string} [consumerId] - Who is asking. One picture serves everyone
|
|
1586
|
+
* watching it, and which soundtrack they are listening to is theirs alone.
|
|
1587
|
+
* @returns {Promise<{ sessionId: string | null, error?: string }>}
|
|
1588
|
+
*/
|
|
1589
|
+
async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName, consumerId = "") {
|
|
1590
|
+
if (!isOutputName(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
1591
|
+
return { sessionId: null };
|
|
1592
|
+
}
|
|
1593
|
+
const base = this.#host.outputs.get(baseSessionId);
|
|
1594
|
+
if (!base || !this.servesAudioSeparately(base)) {
|
|
1595
|
+
return { sessionId: null };
|
|
1596
|
+
}
|
|
1597
|
+
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
1598
|
+
const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
|
|
1599
|
+
const isSegment = base.segmentFormat.isSegmentFileName(fileName);
|
|
1600
|
+
if (!isPlaylist && !isInit && !isSegment) {
|
|
1601
|
+
return { sessionId: null };
|
|
1602
|
+
}
|
|
1603
|
+
if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
|
|
1604
|
+
return { sessionId: null };
|
|
1605
|
+
}
|
|
1606
|
+
// The playlist is answered from the base, for the same reason a variant's
|
|
1607
|
+
// is: every rendition of a file has the same boundaries and the same
|
|
1608
|
+
// duration — they are cut on one grid — and the player fetches the playlist
|
|
1609
|
+
// of tracks it may never select. Starting an encoder for each would put as
|
|
1610
|
+
// many encoders on the host as the file has languages.
|
|
1611
|
+
if (isPlaylist) {
|
|
1612
|
+
return { sessionId: base.id };
|
|
1613
|
+
}
|
|
1614
|
+
let rendition;
|
|
1615
|
+
try {
|
|
1616
|
+
rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1619
|
+
this.#host.logger.error(
|
|
1620
|
+
`transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
|
|
1621
|
+
(error instanceof Error && error.stack ? `\n${error.stack}` : "")
|
|
1622
|
+
);
|
|
1623
|
+
return { sessionId: null, error: message };
|
|
1624
|
+
}
|
|
1625
|
+
if (rendition && consumerId) {
|
|
1626
|
+
// Asking for ANY file of this soundtrack is this viewer watching it, and
|
|
1627
|
+
// the init is the file they ask for first. Registered here rather than on
|
|
1628
|
+
// the segment alone, because the segment cannot be asked for until the
|
|
1629
|
+
// init has been served, and the init cannot be made unless somebody is
|
|
1630
|
+
// watching: that circle is what left a soundtrack with no encoder, no
|
|
1631
|
+
// init and a viewer waiting sixty seconds on 2026-09-05.
|
|
1632
|
+
//
|
|
1633
|
+
// WHERE they are on it is where they are on the picture: the two are
|
|
1634
|
+
// played together.
|
|
1635
|
+
this.#host.placeViewerOn(rendition, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
|
|
1636
|
+
}
|
|
1637
|
+
if (isSegment && rendition) {
|
|
1638
|
+
this.#noteAudioTrackActive(base, trackIndex, consumerId);
|
|
1639
|
+
}
|
|
1640
|
+
return { sessionId: rendition?.id ?? null };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* A SEGMENT of this track is what says the viewer is listening to it — the
|
|
1645
|
+
* player fetches the playlist and the init of tracks it may never choose.
|
|
1646
|
+
*
|
|
1647
|
+
* Every other track is then stopped. Each one is an ffmpeg process AND a
|
|
1648
|
+
* reader holding pieces of the torrent in memory, and the store can only
|
|
1649
|
+
* spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
|
|
1650
|
+
* track once had three readers on one file — picture, the track they chose
|
|
1651
|
+
* and the track they left — and at a seek all three revived their windows at
|
|
1652
|
+
* once, every resident piece was pinned, a read ended with zero bytes, and
|
|
1653
|
+
* every encoder took that for the end of the file and died. Playback was over
|
|
1654
|
+
* for good; the sessions answered 500 to everything after that.
|
|
1655
|
+
*
|
|
1656
|
+
* Stopped, not disposed: the track keeps its place, its grid and its
|
|
1657
|
+
* position, so switching back does not build it again — the same treatment a
|
|
1658
|
+
* quality rung gets when the viewer moves off it.
|
|
1659
|
+
*
|
|
1660
|
+
* "Every other track" is every track NO LIVE VIEWER is listening to, which
|
|
1661
|
+
* with one viewer is what it always was. It has to be asked that way now that
|
|
1662
|
+
* two viewers share one picture: each of them fetches the sound they chose,
|
|
1663
|
+
* and stopping "the others" per request would have them switch each other's
|
|
1664
|
+
* soundtrack off in turn, once per segment, for the whole film.
|
|
1665
|
+
*
|
|
1666
|
+
* @param {HlsSession} base
|
|
1667
|
+
* @param {number} trackIndex
|
|
1668
|
+
* @param {string} consumerId - Who is listening. A request that names
|
|
1669
|
+
* nobody changes nobody's track.
|
|
1670
|
+
*/
|
|
1671
|
+
#noteAudioTrackActive(base, trackIndex, consumerId) {
|
|
1672
|
+
if (!consumerId) {
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const previous = this.#audioChoiceOf(base, consumerId);
|
|
1676
|
+
if (previous.trackIndex === trackIndex) {
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
this.#host.chooseAudioTrack(base, consumerId, { ...previous, trackIndex });
|
|
1680
|
+
// Every soundtrack nobody present is listening to any more stops being
|
|
1681
|
+
// waited on.
|
|
1682
|
+
const wanted = this.#liveAudioRenditionKeys(base);
|
|
1683
|
+
for (const other of this.#host.outputs.renditionsOf(base)) {
|
|
1684
|
+
if (wanted.has(audioRenditionKey(this.#flatAudioTrackOf(other), other.spec.transcodesAudio))) {
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
// Requests held on it are for segments nobody will produce now, and the
|
|
1688
|
+
// player stopped waiting for them the moment it changed track.
|
|
1689
|
+
if (this.#host.encodeRuns.liveRunsOf(other).length > 0) {
|
|
1690
|
+
this.#host.invalidateWaits(other);
|
|
1691
|
+
}
|
|
1692
|
+
// Nobody is listening to it any more: this viewer stops watching that
|
|
1693
|
+
// output, on both sides of the relation, and the claim their listening
|
|
1694
|
+
// placed on it is released with them. Its encoder follows from that —
|
|
1695
|
+
// an output with nobody on it has a map with nothing in it — and was
|
|
1696
|
+
// additionally stopped from here, which is the same decision taken twice
|
|
1697
|
+
// by two parties with two rules.
|
|
1698
|
+
this.#host.viewerLeaves(other, consumerId);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* The viewers this family has heard from recently enough to still be watching.
|
|
1704
|
+
*
|
|
1705
|
+
* Asked of the whole family, not of one session: a viewer on a quality step
|
|
1706
|
+
* asks that step for its segments, so the picture they started on has not
|
|
1707
|
+
* heard from them since they switched. Their head expires by the same rule the
|
|
1708
|
+
* encoder's own steering uses.
|
|
1709
|
+
*
|
|
1710
|
+
* It is what decides whether an encoder is still wanted, and it is needed
|
|
1711
|
+
* because nothing releases a session when a channel closes (roadmap item 54)
|
|
1712
|
+
* — without it a viewer whose tab is gone would hold a soundtrack or a rung
|
|
1713
|
+
* for the session's whole life.
|
|
1714
|
+
*
|
|
1715
|
+
* @param {HlsSession} base
|
|
1716
|
+
* @returns {Set<string>}
|
|
1717
|
+
*/
|
|
1718
|
+
liveConsumers(base) {
|
|
1719
|
+
const live = new Set();
|
|
1720
|
+
for (const member of this.#host.outputs.familyOf(base)) {
|
|
1721
|
+
for (const consumerId of this.#host.presentOn(member)) {
|
|
1722
|
+
live.add(consumerId);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return live;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* What one viewer wants of the sound: which soundtrack, and whether their
|
|
1730
|
+
* browser needs it re-encoded.
|
|
1731
|
+
*
|
|
1732
|
+
* @param {HlsSession} base
|
|
1733
|
+
* @param {string} consumerId
|
|
1734
|
+
* @returns {{ trackIndex: number, transcode: boolean }}
|
|
1735
|
+
*/
|
|
1736
|
+
#audioChoiceOf(base, consumerId) {
|
|
1737
|
+
const stated = this.#host.audioChoiceOf(base, consumerId);
|
|
1738
|
+
if (stated) {
|
|
1739
|
+
return stated;
|
|
1740
|
+
}
|
|
1741
|
+
// A viewer this session has not heard from by name. The session's own
|
|
1742
|
+
// parameters are the honest fallback: they are what the request that
|
|
1743
|
+
// created it asked for.
|
|
1744
|
+
return {
|
|
1745
|
+
trackIndex: this.#flatAudioTrackOf(base),
|
|
1746
|
+
transcode: base.spec.transcodesAudio
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* The renditions live viewers are listening to, as the keys they are filed
|
|
1752
|
+
* under.
|
|
1753
|
+
*
|
|
1754
|
+
* A viewer counts while their head is fresh on the picture — the same
|
|
1755
|
+
* expiry the encoder's own steering uses. Without that test a viewer whose
|
|
1756
|
+
* tab was closed without releasing the session (roadmap item 54) would hold
|
|
1757
|
+
* an encoder for the session's whole life.
|
|
1758
|
+
*
|
|
1759
|
+
* @param {HlsSession} base
|
|
1760
|
+
* @returns {Set<string>}
|
|
1761
|
+
*/
|
|
1762
|
+
#liveAudioRenditionKeys(base) {
|
|
1763
|
+
const wanted = new Set();
|
|
1764
|
+
const live = this.liveConsumers(base);
|
|
1765
|
+
for (const consumerId of this.#host.consumersOn(base)) {
|
|
1766
|
+
const choice = this.#audioChoiceOf(base, consumerId);
|
|
1767
|
+
// A viewer counts while some session of the family has heard from them.
|
|
1768
|
+
if (live.size > 0 && !live.has(consumerId)) {
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
wanted.add(audioRenditionKey(choice.trackIndex, choice.transcode));
|
|
1772
|
+
}
|
|
1773
|
+
return wanted;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* The session producing one audio track of this file, made on first request.
|
|
1778
|
+
*
|
|
1779
|
+
* Filed under the track AND how it has to be produced, because those are two
|
|
1780
|
+
* different encodes: a browser that can decode this track as it stands is
|
|
1781
|
+
* served a copy, and one that cannot is served AAC. The base cannot answer
|
|
1782
|
+
* for either of them now that it is shared — its own `transcodeAudio` is
|
|
1783
|
+
* whatever the first viewer's browser needed.
|
|
1784
|
+
*
|
|
1785
|
+
* @param {HlsSession} base
|
|
1786
|
+
* @param {number} trackIndex
|
|
1787
|
+
* @param {string} consumerId - Who is asking.
|
|
1788
|
+
* @returns {Promise<HlsSession | null>}
|
|
1789
|
+
*/
|
|
1790
|
+
async #resolveAudioRenditionSession(base, trackIndex, consumerId = "") {
|
|
1791
|
+
const transcodeAudio = this.#audioChoiceOf(base, consumerId).transcode;
|
|
1792
|
+
// Found by what it IS: a soundtrack of this file, this track, produced this
|
|
1793
|
+
// way. That is what a map from a rendition key to a session id said, at the
|
|
1794
|
+
// price of a link between two sessions' lifetimes — one that had to be
|
|
1795
|
+
// cleaned from the other side when either ended.
|
|
1796
|
+
const already = this.#host.outputs.renditionsOf(base).find(
|
|
1797
|
+
(other) =>
|
|
1798
|
+
this.#flatAudioTrackOf(other) === trackIndex &&
|
|
1799
|
+
other.spec.transcodesAudio === transcodeAudio
|
|
1800
|
+
);
|
|
1801
|
+
if (already) {
|
|
1802
|
+
this.#host.outputs.touch(already);
|
|
1803
|
+
return already;
|
|
1804
|
+
}
|
|
1805
|
+
const rendition = await this.#host.createOrGetSession({
|
|
1806
|
+
sourceKey: base.file.sourceKey,
|
|
1807
|
+
fileIndex: base.file.fileIndex,
|
|
1808
|
+
// No picture at all: the video flag says what to do with a video stream
|
|
1809
|
+
// this output does not carry.
|
|
1810
|
+
transcodeVideo: false,
|
|
1811
|
+
transcodeAudio,
|
|
1812
|
+
fileName: base.file.name,
|
|
1813
|
+
// Opened by nobody in particular, as a step is.
|
|
1814
|
+
consumerId: "",
|
|
1815
|
+
audioTrackIndex: trackIndex,
|
|
1816
|
+
audioOnly: true,
|
|
1817
|
+
// Where the viewer is, so the rendition starts with the picture rather
|
|
1818
|
+
// than at the beginning of the file. Read the same way a quality variant
|
|
1819
|
+
// reads it: the base's own field is only written by a seek or by a
|
|
1820
|
+
// segment IT served, so on a resume-from-position open it is still unset
|
|
1821
|
+
// while the player is asking for segment #537 — and the audio would begin
|
|
1822
|
+
// at zero and never catch up, since nothing treats a far request as a
|
|
1823
|
+
// seek. The accessor falls back to the last segment actually requested.
|
|
1824
|
+
// Where the PICTURE is, not where it has been read to.
|
|
1825
|
+
//
|
|
1826
|
+
// The position this class keeps is written by the segments a session
|
|
1827
|
+
// serves, so it is the READ head, and the viewer's picture sits behind it
|
|
1828
|
+
// by everything the player has buffered. Started at the read head, the
|
|
1829
|
+
// audio run begins AHEAD of the viewer, and every request they then make
|
|
1830
|
+
// is behind a run that only moves forward — field 2026-08-15, placed at
|
|
1831
|
+
// #16 while the player asked for #10, and the audio arrived only after
|
|
1832
|
+
// the encoder was dragged back.
|
|
1833
|
+
//
|
|
1834
|
+
// The distance is measured, not assumed: the browser reports how many
|
|
1835
|
+
// seconds it holds ahead of the picture with every link report, so the
|
|
1836
|
+
// playhead is one subtraction away. A stale report is no use — a viewer
|
|
1837
|
+
// who seeked since then is somewhere else entirely — so an old one is
|
|
1838
|
+
// ignored and the whole look-ahead is subtracted instead, which cannot
|
|
1839
|
+
// leave the run ahead of them.
|
|
1840
|
+
startPositionSeconds: this.#host.audioStartSecondsFor({
|
|
1841
|
+
family: this.#host.outputs.familyOf(base),
|
|
1842
|
+
openedAtSeconds: this.#host.encodeRuns.progressOf(base)?.startPositionSeconds,
|
|
1843
|
+
segmentSeconds: this.#host.segmentDurationSec
|
|
1844
|
+
}),
|
|
1845
|
+
segmentFormatId: base.segmentFormat.id,
|
|
1846
|
+
// Cut where the picture is cut. Two streams meant to be played together
|
|
1847
|
+
// have to be divided at the same times, and the grid is the base's — the
|
|
1848
|
+
// table as it stands now, corrections included. A base on the uniform
|
|
1849
|
+
// grid passes nothing: the rendition computes the same even grid from the
|
|
1850
|
+
// same duration.
|
|
1851
|
+
inheritedGrid: base.timeline.cutGrid === "keyframe"
|
|
1852
|
+
? {
|
|
1853
|
+
boundaries: base.timeline.boundaries,
|
|
1854
|
+
published: base.timeline.published
|
|
1855
|
+
}
|
|
1856
|
+
: null
|
|
1857
|
+
});
|
|
1858
|
+
return rendition ?? null;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* Whether a session created with these parameters publishes its sound as its
|
|
1863
|
+
* own stream rather than muxing it into the picture.
|
|
1864
|
+
*
|
|
1865
|
+
* Asked before the session exists, because the session's KEY depends on the
|
|
1866
|
+
* answer: an output that carries no sound must not be told apart by which
|
|
1867
|
+
* soundtrack was asked for, and an output that carries it must be.
|
|
1868
|
+
*
|
|
1869
|
+
* Three conditions, all of them facts about the request and the file rather
|
|
1870
|
+
* than about the machine's load, so the answer cannot move afterwards:
|
|
1871
|
+
*
|
|
1872
|
+
* 1. the browser said it understands rendition groups. One that did not must
|
|
1873
|
+
* be sent its sound inside the picture, or it gets silence;
|
|
1874
|
+
* 2. the file has soundtracks to publish;
|
|
1875
|
+
* 3. there is more than one height to move between, because renditions are
|
|
1876
|
+
* published in a master playlist and a stream served as a single media
|
|
1877
|
+
* playlist has nowhere to carry an `#EXT-X-MEDIA` line.
|
|
1878
|
+
*
|
|
1879
|
+
* Condition 3 is the reason a source too small for a ladder mixes its sound
|
|
1880
|
+
* in as it always did. It is asked of the same ladder the master's rung list
|
|
1881
|
+
* comes from; the realtime budget may still encode below the height named
|
|
1882
|
+
* here, and cannot change the count, because what it picks is a rung of that
|
|
1883
|
+
* same ladder.
|
|
1884
|
+
*
|
|
1885
|
+
* @param {{ sourceKey: string, fileIndex: number, audioRenditions: boolean, ownHeight: number }} params
|
|
1886
|
+
* @returns {boolean}
|
|
1887
|
+
*/
|
|
1888
|
+
audioTravelsSeparately({ sourceKey, fileIndex, audioRenditions, ownHeight }) {
|
|
1889
|
+
if (audioRenditions !== true) {
|
|
1890
|
+
return false;
|
|
1891
|
+
}
|
|
1892
|
+
const tracks = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
|
|
1893
|
+
if (!Array.isArray(tracks) || tracks.length === 0) {
|
|
1894
|
+
return false;
|
|
1895
|
+
}
|
|
1896
|
+
// The source's own height, from the probe the playback plan already ran.
|
|
1897
|
+
// Absent, this answers "mix it in" — which is what the later computation
|
|
1898
|
+
// answered too, since a session with no source height has an empty ladder.
|
|
1899
|
+
const sourceHeight = Math.round(Number(this.#host.getCachedMediaInfo?.({ sourceKey, fileIndex })?.height) || 0);
|
|
1900
|
+
const heights = new Set(variantHeightsFor(sourceHeight));
|
|
1901
|
+
if (Number.isInteger(ownHeight) && ownHeight > 0) {
|
|
1902
|
+
heights.add(ownHeight);
|
|
1903
|
+
}
|
|
1904
|
+
return heights.size >= 2;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Where one numbered soundtrack actually is: which file of the torrent, and
|
|
1909
|
+
* which `0:a:N` inside it.
|
|
1910
|
+
*
|
|
1911
|
+
* The number is flat across the picture's own tracks and every soundtrack
|
|
1912
|
+
* shipped as a file beside it, so that the browser's menu, the
|
|
1913
|
+
* `audioTrackIndex` on a session request and the `a/<n>/` path a rendition is
|
|
1914
|
+
* published at all mean the same thing. This resolves it, once, from the
|
|
1915
|
+
* inventory the playback plan built — the very list the menu was drawn from,
|
|
1916
|
+
* so the two cannot disagree about what a number means.
|
|
1917
|
+
*
|
|
1918
|
+
* A number the inventory does not describe resolves to the picture's own file
|
|
1919
|
+
* at that index, which is exactly what every session did before soundtracks in
|
|
1920
|
+
* their own files existed: a plan cached by an older build carries no
|
|
1921
|
+
* inventory, and a session created against it must keep working.
|
|
1922
|
+
*
|
|
1923
|
+
* @param {string} sourceKey
|
|
1924
|
+
* @param {number} fileIndex - The PICTURE's file.
|
|
1925
|
+
* @param {number} flatIndex
|
|
1926
|
+
* @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string }}
|
|
1927
|
+
*/
|
|
1928
|
+
resolveAudioSource(sourceKey, fileIndex, flatIndex) {
|
|
1929
|
+
const inventory = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
|
|
1930
|
+
const entry = Array.isArray(inventory)
|
|
1931
|
+
? inventory.find((candidate) => candidate?.index === flatIndex)
|
|
1932
|
+
: null;
|
|
1933
|
+
if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
|
|
1934
|
+
return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "" };
|
|
1935
|
+
}
|
|
1936
|
+
return {
|
|
1937
|
+
fileIndex: entry.fileIndex,
|
|
1938
|
+
sourceTrackIndex: entry.sourceTrackIndex,
|
|
1939
|
+
isSidecar: entry.fileIndex !== fileIndex,
|
|
1940
|
+
name: typeof entry.fileName === "string" ? entry.fileName : "",
|
|
1941
|
+
// The rate the file states for this track, or null; what a viewer's link
|
|
1942
|
+
// is asked to carry for it when it is copied.
|
|
1943
|
+
bitrateKbps: Number.isFinite(entry.bitrateKbps) ? entry.bitrateKbps : null
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* The browser's flat soundtrack number for the audio carried by this output.
|
|
1949
|
+
*
|
|
1950
|
+
* `OutputSpec` keeps the stable source address: file plus `0:a:N`. The flat
|
|
1951
|
+
* number belongs to the browser menu and is reconstructed from that menu's
|
|
1952
|
+
* inventory when a route needs it. It is therefore not kept as a duplicate
|
|
1953
|
+
* field on the output.
|
|
1954
|
+
*
|
|
1955
|
+
* @param {HlsSession} session
|
|
1956
|
+
* @returns {number}
|
|
1957
|
+
*/
|
|
1958
|
+
#flatAudioTrackOf(session) {
|
|
1959
|
+
const audio = session.spec.audio;
|
|
1960
|
+
if (!audio) {
|
|
1961
|
+
return 0;
|
|
1962
|
+
}
|
|
1963
|
+
const tracks = this.#host.getCachedAudioTracks?.({
|
|
1964
|
+
sourceKey: session.file.sourceKey,
|
|
1965
|
+
fileIndex: session.file.fileIndex
|
|
1966
|
+
}) ?? [];
|
|
1967
|
+
const matching = Array.isArray(tracks)
|
|
1968
|
+
? tracks.find((entry) => entry?.fileIndex === audio.fileIndex && entry?.sourceTrackIndex === audio.trackIndex)
|
|
1969
|
+
: null;
|
|
1970
|
+
return Number.isInteger(matching?.index) ? matching.index : audio.trackIndex;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Start reading where a soundtrack file's own timeline begins, if nobody has.
|
|
1975
|
+
*
|
|
1976
|
+
* Read by the container layer from the file's own header — the same 64 KB,
|
|
1977
|
+
* the same reader and the same per-file cache the audio menu's track list
|
|
1978
|
+
* comes from. A container states this, so it is read from the container and
|
|
1979
|
+
* not measured from the media.
|
|
1980
|
+
*
|
|
1981
|
+
* The answer goes onto the FILE, which is where it belongs and which is what
|
|
1982
|
+
* removed the pair of maps this used to keep beside it: a start time held per
|
|
1983
|
+
* `sourceKey:fileIndex` is a fact of that file, and a second store of facts
|
|
1984
|
+
* about files is a second thing that can disagree. Every session of the
|
|
1985
|
+
* soundtrack shares the one object, so a reading that lands after a session
|
|
1986
|
+
* has started is seen by that session too — which is what the spawn path
|
|
1987
|
+
* needed and used to re-read a map for.
|
|
1988
|
+
*
|
|
1989
|
+
* Until 2.73.0 the session spawned an ffmpeg against the proxy's own
|
|
1990
|
+
* `/stream` for it and waited up to eight seconds for the banner. Field
|
|
1991
|
+
* 2026-09-03: that read cost 8121 ms of a cold start, three times out of
|
|
1992
|
+
* three, while the container layer had read the same header of the same file
|
|
1993
|
+
* in 8 ms in the same second. The eight seconds were not even spent on the
|
|
1994
|
+
* answer — the early exit was gated on a DURATION, and a partly downloaded
|
|
1995
|
+
* file prints `Duration: N/A` with the start time on that very line.
|
|
1996
|
+
*
|
|
1997
|
+
* Runs behind whoever asked, so no viewer waits for it. Once per file per
|
|
1998
|
+
* process: a container's start time is a property of the file and cannot
|
|
1999
|
+
* change. A reading that comes back without an answer is NOT remembered — the
|
|
2000
|
+
* file may simply not have been downloaded far enough yet, and the next
|
|
2001
|
+
* session asks again.
|
|
2002
|
+
*
|
|
2003
|
+
* @param {SourceFile} file - The soundtrack's own file.
|
|
2004
|
+
* @returns {void}
|
|
2005
|
+
*/
|
|
2006
|
+
warmFileStartTime(file) {
|
|
2007
|
+
if (typeof this.#host.getContainerMediaInfo !== "function") {
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (!(this.#host.fileStartTimeReads instanceof Set)) {
|
|
2011
|
+
this.#host.fileStartTimeReads = new Set();
|
|
2012
|
+
}
|
|
2013
|
+
if (file.media?.startTime !== undefined || this.#host.fileStartTimeReads.has(file.key)) {
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
this.#host.fileStartTimeReads.add(file.key);
|
|
2017
|
+
void Promise.resolve(
|
|
2018
|
+
this.#host.getContainerMediaInfo({ sourceKey: file.sourceKey, fileIndex: file.fileIndex })
|
|
2019
|
+
)
|
|
2020
|
+
.then((info) => {
|
|
2021
|
+
if (info && Number.isFinite(info.startTimeSeconds)) {
|
|
2022
|
+
file.learn({ startTime: info.startTimeSeconds });
|
|
2023
|
+
this.#host.logger.info(
|
|
2024
|
+
`transcode: soundtrack file ${file.fileIndex}'s own timeline starts at ` +
|
|
2025
|
+
`${info.startTimeSeconds.toFixed(6)}s, read from its header`
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
2028
|
+
})
|
|
2029
|
+
.catch((error) => {
|
|
2030
|
+
this.#host.logger.info(
|
|
2031
|
+
`transcode: the start of soundtrack file ${file.fileIndex}'s timeline could not be read ` +
|
|
2032
|
+
`(${error instanceof Error ? error.message : String(error)}) — the two timelines are ` +
|
|
2033
|
+
"taken to agree until it can be"
|
|
2034
|
+
);
|
|
2035
|
+
})
|
|
2036
|
+
.finally(() => {
|
|
2037
|
+
this.#host.fileStartTimeReads.delete(file.key);
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* The audio tracks of this session's file, as renditions for the master.
|
|
2043
|
+
*
|
|
2044
|
+
* Taken from the inventory the playback plan already probed — the same list
|
|
2045
|
+
* the browser's audio menu is built from — so nothing is probed again here.
|
|
2046
|
+
* The track the session was created with is the default one: it is what the
|
|
2047
|
+
* viewer chose (or the file's first track), and a master that defaulted to
|
|
2048
|
+
* something else would change the language on its own.
|
|
2049
|
+
*
|
|
2050
|
+
* @param {HlsSession} session
|
|
2051
|
+
* @param {number} [chosenTrack] - The track to mark as the default one.
|
|
2052
|
+
* Defaults to the session's own, which is what a caller that is only
|
|
2053
|
+
* counting the renditions wants.
|
|
2054
|
+
* @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
|
|
2055
|
+
*/
|
|
2056
|
+
#audioRenditionsOf(session, chosenTrack) {
|
|
2057
|
+
const tracks = this.#host.getCachedAudioTracks?.({
|
|
2058
|
+
sourceKey: session.file.sourceKey,
|
|
2059
|
+
// The PICTURE's file, which is what `file` is on every session of a
|
|
2060
|
+
// family — a rendition is created with its base's, and only its
|
|
2061
|
+
// `audioFile` points at the file its sound comes from. The inventory is
|
|
2062
|
+
// keyed on the picture and spans the soundtracks beside it.
|
|
2063
|
+
fileIndex: session.file.fileIndex
|
|
2064
|
+
}) ?? [];
|
|
2065
|
+
if (!Array.isArray(tracks) || tracks.length === 0) {
|
|
2066
|
+
return [];
|
|
2067
|
+
}
|
|
2068
|
+
const chosen = Number.isInteger(chosenTrack) ? chosenTrack : this.#flatAudioTrackOf(session);
|
|
2069
|
+
// One line per entry of the inventory, in its order and without omissions —
|
|
2070
|
+
// including a track the container marks unusable. The player addresses a
|
|
2071
|
+
// rendition by its POSITION in this list, and the browser addresses it by
|
|
2072
|
+
// the number the inventory gave it; leaving anything out would make those two
|
|
2073
|
+
// disagree from that point on. A track the file says not to offer is kept out
|
|
2074
|
+
// of the VIEWER's menu, which is the browser's own business and does not
|
|
2075
|
+
// touch the numbering.
|
|
2076
|
+
return tracks.map((entry, order) => {
|
|
2077
|
+
const index = Number.isInteger(entry?.index) ? entry.index : order;
|
|
2078
|
+
const language = typeof entry?.languageBcp47 === "string" && entry.languageBcp47.length > 0
|
|
2079
|
+
? entry.languageBcp47
|
|
2080
|
+
: (typeof entry?.language === "string" ? entry.language : "");
|
|
2081
|
+
return {
|
|
2082
|
+
trackIndex: index,
|
|
2083
|
+
name: this.#host.audioRenditionName(
|
|
2084
|
+
{ ...entry, index, folders: Array.isArray(entry?.folders) ? entry.folders : [] },
|
|
2085
|
+
tracks
|
|
2086
|
+
),
|
|
2087
|
+
// Only what the container itself states. What a folder name suggests
|
|
2088
|
+
// about a language is derived in the browser, where the language table
|
|
2089
|
+
// and the viewer's own locale already are; writing a guess into
|
|
2090
|
+
// `LANGUAGE` would put it in a playlist as though the file had said it.
|
|
2091
|
+
language,
|
|
2092
|
+
isDefault: index === chosen
|
|
2093
|
+
};
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* Where a person stands when they come onto a rung, in seconds.
|
|
2099
|
+
*
|
|
2100
|
+
* A stated position when there is one — the picture's, sent with the switch,
|
|
2101
|
+
* or the start of the segment the player asked a new rung for. Otherwise the
|
|
2102
|
+
* position they have stated on the rung they are on. Never the rung's READ
|
|
2103
|
+
* head, which sits a whole buffer further on.
|
|
2104
|
+
*
|
|
2105
|
+
* @param {HlsSession} base
|
|
2106
|
+
* @param {number} [positionSeconds] - A stated position, when there is one.
|
|
2107
|
+
* @param {string} [consumerId]
|
|
2108
|
+
* @returns {number}
|
|
2109
|
+
*/
|
|
2110
|
+
#variantStartSeconds(base, positionSeconds, consumerId = "") {
|
|
2111
|
+
if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
|
|
2112
|
+
return positionSeconds;
|
|
2113
|
+
}
|
|
2114
|
+
return this.#host.viewerSecondsOn(this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }));
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* The track set this session's output will carry — what the proxy DECLARES,
|
|
2119
|
+
* and the one answer both sides must agree on.
|
|
2120
|
+
*
|
|
2121
|
+
* The source may hold any number of tracks: several dubs, subtitles, even a
|
|
2122
|
+
* cover-art video stream. The output does not inherit that list — the command
|
|
2123
|
+
* maps at most one video and at most one audio, each optional, and subtitles
|
|
2124
|
+
* never enter the HLS output at all (they are served separately as WebVTT).
|
|
2125
|
+
* So this is not an inference about the file; it is the proxy stating what it
|
|
2126
|
+
* chose to produce.
|
|
2127
|
+
*
|
|
2128
|
+
* Used in two places, and that is the point: the init segment is checked
|
|
2129
|
+
* against it here, and it is sent to the browser so the browser can check
|
|
2130
|
+
* what it actually received against the same statement. Without the second
|
|
2131
|
+
* check a missing track is only noticed by its absence, minutes later, as a
|
|
2132
|
+
* black picture with working sound.
|
|
2133
|
+
*
|
|
2134
|
+
* @param {HlsSession} session
|
|
2135
|
+
* @returns {{ video: boolean, audio: boolean }}
|
|
2136
|
+
*/
|
|
2137
|
+
declaredTracks(session) {
|
|
2138
|
+
const probed = this.#host.getCachedMediaInfo?.({
|
|
2139
|
+
sourceKey: session.file.sourceKey,
|
|
2140
|
+
fileIndex: session.file.fileIndex
|
|
2141
|
+
}) ?? null;
|
|
2142
|
+
// What the SOURCE has, narrowed to what this session's output carries. A
|
|
2143
|
+
// rendition maps only audio and a stream whose audio travels separately
|
|
2144
|
+
// maps only video, so answering from the source alone would tell the
|
|
2145
|
+
// browser about a track that is not in the stream, and would leave
|
|
2146
|
+
// `#initFromFirstSegment` waiting for a second track that no init will ever
|
|
2147
|
+
// declare — its warning about a short header would then fire on every one.
|
|
2148
|
+
const carriesVideo = session.spec.carries !== "audio-only";
|
|
2149
|
+
const carriesAudio = !this.servesAudioSeparately(session);
|
|
2150
|
+
// The soundtrack of this session may not be in the file that was probed. A
|
|
2151
|
+
// release that ships its dub as a separate file often ships the picture with
|
|
2152
|
+
// no sound of its own at all, and then the picture's probe says there is no
|
|
2153
|
+
// audio while the output plainly carries some — which would leave the header
|
|
2154
|
+
// check expecting one track where two arrive, and tell the browser its sound
|
|
2155
|
+
// was lost.
|
|
2156
|
+
const audioFromAnotherFile = session.spec.audioFileIndex !== session.file.fileIndex;
|
|
2157
|
+
return {
|
|
2158
|
+
video: carriesVideo && Boolean(probed?.videoCodec),
|
|
2159
|
+
audio: carriesAudio && (audioFromAnotherFile || Boolean(probed?.audioCodec))
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
}
|