@torrent-tv/proxy 2.89.5 → 2.89.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +338 -338
- package/CHANGELOG.md +16 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +765 -765
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/playback-start-forecast.md +17 -2
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +273 -273
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -217
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +138 -138
- package/routes/api/transcode-sessions/post.js +178 -178
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +416 -416
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +95 -95
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/server.js +748 -748
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +376 -376
- package/services/encode/EncodeOrchestrator.js +1192 -1192
- package/services/encode/EncodePlan.js +1368 -1368
- package/services/encode/EncodeRun.js +32 -4
- package/services/encode/EncodeRuns.js +19 -7
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2283 -2283
- package/services/encode/RunClock.js +87 -87
- package/services/encode/RunProgress.js +79 -79
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +418 -418
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +116 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +43 -43
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/piece-completeness.js +60 -2
- package/services/encode/quality/EncodeCost.js +1043 -1043
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +24 -24
- package/services/encode/quality/QualityController.js +758 -758
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +337 -337
- package/services/encode/quality/output-format.js +319 -319
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +3 -1
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +53 -53
- package/services/encode/segment-formats/fmp4.js +476 -476
- package/services/encode/segment-formats/index.js +1 -0
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +8 -1
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +931 -931
- package/services/media/tracks/AudioTrack.js +143 -143
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/audio-bound.js +233 -233
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1152 -1152
- package/services/server/ViewerRequests.js +785 -785
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +1029 -1029
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +539 -539
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/returns.js +103 -103
- package/services/storage/wire.js +175 -175
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +264 -264
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +371 -371
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +691 -691
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/media-time.js +355 -355
- package/services/viewer/playback-readiness.js +635 -635
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-buffer-continuity.test.js +77 -77
- package/test/audio-inventory.test.js +212 -212
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +762 -753
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/data-channel-viewer-lifecycle.test.js +92 -92
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +43 -43
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +475 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +275 -275
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +426 -426
- package/test/encode-plan-viewers.test.js +725 -725
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +38 -0
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +68 -68
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fixtures/refused-audio-20261002.json +10 -0
- package/test/fmp4-presentation-clock.test.js +4 -2
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +240 -240
- package/test/helpers/manager.js +62 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +291 -291
- package/test/matroska-cues-track.test.js +192 -192
- package/test/media-time.test.js +62 -62
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +207 -207
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +87 -87
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-completeness.test.js +37 -1
- package/test/piece-disk-store.test.js +401 -401
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +595 -595
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +403 -403
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/playback-readiness.test.js +518 -518
- package/test/playback-start-regressions.test.js +101 -101
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +160 -160
- package/test/quality-variants.test.js +1270 -1257
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +64 -64
- package/test/same-height-switch.test.js +663 -663
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +222 -221
- package/test/segment-serve-wiring.test.js +9 -3
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +352 -352
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +111 -105
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/soundtrack-rate-bound.test.js +289 -289
- package/test/source-file.test.js +133 -133
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +237 -237
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/supply-margin.test.js +190 -190
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +508 -504
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +92 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/utils/torrent-source-key.js +38 -38
|
@@ -1,476 +1,476 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
|
|
3
|
-
* `init.mp4` referenced by `#EXT-X-MAP`.
|
|
4
|
-
*
|
|
5
|
-
* Codec configuration (SPS/PPS) lives once in the init segment instead of being
|
|
6
|
-
* repeated per segment. That is what makes hardware encoders which do not
|
|
7
|
-
* repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce
|
|
8
|
-
* independently usable segments, and it lowers container overhead.
|
|
9
|
-
*
|
|
10
|
-
* See {@link SegmentFormat} in `./index.js` for the interface contract.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
rebaseSegmentDecodeTimes,
|
|
15
|
-
neutralizeEmptyEdits,
|
|
16
|
-
producedThroughSeconds,
|
|
17
|
-
readPresentationRanges,
|
|
18
|
-
readSelfContainedStartSeconds,
|
|
19
|
-
readTrackTimescales,
|
|
20
|
-
readVideoSampleSize,
|
|
21
|
-
servedPresentationRanges,
|
|
22
|
-
stampSegmentStartTime,
|
|
23
|
-
walkBoxes
|
|
24
|
-
} from "./mp4-boxes.js";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Served ranges by the coverage they were derived from; see `servedMediaRanges`.
|
|
28
|
-
*
|
|
29
|
-
* @type {WeakMap<object, { initBytes: Buffer | null, served: object }>}
|
|
30
|
-
*/
|
|
31
|
-
const servedRangesKept = new WeakMap();
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* How many distinct tracks have a fragment in this segment.
|
|
35
|
-
*
|
|
36
|
-
* @param {Buffer} bytes
|
|
37
|
-
* @returns {number}
|
|
38
|
-
*/
|
|
39
|
-
function countFragmentTracks(bytes) {
|
|
40
|
-
const tracks = new Set();
|
|
41
|
-
walkBoxes(bytes, (type, bodyStart) => {
|
|
42
|
-
if (type === "tfhd" && bodyStart + 8 <= bytes.length) {
|
|
43
|
-
tracks.add(bytes.readUInt32BE(bodyStart + 4));
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
return tracks.size;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Where the fragments begin and where the trailing index starts, as offsets of
|
|
51
|
-
* whole boxes at the TOP level.
|
|
52
|
-
*
|
|
53
|
-
* Deliberately not `walkBoxes`: that reports the start of a box's *body* and
|
|
54
|
-
* descends into containers, so it cannot say where a box begins — and cutting a
|
|
55
|
-
* file at the wrong offset by eight bytes produces something that parses as
|
|
56
|
-
* garbage rather than failing outright.
|
|
57
|
-
*
|
|
58
|
-
* @param {Buffer} bytes
|
|
59
|
-
* @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
|
|
60
|
-
*/
|
|
61
|
-
function findFragmentBounds(bytes) {
|
|
62
|
-
let firstFragment = -1;
|
|
63
|
-
let trailingIndex = -1;
|
|
64
|
-
let offset = 0;
|
|
65
|
-
while (offset + 8 <= bytes.length) {
|
|
66
|
-
let size = bytes.readUInt32BE(offset);
|
|
67
|
-
const type = bytes.toString("latin1", offset + 4, offset + 8);
|
|
68
|
-
if (size === 1) {
|
|
69
|
-
if (offset + 16 > bytes.length) {
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
size = Number(bytes.readBigUInt64BE(offset + 8));
|
|
73
|
-
} else if (size === 0) {
|
|
74
|
-
size = bytes.length - offset;
|
|
75
|
-
}
|
|
76
|
-
if (size < 8) {
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
|
|
80
|
-
firstFragment = offset;
|
|
81
|
-
}
|
|
82
|
-
if (type === "mfra") {
|
|
83
|
-
trailingIndex = offset;
|
|
84
|
-
}
|
|
85
|
-
offset += size;
|
|
86
|
-
}
|
|
87
|
-
return { firstFragment, trailingIndex };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const INIT_FILE_NAME = "init.mp4";
|
|
91
|
-
// What a piece is called while it is still being written. See
|
|
92
|
-
// `makingFileNameTemplate`.
|
|
93
|
-
const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.mp4$/;
|
|
94
|
-
const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* @type {import("./index.js").SegmentFormat}
|
|
98
|
-
*/
|
|
99
|
-
export const fmp4Format = {
|
|
100
|
-
id: "fmp4",
|
|
101
|
-
initFileName: INIT_FILE_NAME,
|
|
102
|
-
initContentType: "video/mp4",
|
|
103
|
-
segmentContentType: "video/mp4",
|
|
104
|
-
// Version 7 is the minimum that allows fMP4 media segments + `#EXT-X-MAP`.
|
|
105
|
-
playlistVersion: 7,
|
|
106
|
-
|
|
107
|
-
muxerArgs() {
|
|
108
|
-
return [
|
|
109
|
-
"-hls_segment_type",
|
|
110
|
-
"fmp4",
|
|
111
|
-
"-hls_fmp4_init_filename",
|
|
112
|
-
INIT_FILE_NAME,
|
|
113
|
-
"-hls_segment_filename",
|
|
114
|
-
"segment-%05d.mp4"
|
|
115
|
-
];
|
|
116
|
-
},
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Cut at times we choose rather than times ffmpeg picks.
|
|
120
|
-
*
|
|
121
|
-
* The `segment` muxer writes each fMP4 piece as a **self-contained** file:
|
|
122
|
-
* `ftyp moov moof mdat … mfra`, verified on the field host. That is not what
|
|
123
|
-
* HLS wants — it wants one init segment named by `#EXT-X-MAP` and media
|
|
124
|
-
* segments carrying only fragments — so the pieces are split on serve:
|
|
125
|
-
* {@link extractInit} takes the header off the first one to serve as the init,
|
|
126
|
-
* and {@link stripInit} removes it from every one.
|
|
127
|
-
*
|
|
128
|
-
* The timestamps still need stamping, exactly as before: measured on the
|
|
129
|
-
* field host, all three pieces of a run reported a start time of 0.080 s,
|
|
130
|
-
* i.e. each carries its own zero. That is the same defect the shared-init
|
|
131
|
-
* path already corrects, so `prepareSegmentBytes` handles both.
|
|
132
|
-
*
|
|
133
|
-
* @returns {string[]}
|
|
134
|
-
*/
|
|
135
|
-
explicitTimesMuxerArgs() {
|
|
136
|
-
return [
|
|
137
|
-
"-segment_format",
|
|
138
|
-
"mp4",
|
|
139
|
-
// `empty_moov` is what makes each piece self-describing, which is what
|
|
140
|
-
// lets the init be lifted out of it; `default_base_moof` keeps fragment
|
|
141
|
-
// offsets relative, so removing the header does not invalidate them.
|
|
142
|
-
//
|
|
143
|
-
// `delay_moov` is not optional here. The MP4 muxer builds a copied AC-3
|
|
144
|
-
// track's `dac3` box out of the bitstream, so it cannot write `moov`
|
|
145
|
-
// until the first audio packet has arrived — while `empty_moov` asks for
|
|
146
|
-
// it at header time. Without this flag ffmpeg exits before producing
|
|
147
|
-
// anything: "Cannot write moov atom before AC3 packets", which is exactly
|
|
148
|
-
// how fMP4 playback died in the field on 2.9.84/2.9.85. The `hls` muxer
|
|
149
|
-
// sets this flag itself, which is why the fault only appeared once the
|
|
150
|
-
// muxing moved here. Delaying `moov` does not change the piece layout —
|
|
151
|
-
// measured: still `ftyp moov moof mdat … mfra`.
|
|
152
|
-
"-segment_format_options",
|
|
153
|
-
"movflags=+frag_keyframe+empty_moov+default_base_moof+delay_moov"
|
|
154
|
-
];
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
/** The output path template for the `segment` muxer. */
|
|
158
|
-
/**
|
|
159
|
-
* The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
|
|
160
|
-
*
|
|
161
|
-
* Not the name it is served under. A piece being written is not a piece, and
|
|
162
|
-
* under its final name it is indistinguishable from one — which is how half a
|
|
163
|
-
* segment came to be served: the proof of completeness was "the next file
|
|
164
|
-
* exists", true of one writer walking forward and false the moment two runs
|
|
165
|
-
* share an output, because the next file is then written by another process.
|
|
166
|
-
* Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
|
|
167
|
-
* 4 510 940, and the browser refused the whole one for the rest of the session.
|
|
168
|
-
*
|
|
169
|
-
* With a working name, the served name appears only when the encoder has said
|
|
170
|
-
* the piece is closed, and existence under it IS the proof — one rule, the
|
|
171
|
-
* same for every branch, and true whether or not our own process is alive.
|
|
172
|
-
*
|
|
173
|
-
* @returns {string}
|
|
174
|
-
*/
|
|
175
|
-
makingFileNameTemplate(tag) {
|
|
176
|
-
return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.mp4`;
|
|
177
|
-
},
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Which run is writing this working name, or null when the name is not one.
|
|
181
|
-
*
|
|
182
|
-
* The tag is what makes clearing up after a dead run possible without
|
|
183
|
-
* guessing: several runs write into one directory, so "the unfinished pieces"
|
|
184
|
-
* is only a well-formed question per run. It used to be answered by looking
|
|
185
|
-
* for the highest SERVED name inside the stretch the ended run was given and
|
|
186
|
-
* judging its bytes — a guess, and under the naming rule above it would remove
|
|
187
|
-
* a complete piece somebody else had closed.
|
|
188
|
-
*
|
|
189
|
-
* @param {string} name
|
|
190
|
-
* @returns {string | null}
|
|
191
|
-
*/
|
|
192
|
-
makingTagOf(name) {
|
|
193
|
-
const match = MAKING_PATTERN.exec(String(name ?? "").trim());
|
|
194
|
-
return match ? match[1] : null;
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* The name a piece just closed under a working name is served as, or null when
|
|
199
|
-
* the name is not one of ours.
|
|
200
|
-
*
|
|
201
|
-
* @param {string} makingName
|
|
202
|
-
* @returns {string | null}
|
|
203
|
-
*/
|
|
204
|
-
servedNameOf(makingName) {
|
|
205
|
-
const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
|
|
206
|
-
return match ? this.segmentFileName(Number(match[2])) : null;
|
|
207
|
-
},
|
|
208
|
-
|
|
209
|
-
segmentFileNameTemplate() {
|
|
210
|
-
return "segment-%05d.mp4";
|
|
211
|
-
},
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* The init part of a self-contained piece: everything before the first
|
|
215
|
-
* fragment.
|
|
216
|
-
*
|
|
217
|
-
* @param {Buffer} bytes
|
|
218
|
-
* @returns {Buffer | null} `null` when the piece carries no fragment, which
|
|
219
|
-
* means it is not one of these and must not be cut up.
|
|
220
|
-
*/
|
|
221
|
-
extractInit(bytes) {
|
|
222
|
-
const { firstFragment } = findFragmentBounds(bytes);
|
|
223
|
-
return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
|
|
224
|
-
},
|
|
225
|
-
prepareSharedInit: neutralizeEmptyEdits,
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* A piece with its init header removed, and the trailing random-access index
|
|
229
|
-
* dropped — a player reading a media segment has no use for either, and
|
|
230
|
-
* `mfra` describes offsets that stop being true once the header is gone.
|
|
231
|
-
*
|
|
232
|
-
* @param {Buffer} bytes
|
|
233
|
-
* @returns {Buffer}
|
|
234
|
-
*/
|
|
235
|
-
stripInit(bytes) {
|
|
236
|
-
const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
|
|
237
|
-
if (firstFragment < 0) {
|
|
238
|
-
return bytes;
|
|
239
|
-
}
|
|
240
|
-
const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
|
|
241
|
-
return bytes.subarray(firstFragment, end);
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
playlistHeaderLines() {
|
|
245
|
-
return [
|
|
246
|
-
// The init segment (codec config). Fetched once; applies to every media
|
|
247
|
-
// segment in the playlist.
|
|
248
|
-
`#EXT-X-MAP:URI="${INIT_FILE_NAME}"`
|
|
249
|
-
];
|
|
250
|
-
},
|
|
251
|
-
|
|
252
|
-
segmentFileName(index) {
|
|
253
|
-
return `segment-${String(index).padStart(5, "0")}.mp4`;
|
|
254
|
-
},
|
|
255
|
-
|
|
256
|
-
isSegmentFileName(fileName) {
|
|
257
|
-
return SEGMENT_PATTERN.test(fileName);
|
|
258
|
-
},
|
|
259
|
-
|
|
260
|
-
segmentIndexFromName(fileName) {
|
|
261
|
-
const match = SEGMENT_PATTERN.exec(fileName);
|
|
262
|
-
return match ? Number(match[1]) : -1;
|
|
263
|
-
},
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Where a self-contained piece says it begins, in seconds, or null.
|
|
267
|
-
*
|
|
268
|
-
* Only the pieces the `segment` muxer writes carry this — they have their
|
|
269
|
-
* own `moov` — which is exactly the path where the playlist's own answer
|
|
270
|
-
* can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
|
|
271
|
-
* removes the header the position lives in.
|
|
272
|
-
*
|
|
273
|
-
* @param {Buffer} piece
|
|
274
|
-
* @returns {number | null}
|
|
275
|
-
*/
|
|
276
|
-
readSegmentStartSeconds(piece) {
|
|
277
|
-
return readSelfContainedStartSeconds(piece);
|
|
278
|
-
},
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* fMP4 segments must be read into memory and corrected before being served —
|
|
282
|
-
* see {@link stampSegmentStartTime} for the full reasoning. Without this a
|
|
283
|
-
* seek is permanently broken: ffmpeg leaves every segment claiming to start
|
|
284
|
-
* at 0 and puts the real offset in the per-run init, which we do not serve
|
|
285
|
-
* (the player fetches the session's first init once and keeps it).
|
|
286
|
-
*
|
|
287
|
-
* Segments are a few hundred KB, so reading one whole is cheap next to the
|
|
288
|
-
* transcode itself; the box walk never descends into `mdat`.
|
|
289
|
-
*/
|
|
290
|
-
needsSegmentRewrite: true,
|
|
291
|
-
producedThroughSeconds,
|
|
292
|
-
readMediaRanges(raw, { initBytes = null, startSeconds = 0 } = {}) {
|
|
293
|
-
if (this.extractInit(raw)) return readPresentationRanges(raw);
|
|
294
|
-
if (!initBytes?.length) return undefined;
|
|
295
|
-
const served = this.prepareSegmentBytes(raw, { initBytes, startSeconds, rawBytes: raw });
|
|
296
|
-
return readPresentationRanges(Buffer.concat([initBytes, served]));
|
|
297
|
-
},
|
|
298
|
-
/**
|
|
299
|
-
* The same object is returned while the coverage and the init are the same
|
|
300
|
-
* ones, so a reader can keep what it derives from a piece instead of deriving
|
|
301
|
-
* it on every report.
|
|
302
|
-
*/
|
|
303
|
-
servedMediaRanges(coverage, { initBytes = null } = {}) {
|
|
304
|
-
const kept = servedRangesKept.get(coverage);
|
|
305
|
-
if (kept && kept.initBytes === initBytes) {
|
|
306
|
-
return kept.served;
|
|
307
|
-
}
|
|
308
|
-
const served = servedPresentationRanges(coverage, initBytes);
|
|
309
|
-
if (coverage !== null && typeof coverage === "object") {
|
|
310
|
-
servedRangesKept.set(coverage, { initBytes, served });
|
|
311
|
-
}
|
|
312
|
-
return served;
|
|
313
|
-
},
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Whether a segment carries every track the init promises.
|
|
317
|
-
*
|
|
318
|
-
* A run that is TERMINATED closes its current output file properly — trailing
|
|
319
|
-
* index and all — but the file holds only what had been muxed by then, which
|
|
320
|
-
* after a seek-restart is routinely one track of two. Nothing about it looks
|
|
321
|
-
* unfinished: the file exists, the next one exists, so the readiness rule
|
|
322
|
-
* calls it done and it is served. The player then cannot use it and the seek
|
|
323
|
-
* never completes. Measured 2026-08-06: segment #133 carried one `tfdt`
|
|
324
|
-
* where its neighbours carried two, and a viewer sat on a spinner while the
|
|
325
|
-
* proxy answered every request in 98 ms.
|
|
326
|
-
*
|
|
327
|
-
* Cheap to check: the fragments are already walked to stamp them.
|
|
328
|
-
*
|
|
329
|
-
* @param {Buffer} bytes - The media segment, init header already removed.
|
|
330
|
-
* @param {Buffer | null} initBytes
|
|
331
|
-
* @returns {boolean} False only when a track is provably missing.
|
|
332
|
-
*/
|
|
333
|
-
hasEveryTrack(bytes, initBytes) {
|
|
334
|
-
if (!initBytes || initBytes.length === 0) {
|
|
335
|
-
return true;
|
|
336
|
-
}
|
|
337
|
-
const expected = readTrackTimescales(initBytes);
|
|
338
|
-
if (expected.size === 0) {
|
|
339
|
-
return true;
|
|
340
|
-
}
|
|
341
|
-
return countFragmentTracks(bytes) >= expected.size;
|
|
342
|
-
},
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Whether this piece is whole, judged against ITS OWN header.
|
|
346
|
-
*
|
|
347
|
-
* A piece that disagrees with the session's cached header is one of two very
|
|
348
|
-
* different things, and they call for opposite actions: the piece may be
|
|
349
|
-
* short, or the HEADER may be the odd one out. A self-contained piece carries
|
|
350
|
-
* a `moov` of its own, so it can be compared with itself, and that comparison
|
|
351
|
-
* tells them apart without knowing anything about encoders.
|
|
352
|
-
*
|
|
353
|
-
* WHAT THIS REPLACED, because the difference is five minutes of a frozen
|
|
354
|
-
* picture. The action used to be chosen by "is any run of this session
|
|
355
|
-
* alive?" — a question about the SESSION, asked about a fact of the BYTES. A
|
|
356
|
-
* run anywhere in the film meant "still being written; waiting for it", and
|
|
357
|
-
* nothing re-judged or re-made the piece afterwards, so one misjudgement
|
|
358
|
-
* became permanent. Field 2026-09-14: `segment-00033.mp4` of a copied
|
|
359
|
-
* picture, already served whole to one viewer at 5 099 421 bytes, was refused
|
|
360
|
-
* to a second viewer for five minutes — four holds of sixty seconds each,
|
|
361
|
-
* then 503 — while the run in force was at #192 and beyond. That viewer's
|
|
362
|
-
* picture stood still 196.2 s and then a further 56.2 s.
|
|
363
|
-
*
|
|
364
|
-
* The run question is also unanswerable and does not need asking: a piece
|
|
365
|
-
* being written is called `making-<tag>-NNNNN.mp4` and takes its served name
|
|
366
|
-
* by a rename, which is atomic. A file under its served name is never being
|
|
367
|
-
* written and never half-read.
|
|
368
|
-
*
|
|
369
|
-
* The same file read back off the disk declares one track in its header and
|
|
370
|
-
* carries one in its fragments, so what fired in the field was not the piece.
|
|
371
|
-
* Until a reading names what it was, the verdict carries every count it made.
|
|
372
|
-
*
|
|
373
|
-
* @param {Buffer} raw - The piece as it is on disk, header and all.
|
|
374
|
-
* @param {Buffer} bytes - Its media part, the header already removed.
|
|
375
|
-
* @param {Buffer | null} sessionHeader - What the session serves as its init.
|
|
376
|
-
* @returns {{ whole: boolean, serve: boolean, fragmentTracks: number,
|
|
377
|
-
* ownTracks: number, sessionTracks: number, because: string } | null} Null
|
|
378
|
-
* where there is nothing to compare against, which is not a verdict.
|
|
379
|
-
*/
|
|
380
|
-
judgeTracks(raw, bytes, sessionHeader) {
|
|
381
|
-
if (!sessionHeader || sessionHeader.length === 0) {
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
const sessionTracks = readTrackTimescales(sessionHeader).size;
|
|
385
|
-
if (sessionTracks === 0) {
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
const fragmentTracks = countFragmentTracks(bytes);
|
|
389
|
-
if (fragmentTracks >= sessionTracks) {
|
|
390
|
-
return null;
|
|
391
|
-
}
|
|
392
|
-
const ownHeader = this.extractInit(raw);
|
|
393
|
-
const ownTracks = ownHeader ? readTrackTimescales(ownHeader).size : 0;
|
|
394
|
-
const agreesWithItself = ownTracks > 0 && fragmentTracks >= ownTracks;
|
|
395
|
-
return {
|
|
396
|
-
whole: agreesWithItself,
|
|
397
|
-
serve: agreesWithItself,
|
|
398
|
-
fragmentTracks,
|
|
399
|
-
ownTracks,
|
|
400
|
-
sessionTracks,
|
|
401
|
-
because: agreesWithItself
|
|
402
|
-
? "it agrees with its own header, so the session's is the odd one out; serving it"
|
|
403
|
-
: "it disagrees with its own header, so it was closed short; removing it to be made again"
|
|
404
|
-
};
|
|
405
|
-
},
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* How many tracks an init segment declares.
|
|
409
|
-
*
|
|
410
|
-
* The init is extracted from the first self-contained piece and then cached
|
|
411
|
-
* for the WHOLE session — the player fetches `#EXT-X-MAP` once and never
|
|
412
|
-
* again. So an init taken from a piece written before the video track was
|
|
413
|
-
* muxed describes audio alone, and the browser then has no video source
|
|
414
|
-
* buffer for the rest of the session however much video arrives afterwards.
|
|
415
|
-
* Measured 2026-08-10: sixty-five seconds of playing sound with
|
|
416
|
-
* `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element
|
|
417
|
-
* perfectly happy, with no picture in it.
|
|
418
|
-
*
|
|
419
|
-
* @param {Buffer} initBytes
|
|
420
|
-
* @returns {number}
|
|
421
|
-
*/
|
|
422
|
-
/**
|
|
423
|
-
* How many distinct tracks this piece's own fragments carry.
|
|
424
|
-
*
|
|
425
|
-
* Needs no knowledge of the source: whatever a produced piece contains, it
|
|
426
|
-
* contains. Comparing the richest piece against a candidate header is what
|
|
427
|
-
* lets an init be judged without assuming how many tracks a file "should"
|
|
428
|
-
* have — a film with no soundtrack and a file with two audio tracks are both
|
|
429
|
-
* answered correctly, and neither is guessed at.
|
|
430
|
-
*
|
|
431
|
-
* @param {Buffer} bytes
|
|
432
|
-
* @returns {number}
|
|
433
|
-
*/
|
|
434
|
-
countSegmentTracks(bytes) {
|
|
435
|
-
return countFragmentTracks(bytes);
|
|
436
|
-
},
|
|
437
|
-
|
|
438
|
-
countInitTracks(initBytes) {
|
|
439
|
-
if (!initBytes || initBytes.length === 0) {
|
|
440
|
-
return 0;
|
|
441
|
-
}
|
|
442
|
-
return readTrackTimescales(initBytes).size;
|
|
443
|
-
},
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* The picture size this init segment describes, or null when it describes no
|
|
447
|
-
* picture.
|
|
448
|
-
*
|
|
449
|
-
* Asked because one init serves the session's whole life while an encode run
|
|
450
|
-
* can be restarted with other settings, and a run that changes the SIZE makes
|
|
451
|
-
* every fragment after it undecodable against the init already in the
|
|
452
|
-
* player's hands.
|
|
453
|
-
*
|
|
454
|
-
* @param {Buffer | null} initBytes
|
|
455
|
-
* @returns {{ width: number, height: number } | null}
|
|
456
|
-
*/
|
|
457
|
-
initVideoSize(initBytes) {
|
|
458
|
-
if (!initBytes || initBytes.length === 0) {
|
|
459
|
-
return null;
|
|
460
|
-
}
|
|
461
|
-
return readVideoSampleSize(initBytes);
|
|
462
|
-
},
|
|
463
|
-
|
|
464
|
-
prepareSegmentBytes(bytes, { startSeconds, initBytes, rawBytes }) {
|
|
465
|
-
if (!initBytes || initBytes.length === 0) {
|
|
466
|
-
// No init cached yet — nothing to read timescales from. The player always
|
|
467
|
-
// fetches `#EXT-X-MAP` before any segment, so this is not reachable in
|
|
468
|
-
// practice; serve unmodified rather than guess a timescale.
|
|
469
|
-
return bytes;
|
|
470
|
-
}
|
|
471
|
-
const ownInit = rawBytes ? this.extractInit(rawBytes) : null;
|
|
472
|
-
return ownInit
|
|
473
|
-
? rebaseSegmentDecodeTimes(bytes, ownInit, initBytes)
|
|
474
|
-
: stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
|
|
475
|
-
}
|
|
476
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
|
|
3
|
+
* `init.mp4` referenced by `#EXT-X-MAP`.
|
|
4
|
+
*
|
|
5
|
+
* Codec configuration (SPS/PPS) lives once in the init segment instead of being
|
|
6
|
+
* repeated per segment. That is what makes hardware encoders which do not
|
|
7
|
+
* repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce
|
|
8
|
+
* independently usable segments, and it lowers container overhead.
|
|
9
|
+
*
|
|
10
|
+
* See {@link SegmentFormat} in `./index.js` for the interface contract.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
rebaseSegmentDecodeTimes,
|
|
15
|
+
neutralizeEmptyEdits,
|
|
16
|
+
producedThroughSeconds,
|
|
17
|
+
readPresentationRanges,
|
|
18
|
+
readSelfContainedStartSeconds,
|
|
19
|
+
readTrackTimescales,
|
|
20
|
+
readVideoSampleSize,
|
|
21
|
+
servedPresentationRanges,
|
|
22
|
+
stampSegmentStartTime,
|
|
23
|
+
walkBoxes
|
|
24
|
+
} from "./mp4-boxes.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Served ranges by the coverage they were derived from; see `servedMediaRanges`.
|
|
28
|
+
*
|
|
29
|
+
* @type {WeakMap<object, { initBytes: Buffer | null, served: object }>}
|
|
30
|
+
*/
|
|
31
|
+
const servedRangesKept = new WeakMap();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How many distinct tracks have a fragment in this segment.
|
|
35
|
+
*
|
|
36
|
+
* @param {Buffer} bytes
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
function countFragmentTracks(bytes) {
|
|
40
|
+
const tracks = new Set();
|
|
41
|
+
walkBoxes(bytes, (type, bodyStart) => {
|
|
42
|
+
if (type === "tfhd" && bodyStart + 8 <= bytes.length) {
|
|
43
|
+
tracks.add(bytes.readUInt32BE(bodyStart + 4));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return tracks.size;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where the fragments begin and where the trailing index starts, as offsets of
|
|
51
|
+
* whole boxes at the TOP level.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately not `walkBoxes`: that reports the start of a box's *body* and
|
|
54
|
+
* descends into containers, so it cannot say where a box begins — and cutting a
|
|
55
|
+
* file at the wrong offset by eight bytes produces something that parses as
|
|
56
|
+
* garbage rather than failing outright.
|
|
57
|
+
*
|
|
58
|
+
* @param {Buffer} bytes
|
|
59
|
+
* @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
|
|
60
|
+
*/
|
|
61
|
+
function findFragmentBounds(bytes) {
|
|
62
|
+
let firstFragment = -1;
|
|
63
|
+
let trailingIndex = -1;
|
|
64
|
+
let offset = 0;
|
|
65
|
+
while (offset + 8 <= bytes.length) {
|
|
66
|
+
let size = bytes.readUInt32BE(offset);
|
|
67
|
+
const type = bytes.toString("latin1", offset + 4, offset + 8);
|
|
68
|
+
if (size === 1) {
|
|
69
|
+
if (offset + 16 > bytes.length) {
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
size = Number(bytes.readBigUInt64BE(offset + 8));
|
|
73
|
+
} else if (size === 0) {
|
|
74
|
+
size = bytes.length - offset;
|
|
75
|
+
}
|
|
76
|
+
if (size < 8) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
|
|
80
|
+
firstFragment = offset;
|
|
81
|
+
}
|
|
82
|
+
if (type === "mfra") {
|
|
83
|
+
trailingIndex = offset;
|
|
84
|
+
}
|
|
85
|
+
offset += size;
|
|
86
|
+
}
|
|
87
|
+
return { firstFragment, trailingIndex };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const INIT_FILE_NAME = "init.mp4";
|
|
91
|
+
// What a piece is called while it is still being written. See
|
|
92
|
+
// `makingFileNameTemplate`.
|
|
93
|
+
const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.mp4$/;
|
|
94
|
+
const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @type {import("./index.js").SegmentFormat}
|
|
98
|
+
*/
|
|
99
|
+
export const fmp4Format = {
|
|
100
|
+
id: "fmp4",
|
|
101
|
+
initFileName: INIT_FILE_NAME,
|
|
102
|
+
initContentType: "video/mp4",
|
|
103
|
+
segmentContentType: "video/mp4",
|
|
104
|
+
// Version 7 is the minimum that allows fMP4 media segments + `#EXT-X-MAP`.
|
|
105
|
+
playlistVersion: 7,
|
|
106
|
+
|
|
107
|
+
muxerArgs() {
|
|
108
|
+
return [
|
|
109
|
+
"-hls_segment_type",
|
|
110
|
+
"fmp4",
|
|
111
|
+
"-hls_fmp4_init_filename",
|
|
112
|
+
INIT_FILE_NAME,
|
|
113
|
+
"-hls_segment_filename",
|
|
114
|
+
"segment-%05d.mp4"
|
|
115
|
+
];
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Cut at times we choose rather than times ffmpeg picks.
|
|
120
|
+
*
|
|
121
|
+
* The `segment` muxer writes each fMP4 piece as a **self-contained** file:
|
|
122
|
+
* `ftyp moov moof mdat … mfra`, verified on the field host. That is not what
|
|
123
|
+
* HLS wants — it wants one init segment named by `#EXT-X-MAP` and media
|
|
124
|
+
* segments carrying only fragments — so the pieces are split on serve:
|
|
125
|
+
* {@link extractInit} takes the header off the first one to serve as the init,
|
|
126
|
+
* and {@link stripInit} removes it from every one.
|
|
127
|
+
*
|
|
128
|
+
* The timestamps still need stamping, exactly as before: measured on the
|
|
129
|
+
* field host, all three pieces of a run reported a start time of 0.080 s,
|
|
130
|
+
* i.e. each carries its own zero. That is the same defect the shared-init
|
|
131
|
+
* path already corrects, so `prepareSegmentBytes` handles both.
|
|
132
|
+
*
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
explicitTimesMuxerArgs() {
|
|
136
|
+
return [
|
|
137
|
+
"-segment_format",
|
|
138
|
+
"mp4",
|
|
139
|
+
// `empty_moov` is what makes each piece self-describing, which is what
|
|
140
|
+
// lets the init be lifted out of it; `default_base_moof` keeps fragment
|
|
141
|
+
// offsets relative, so removing the header does not invalidate them.
|
|
142
|
+
//
|
|
143
|
+
// `delay_moov` is not optional here. The MP4 muxer builds a copied AC-3
|
|
144
|
+
// track's `dac3` box out of the bitstream, so it cannot write `moov`
|
|
145
|
+
// until the first audio packet has arrived — while `empty_moov` asks for
|
|
146
|
+
// it at header time. Without this flag ffmpeg exits before producing
|
|
147
|
+
// anything: "Cannot write moov atom before AC3 packets", which is exactly
|
|
148
|
+
// how fMP4 playback died in the field on 2.9.84/2.9.85. The `hls` muxer
|
|
149
|
+
// sets this flag itself, which is why the fault only appeared once the
|
|
150
|
+
// muxing moved here. Delaying `moov` does not change the piece layout —
|
|
151
|
+
// measured: still `ftyp moov moof mdat … mfra`.
|
|
152
|
+
"-segment_format_options",
|
|
153
|
+
"movflags=+frag_keyframe+empty_moov+default_base_moof+delay_moov"
|
|
154
|
+
];
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** The output path template for the `segment` muxer. */
|
|
158
|
+
/**
|
|
159
|
+
* The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
|
|
160
|
+
*
|
|
161
|
+
* Not the name it is served under. A piece being written is not a piece, and
|
|
162
|
+
* under its final name it is indistinguishable from one — which is how half a
|
|
163
|
+
* segment came to be served: the proof of completeness was "the next file
|
|
164
|
+
* exists", true of one writer walking forward and false the moment two runs
|
|
165
|
+
* share an output, because the next file is then written by another process.
|
|
166
|
+
* Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
|
|
167
|
+
* 4 510 940, and the browser refused the whole one for the rest of the session.
|
|
168
|
+
*
|
|
169
|
+
* With a working name, the served name appears only when the encoder has said
|
|
170
|
+
* the piece is closed, and existence under it IS the proof — one rule, the
|
|
171
|
+
* same for every branch, and true whether or not our own process is alive.
|
|
172
|
+
*
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
makingFileNameTemplate(tag) {
|
|
176
|
+
return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.mp4`;
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Which run is writing this working name, or null when the name is not one.
|
|
181
|
+
*
|
|
182
|
+
* The tag is what makes clearing up after a dead run possible without
|
|
183
|
+
* guessing: several runs write into one directory, so "the unfinished pieces"
|
|
184
|
+
* is only a well-formed question per run. It used to be answered by looking
|
|
185
|
+
* for the highest SERVED name inside the stretch the ended run was given and
|
|
186
|
+
* judging its bytes — a guess, and under the naming rule above it would remove
|
|
187
|
+
* a complete piece somebody else had closed.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} name
|
|
190
|
+
* @returns {string | null}
|
|
191
|
+
*/
|
|
192
|
+
makingTagOf(name) {
|
|
193
|
+
const match = MAKING_PATTERN.exec(String(name ?? "").trim());
|
|
194
|
+
return match ? match[1] : null;
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The name a piece just closed under a working name is served as, or null when
|
|
199
|
+
* the name is not one of ours.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} makingName
|
|
202
|
+
* @returns {string | null}
|
|
203
|
+
*/
|
|
204
|
+
servedNameOf(makingName) {
|
|
205
|
+
const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
|
|
206
|
+
return match ? this.segmentFileName(Number(match[2])) : null;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
segmentFileNameTemplate() {
|
|
210
|
+
return "segment-%05d.mp4";
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The init part of a self-contained piece: everything before the first
|
|
215
|
+
* fragment.
|
|
216
|
+
*
|
|
217
|
+
* @param {Buffer} bytes
|
|
218
|
+
* @returns {Buffer | null} `null` when the piece carries no fragment, which
|
|
219
|
+
* means it is not one of these and must not be cut up.
|
|
220
|
+
*/
|
|
221
|
+
extractInit(bytes) {
|
|
222
|
+
const { firstFragment } = findFragmentBounds(bytes);
|
|
223
|
+
return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
|
|
224
|
+
},
|
|
225
|
+
prepareSharedInit: neutralizeEmptyEdits,
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A piece with its init header removed, and the trailing random-access index
|
|
229
|
+
* dropped — a player reading a media segment has no use for either, and
|
|
230
|
+
* `mfra` describes offsets that stop being true once the header is gone.
|
|
231
|
+
*
|
|
232
|
+
* @param {Buffer} bytes
|
|
233
|
+
* @returns {Buffer}
|
|
234
|
+
*/
|
|
235
|
+
stripInit(bytes) {
|
|
236
|
+
const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
|
|
237
|
+
if (firstFragment < 0) {
|
|
238
|
+
return bytes;
|
|
239
|
+
}
|
|
240
|
+
const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
|
|
241
|
+
return bytes.subarray(firstFragment, end);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
playlistHeaderLines() {
|
|
245
|
+
return [
|
|
246
|
+
// The init segment (codec config). Fetched once; applies to every media
|
|
247
|
+
// segment in the playlist.
|
|
248
|
+
`#EXT-X-MAP:URI="${INIT_FILE_NAME}"`
|
|
249
|
+
];
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
segmentFileName(index) {
|
|
253
|
+
return `segment-${String(index).padStart(5, "0")}.mp4`;
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
isSegmentFileName(fileName) {
|
|
257
|
+
return SEGMENT_PATTERN.test(fileName);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
segmentIndexFromName(fileName) {
|
|
261
|
+
const match = SEGMENT_PATTERN.exec(fileName);
|
|
262
|
+
return match ? Number(match[1]) : -1;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Where a self-contained piece says it begins, in seconds, or null.
|
|
267
|
+
*
|
|
268
|
+
* Only the pieces the `segment` muxer writes carry this — they have their
|
|
269
|
+
* own `moov` — which is exactly the path where the playlist's own answer
|
|
270
|
+
* can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
|
|
271
|
+
* removes the header the position lives in.
|
|
272
|
+
*
|
|
273
|
+
* @param {Buffer} piece
|
|
274
|
+
* @returns {number | null}
|
|
275
|
+
*/
|
|
276
|
+
readSegmentStartSeconds(piece) {
|
|
277
|
+
return readSelfContainedStartSeconds(piece);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* fMP4 segments must be read into memory and corrected before being served —
|
|
282
|
+
* see {@link stampSegmentStartTime} for the full reasoning. Without this a
|
|
283
|
+
* seek is permanently broken: ffmpeg leaves every segment claiming to start
|
|
284
|
+
* at 0 and puts the real offset in the per-run init, which we do not serve
|
|
285
|
+
* (the player fetches the session's first init once and keeps it).
|
|
286
|
+
*
|
|
287
|
+
* Segments are a few hundred KB, so reading one whole is cheap next to the
|
|
288
|
+
* transcode itself; the box walk never descends into `mdat`.
|
|
289
|
+
*/
|
|
290
|
+
needsSegmentRewrite: true,
|
|
291
|
+
producedThroughSeconds,
|
|
292
|
+
readMediaRanges(raw, { initBytes = null, startSeconds = 0 } = {}) {
|
|
293
|
+
if (this.extractInit(raw)) return readPresentationRanges(raw);
|
|
294
|
+
if (!initBytes?.length) return undefined;
|
|
295
|
+
const served = this.prepareSegmentBytes(raw, { initBytes, startSeconds, rawBytes: raw });
|
|
296
|
+
return readPresentationRanges(Buffer.concat([initBytes, served]));
|
|
297
|
+
},
|
|
298
|
+
/**
|
|
299
|
+
* The same object is returned while the coverage and the init are the same
|
|
300
|
+
* ones, so a reader can keep what it derives from a piece instead of deriving
|
|
301
|
+
* it on every report.
|
|
302
|
+
*/
|
|
303
|
+
servedMediaRanges(coverage, { initBytes = null } = {}) {
|
|
304
|
+
const kept = servedRangesKept.get(coverage);
|
|
305
|
+
if (kept && kept.initBytes === initBytes) {
|
|
306
|
+
return kept.served;
|
|
307
|
+
}
|
|
308
|
+
const served = servedPresentationRanges(coverage, initBytes);
|
|
309
|
+
if (coverage !== null && typeof coverage === "object") {
|
|
310
|
+
servedRangesKept.set(coverage, { initBytes, served });
|
|
311
|
+
}
|
|
312
|
+
return served;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Whether a segment carries every track the init promises.
|
|
317
|
+
*
|
|
318
|
+
* A run that is TERMINATED closes its current output file properly — trailing
|
|
319
|
+
* index and all — but the file holds only what had been muxed by then, which
|
|
320
|
+
* after a seek-restart is routinely one track of two. Nothing about it looks
|
|
321
|
+
* unfinished: the file exists, the next one exists, so the readiness rule
|
|
322
|
+
* calls it done and it is served. The player then cannot use it and the seek
|
|
323
|
+
* never completes. Measured 2026-08-06: segment #133 carried one `tfdt`
|
|
324
|
+
* where its neighbours carried two, and a viewer sat on a spinner while the
|
|
325
|
+
* proxy answered every request in 98 ms.
|
|
326
|
+
*
|
|
327
|
+
* Cheap to check: the fragments are already walked to stamp them.
|
|
328
|
+
*
|
|
329
|
+
* @param {Buffer} bytes - The media segment, init header already removed.
|
|
330
|
+
* @param {Buffer | null} initBytes
|
|
331
|
+
* @returns {boolean} False only when a track is provably missing.
|
|
332
|
+
*/
|
|
333
|
+
hasEveryTrack(bytes, initBytes) {
|
|
334
|
+
if (!initBytes || initBytes.length === 0) {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
const expected = readTrackTimescales(initBytes);
|
|
338
|
+
if (expected.size === 0) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
return countFragmentTracks(bytes) >= expected.size;
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Whether this piece is whole, judged against ITS OWN header.
|
|
346
|
+
*
|
|
347
|
+
* A piece that disagrees with the session's cached header is one of two very
|
|
348
|
+
* different things, and they call for opposite actions: the piece may be
|
|
349
|
+
* short, or the HEADER may be the odd one out. A self-contained piece carries
|
|
350
|
+
* a `moov` of its own, so it can be compared with itself, and that comparison
|
|
351
|
+
* tells them apart without knowing anything about encoders.
|
|
352
|
+
*
|
|
353
|
+
* WHAT THIS REPLACED, because the difference is five minutes of a frozen
|
|
354
|
+
* picture. The action used to be chosen by "is any run of this session
|
|
355
|
+
* alive?" — a question about the SESSION, asked about a fact of the BYTES. A
|
|
356
|
+
* run anywhere in the film meant "still being written; waiting for it", and
|
|
357
|
+
* nothing re-judged or re-made the piece afterwards, so one misjudgement
|
|
358
|
+
* became permanent. Field 2026-09-14: `segment-00033.mp4` of a copied
|
|
359
|
+
* picture, already served whole to one viewer at 5 099 421 bytes, was refused
|
|
360
|
+
* to a second viewer for five minutes — four holds of sixty seconds each,
|
|
361
|
+
* then 503 — while the run in force was at #192 and beyond. That viewer's
|
|
362
|
+
* picture stood still 196.2 s and then a further 56.2 s.
|
|
363
|
+
*
|
|
364
|
+
* The run question is also unanswerable and does not need asking: a piece
|
|
365
|
+
* being written is called `making-<tag>-NNNNN.mp4` and takes its served name
|
|
366
|
+
* by a rename, which is atomic. A file under its served name is never being
|
|
367
|
+
* written and never half-read.
|
|
368
|
+
*
|
|
369
|
+
* The same file read back off the disk declares one track in its header and
|
|
370
|
+
* carries one in its fragments, so what fired in the field was not the piece.
|
|
371
|
+
* Until a reading names what it was, the verdict carries every count it made.
|
|
372
|
+
*
|
|
373
|
+
* @param {Buffer} raw - The piece as it is on disk, header and all.
|
|
374
|
+
* @param {Buffer} bytes - Its media part, the header already removed.
|
|
375
|
+
* @param {Buffer | null} sessionHeader - What the session serves as its init.
|
|
376
|
+
* @returns {{ whole: boolean, serve: boolean, fragmentTracks: number,
|
|
377
|
+
* ownTracks: number, sessionTracks: number, because: string } | null} Null
|
|
378
|
+
* where there is nothing to compare against, which is not a verdict.
|
|
379
|
+
*/
|
|
380
|
+
judgeTracks(raw, bytes, sessionHeader) {
|
|
381
|
+
if (!sessionHeader || sessionHeader.length === 0) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const sessionTracks = readTrackTimescales(sessionHeader).size;
|
|
385
|
+
if (sessionTracks === 0) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
const fragmentTracks = countFragmentTracks(bytes);
|
|
389
|
+
if (fragmentTracks >= sessionTracks) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
const ownHeader = this.extractInit(raw);
|
|
393
|
+
const ownTracks = ownHeader ? readTrackTimescales(ownHeader).size : 0;
|
|
394
|
+
const agreesWithItself = ownTracks > 0 && fragmentTracks >= ownTracks;
|
|
395
|
+
return {
|
|
396
|
+
whole: agreesWithItself,
|
|
397
|
+
serve: agreesWithItself,
|
|
398
|
+
fragmentTracks,
|
|
399
|
+
ownTracks,
|
|
400
|
+
sessionTracks,
|
|
401
|
+
because: agreesWithItself
|
|
402
|
+
? "it agrees with its own header, so the session's is the odd one out; serving it"
|
|
403
|
+
: "it disagrees with its own header, so it was closed short; removing it to be made again"
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* How many tracks an init segment declares.
|
|
409
|
+
*
|
|
410
|
+
* The init is extracted from the first self-contained piece and then cached
|
|
411
|
+
* for the WHOLE session — the player fetches `#EXT-X-MAP` once and never
|
|
412
|
+
* again. So an init taken from a piece written before the video track was
|
|
413
|
+
* muxed describes audio alone, and the browser then has no video source
|
|
414
|
+
* buffer for the rest of the session however much video arrives afterwards.
|
|
415
|
+
* Measured 2026-08-10: sixty-five seconds of playing sound with
|
|
416
|
+
* `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element
|
|
417
|
+
* perfectly happy, with no picture in it.
|
|
418
|
+
*
|
|
419
|
+
* @param {Buffer} initBytes
|
|
420
|
+
* @returns {number}
|
|
421
|
+
*/
|
|
422
|
+
/**
|
|
423
|
+
* How many distinct tracks this piece's own fragments carry.
|
|
424
|
+
*
|
|
425
|
+
* Needs no knowledge of the source: whatever a produced piece contains, it
|
|
426
|
+
* contains. Comparing the richest piece against a candidate header is what
|
|
427
|
+
* lets an init be judged without assuming how many tracks a file "should"
|
|
428
|
+
* have — a film with no soundtrack and a file with two audio tracks are both
|
|
429
|
+
* answered correctly, and neither is guessed at.
|
|
430
|
+
*
|
|
431
|
+
* @param {Buffer} bytes
|
|
432
|
+
* @returns {number}
|
|
433
|
+
*/
|
|
434
|
+
countSegmentTracks(bytes) {
|
|
435
|
+
return countFragmentTracks(bytes);
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
countInitTracks(initBytes) {
|
|
439
|
+
if (!initBytes || initBytes.length === 0) {
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
return readTrackTimescales(initBytes).size;
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The picture size this init segment describes, or null when it describes no
|
|
447
|
+
* picture.
|
|
448
|
+
*
|
|
449
|
+
* Asked because one init serves the session's whole life while an encode run
|
|
450
|
+
* can be restarted with other settings, and a run that changes the SIZE makes
|
|
451
|
+
* every fragment after it undecodable against the init already in the
|
|
452
|
+
* player's hands.
|
|
453
|
+
*
|
|
454
|
+
* @param {Buffer | null} initBytes
|
|
455
|
+
* @returns {{ width: number, height: number } | null}
|
|
456
|
+
*/
|
|
457
|
+
initVideoSize(initBytes) {
|
|
458
|
+
if (!initBytes || initBytes.length === 0) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
return readVideoSampleSize(initBytes);
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
prepareSegmentBytes(bytes, { startSeconds, initBytes, rawBytes }) {
|
|
465
|
+
if (!initBytes || initBytes.length === 0) {
|
|
466
|
+
// No init cached yet — nothing to read timescales from. The player always
|
|
467
|
+
// fetches `#EXT-X-MAP` before any segment, so this is not reachable in
|
|
468
|
+
// practice; serve unmodified rather than guess a timescale.
|
|
469
|
+
return bytes;
|
|
470
|
+
}
|
|
471
|
+
const ownInit = rawBytes ? this.extractInit(rawBytes) : null;
|
|
472
|
+
return ownInit
|
|
473
|
+
? rebaseSegmentDecodeTimes(bytes, ownInit, initBytes)
|
|
474
|
+
: stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
|
|
475
|
+
}
|
|
476
|
+
};
|