@torrent-tv/proxy 2.85.1 → 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 -1909
- 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 -489
- package/services/viewer/Viewers.js +368 -257
- package/services/viewer/active-output.js +37 -54
- package/services/viewer/audio-start.js +46 -0
- 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 -44
- 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 -518
- 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 -172
- 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 -9689
- 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/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
|
@@ -1,601 +1,596 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Base Container — abstract per RFC 9559 / ISO 14496-12.
|
|
3
|
-
*
|
|
4
|
-
* A Container knows how to read its own format's track table and index.
|
|
5
|
-
* Concrete containers (MatroskaContainer, Mp4Container, AviContainer) implement
|
|
6
|
-
* spec-specific parsing. All byte access goes through `readRange(start,end)` so
|
|
7
|
-
* the class works over torrent piece windows.
|
|
8
|
-
*
|
|
9
|
-
* Spec refs:
|
|
10
|
-
* - Matroska RFC 9559 §5: EBML, Segment, SeekHead, Tracks, Cues, Clusters
|
|
11
|
-
* - MP4 ISO/IEC 14496-12 §8: ftyp, moov, trak, tkhd, mdhd, hdlr, elng, stbl
|
|
12
|
-
* - AVI RIFF §: LIST hdrl, idx1
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* What one file declares about itself. Every field is either a value the
|
|
17
|
-
* container states or `null`, which means the container does not state it —
|
|
18
|
-
* a defined absence, not "nobody has looked yet".
|
|
19
|
-
*
|
|
20
|
-
* @typedef {object} ContainerMediaInfo
|
|
21
|
-
* @property {string} format - "matroska" | "mp4" | "avi" | "unknown".
|
|
22
|
-
* @property {number | null} durationSeconds
|
|
23
|
-
* @property {number | null} startTimeSeconds - Where this file's own timeline
|
|
24
|
-
* begins. Two files of one release need not agree on it, and the difference
|
|
25
|
-
* is what keeps a soundtrack shipped separately aligned with its picture.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
export class Container {
|
|
29
|
-
/**
|
|
30
|
-
* Two ways of reading, because two readers of one file want different things
|
|
31
|
-
* and the file is opened once.
|
|
32
|
-
*
|
|
33
|
-
* `readRange` fetches what is missing: the head and the track table are
|
|
34
|
-
* kilobytes, they are needed before anything can be offered, and the codec
|
|
35
|
-
* probe has already pulled the head of every file that plays. `readHeld`
|
|
36
|
-
* reads only what is already downloaded and asks the swarm for nothing, which
|
|
37
|
-
* is what the cue walk needs — turning subtitles on must not pull bytes the
|
|
38
|
-
* viewer is not waiting for. `isHeld` says whether a range can be read that
|
|
39
|
-
* way at all, so the walk can leave a cluster for next time instead of
|
|
40
|
-
* blocking on it.
|
|
41
|
-
*
|
|
42
|
-
* They are given per container rather than per call because a container is
|
|
43
|
-
* cached per file and both readers want the same parsed head. Where the
|
|
44
|
-
* caller supplies only `readRange`, the held reader is that one and every
|
|
45
|
-
* range counts as held — which is the right answer for a local file, and for
|
|
46
|
-
* a torrent it is the caller's job to say otherwise.
|
|
47
|
-
*
|
|
48
|
-
* The torrent is NOT passed in and must not be: `readHeld` and `isHeld` are
|
|
49
|
-
* the only two facts about it this layer needs, and reducing it to two
|
|
50
|
-
* functions is what keeps the container ignorant of where its bytes live.
|
|
51
|
-
*
|
|
52
|
-
* @param {object} params
|
|
53
|
-
* @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
|
|
54
|
-
* @param {number} params.fileSize
|
|
55
|
-
* @param {string} [params.label]
|
|
56
|
-
* @param {(start:number,end:number)=>Promise<Buffer|null>} [params.readHeld]
|
|
57
|
-
* @param {(start:number,end:number)=>boolean} [params.isHeld]
|
|
58
|
-
*/
|
|
59
|
-
constructor({ readRange, fileSize, label = "", readHeld, isHeld }) {
|
|
60
|
-
this.readRange = readRange;
|
|
61
|
-
this.fileSize = fileSize;
|
|
62
|
-
this.label = label;
|
|
63
|
-
this.readHeld = typeof readHeld === "function" ? readHeld : readRange;
|
|
64
|
-
this.isHeld = typeof isHeld === "function" ? isHeld : () => true;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Whether one of ffmpeg's banner streams and one of this container's tracks
|
|
70
|
-
* can be the same track.
|
|
71
|
-
*
|
|
72
|
-
* @param {{ language?: string, title?: string }} banner
|
|
73
|
-
* @param {{ language?: string, name?: string }} container
|
|
74
|
-
* @returns {boolean}
|
|
75
|
-
*/
|
|
76
|
-
static pairingHolds(banner, container) {
|
|
77
|
-
return pairingHolds(banner, container);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* ffmpeg's description of a file's subtitle streams, corrected by what the
|
|
82
|
-
* container itself declares.
|
|
83
|
-
*
|
|
84
|
-
* The banner cannot express the difference between "no track is marked" and
|
|
85
|
-
* "every track is marked", because Matroska's `FlagDefault` defaults to 1 and
|
|
86
|
-
* ffmpeg has already applied that default by the time it prints. The
|
|
87
|
-
* container can be asked, and this is where the two readings are lined up —
|
|
88
|
-
* by position, checked pair by pair rather than assumed.
|
|
89
|
-
*
|
|
90
|
-
* @param {object[]} bannerTracks
|
|
91
|
-
* @param {object[]} declared - This container's own subtitle tracks, in its order.
|
|
92
|
-
* @returns {object[]}
|
|
93
|
-
*/
|
|
94
|
-
/**
|
|
95
|
-
* The picture's facts, from the two readings that state them.
|
|
96
|
-
*
|
|
97
|
-
* Audio and subtitles have had this since the flags were first read from the
|
|
98
|
-
* file; video never did. Every figure the encode is planned from — the size,
|
|
99
|
-
* the frame rate, whether it is HDR, how many bits a sample carries — was
|
|
100
|
-
* taken from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
|
|
101
|
-
* declares was read and then used for nothing but a line in the log.
|
|
102
|
-
*
|
|
103
|
-
* Which reading wins is decided per field by what each one IS, and the rule
|
|
104
|
-
* is the one `readMediaInfo` already states: a fact the container DECLARES is
|
|
105
|
-
* read from the container; a fact only the media has is measured from the
|
|
106
|
-
* media.
|
|
107
|
-
*
|
|
108
|
-
* - the coded size and the frame rate are the BANNER's. Both are declared by
|
|
109
|
-
* the container as well, but what the encoder receives is what the decoder
|
|
110
|
-
* produced, and the ladder and the scale filter have to be sized to that. A
|
|
111
|
-
* container that declares something else is mis-declaring, and using its
|
|
112
|
-
* numbers would size the encode to a picture that never arrives;
|
|
113
|
-
* - the bit depth and the HDR signalling are the CONTAINER's where it states
|
|
114
|
-
* them. They are not properties of the decoded frames at all — they are the
|
|
115
|
-
* file saying how its samples are to be read — and ffmpeg prints them only
|
|
116
|
-
* as a side effect of naming a pixel format. HDR is not compared for
|
|
117
|
-
* disagreement: both sides give a boolean, and a boolean cannot say "I did
|
|
118
|
-
* not look";
|
|
119
|
-
* - the display size is the container's alone; the banner has no such field.
|
|
120
|
-
*
|
|
121
|
-
* Where only one side states a field, that side answers whatever the rule
|
|
122
|
-
* would have preferred. Where both state it and they DISAGREE, the
|
|
123
|
-
* disagreement is reported: it is a fact about the file, and until now
|
|
124
|
-
* nothing could see it.
|
|
125
|
-
*
|
|
126
|
-
* @param {{ width?: number|null, height?: number|null, fps?: number|null, isHdr?: boolean, bitDepth?: number|null }} banner
|
|
127
|
-
* @param {object | null} declared - The container's own `VideoTrack`.
|
|
128
|
-
* @returns {{ width: number|null, height: number|null, fps: number|null, isHdr: boolean, bitDepth: number|null, displayWidth: number|null, displayHeight: number|null, disagreements: string[] }}
|
|
129
|
-
*/
|
|
130
|
-
static mergeVideoFacts(banner, declared) {
|
|
131
|
-
const number = (value) => (Number.isFinite(value) && value > 0 ? Number(value) : null);
|
|
132
|
-
const fromBanner = {
|
|
133
|
-
width: number(banner?.width),
|
|
134
|
-
height: number(banner?.height),
|
|
135
|
-
fps: number(banner?.fps),
|
|
136
|
-
isHdr: banner?.isHdr === true,
|
|
137
|
-
bitDepth: number(banner?.bitDepth)
|
|
138
|
-
};
|
|
139
|
-
if (!declared) {
|
|
140
|
-
return { ...fromBanner, displayWidth: null, displayHeight: null, disagreements: [] };
|
|
141
|
-
}
|
|
142
|
-
const fromContainer = {
|
|
143
|
-
width: number(declared.width),
|
|
144
|
-
height: number(declared.height),
|
|
145
|
-
fps: number(declared.fps),
|
|
146
|
-
isHdr: declared.isHdr === true,
|
|
147
|
-
bitDepth: number(declared.bitDepth)
|
|
148
|
-
};
|
|
149
|
-
const disagreements = [];
|
|
150
|
-
const note = (field, mine, theirs) => {
|
|
151
|
-
if (mine !== null && theirs !== null && mine !== theirs) {
|
|
152
|
-
disagreements.push(`${field} ${theirs} in the container against ${mine} in the probe`);
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
note("width", fromBanner.width, fromContainer.width);
|
|
156
|
-
note("height", fromBanner.height, fromContainer.height);
|
|
157
|
-
if (fromBanner.bitDepth !== null && fromContainer.bitDepth !== null && fromBanner.bitDepth !== fromContainer.bitDepth) {
|
|
158
|
-
disagreements.push(
|
|
159
|
-
`bit depth ${fromContainer.bitDepth} in the container against ${fromBanner.bitDepth} in the probe`
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
// HDR is deliberately NOT compared. Both sides give it as a boolean, and a
|
|
163
|
-
// boolean cannot say "I did not look": a container with no Colour element
|
|
164
|
-
// and one that states SDR are the same `false`, as are a probe that printed
|
|
165
|
-
// no colour metadata and one that printed BT.709. Reporting that as a
|
|
166
|
-
// disagreement would report it on almost every file. Either side saying yes
|
|
167
|
-
// is taken as yes, which is the safe direction — the cost of tone mapping a
|
|
168
|
-
// picture that did not need it is smaller than showing a washed-out one.
|
|
169
|
-
return {
|
|
170
|
-
width: fromBanner.width ?? fromContainer.width,
|
|
171
|
-
height: fromBanner.height ?? fromContainer.height,
|
|
172
|
-
fps: fromBanner.fps ?? fromContainer.fps,
|
|
173
|
-
// The container declares these; the probe only reflects them.
|
|
174
|
-
bitDepth: fromContainer.bitDepth ?? fromBanner.bitDepth,
|
|
175
|
-
isHdr: fromContainer.isHdr || fromBanner.isHdr,
|
|
176
|
-
displayWidth: number(declared.displayWidth),
|
|
177
|
-
displayHeight: number(declared.displayHeight),
|
|
178
|
-
disagreements
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Line ffmpeg's banner up with what the container declares, and say whether the
|
|
184
|
-
* two are describing the same thing in the same order.
|
|
185
|
-
*
|
|
186
|
-
* ffmpeg numbers a file's streams `0:a:N` / `0:s:N` over every stream of that
|
|
187
|
-
* kind, in the order the container declares them; the container reading is a
|
|
188
|
-
* list in that same order. So position is the correspondence — but a position
|
|
189
|
-
* match that is merely assumed is worth nothing, and it is CHECKED: each pair
|
|
190
|
-
* has to agree on language or on title. One pair that agrees on neither, or a
|
|
191
|
-
* length that differs, means the two readings are not about the same thing, and
|
|
192
|
-
* then the container reading is not used AT ALL. A wrong flag is worse than a
|
|
193
|
-
* missing one, because `0:a:N` is what the encoder is given.
|
|
194
|
-
*
|
|
195
|
-
* One alignment for every media kind, because it is one rule. It was written
|
|
196
|
-
* twice — once for subtitles, once for audio — down to a `pairingHolds` that
|
|
197
|
-
* was byte-for-byte the same function under two names.
|
|
198
|
-
*
|
|
199
|
-
* @param {object[]} bannerTracks
|
|
200
|
-
* @param {object[]} declared
|
|
201
|
-
* @param {string} noun - "subtitle" or "audio"; only for the reason text.
|
|
202
|
-
* @returns {{ aligned: boolean, reason: string, banner: object[], container: object[] }}
|
|
203
|
-
*/
|
|
204
|
-
static alignWithBanner(bannerTracks, declared, noun) {
|
|
205
|
-
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
206
|
-
const container = Array.isArray(declared) ? declared : [];
|
|
207
|
-
if (banner.length === 0) {
|
|
208
|
-
return { aligned: false, reason: `the probe found no ${noun} stream`, banner, container };
|
|
209
|
-
}
|
|
210
|
-
if (container.length === 0) {
|
|
211
|
-
return { aligned: false, reason: `the container declares no ${noun} track`, banner, container };
|
|
212
|
-
}
|
|
213
|
-
if (container.length !== banner.length) {
|
|
214
|
-
return {
|
|
215
|
-
aligned: false,
|
|
216
|
-
reason: `the container declares ${container.length} ${noun} tracks and the probe found ${banner.length}`,
|
|
217
|
-
banner,
|
|
218
|
-
container
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
for (const [order, track] of banner.entries()) {
|
|
222
|
-
if (!Container.pairingHolds(track, container[order])) {
|
|
223
|
-
return {
|
|
224
|
-
aligned: false,
|
|
225
|
-
reason:
|
|
226
|
-
`${noun} ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
227
|
-
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
228
|
-
`${normalise(container[order]?.language) || "-"} in the container`,
|
|
229
|
-
banner,
|
|
230
|
-
container
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return { aligned: true, reason: "", banner, container };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* What the banner says, with the flags only the container knows added — or the
|
|
239
|
-
* banner alone where the two could not be lined up.
|
|
240
|
-
*
|
|
241
|
-
* `take` says which of the container's fields this kind of track wants, and is
|
|
242
|
-
* the only part that differs between them.
|
|
243
|
-
*
|
|
244
|
-
* @param {object[]} bannerTracks
|
|
245
|
-
* @param {object[]} declared
|
|
246
|
-
* @param {string} noun
|
|
247
|
-
* @param {(containerTrack: object, bannerTrack: object) => object} take
|
|
248
|
-
* @param {object} absent - The same fields as `take` returns, for a track whose
|
|
249
|
-
* container reading could not be used. Not "the container says no" — the
|
|
250
|
-
* container has not been heard from.
|
|
251
|
-
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
252
|
-
*/
|
|
253
|
-
static mergeDeclaredFlags(bannerTracks, declared, noun, take, absent) {
|
|
254
|
-
const { aligned, reason, banner, container } = Container.alignWithBanner(bannerTracks, declared, noun);
|
|
255
|
-
if (!aligned) {
|
|
256
|
-
return {
|
|
257
|
-
tracks: banner.map((track) => ({ ...track, ...absent })),
|
|
258
|
-
aligned,
|
|
259
|
-
reason
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
return {
|
|
263
|
-
tracks: banner.map((track, order) => ({ ...track, ...take(container[order], track) })),
|
|
264
|
-
aligned: true,
|
|
265
|
-
reason: ""
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
static mergeSubtitleFlags(bannerTracks, declared) {
|
|
270
|
-
return Container.mergeDeclaredFlags(
|
|
271
|
-
bannerTracks,
|
|
272
|
-
declared,
|
|
273
|
-
"subtitle",
|
|
274
|
-
(track) => ({
|
|
275
|
-
isDefault: track.isDefault === true,
|
|
276
|
-
declaresDefault: track.declaresDefault === true,
|
|
277
|
-
// Read from the file rather than guessed from the track's name. Both
|
|
278
|
-
// are stated by the container itself (RFC 9559 §5.1.4.1) and neither
|
|
279
|
-
// reaches ffmpeg's `-i` banner, which is where every other field here
|
|
280
|
-
// comes from.
|
|
281
|
-
isForced: track.isForced === true,
|
|
282
|
-
isHearingImpaired: track.isHearingImpaired === true,
|
|
283
|
-
// FlagEnabled, so the browser can leave an unusable track out of the
|
|
284
|
-
// menu. It stays in the list and keeps its number: ffmpeg creates a
|
|
285
|
-
// stream for it either way.
|
|
286
|
-
isEnabled: track.isEnabled !== false,
|
|
287
|
-
// The RFC 5646 tag where the file writes one, kept BESIDE the code
|
|
288
|
-
// rather than replacing it: this list is aligned against ffmpeg's
|
|
289
|
-
// banner, which prints the three-letter form.
|
|
290
|
-
languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : ""
|
|
291
|
-
}),
|
|
292
|
-
{
|
|
293
|
-
declaresDefault: false,
|
|
294
|
-
isForced: false,
|
|
295
|
-
isHearingImpaired: false,
|
|
296
|
-
isEnabled: true,
|
|
297
|
-
languageBcp47: ""
|
|
298
|
-
}
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* The same for audio, with the flags RFC 9559 §5.1.4.1 defines for it.
|
|
304
|
-
*
|
|
305
|
-
* `FlagOriginal`, `FlagCommentary` and `FlagVisualImpaired` do not appear in
|
|
306
|
-
* the banner at all, so without this the audio menu cannot tell a director's
|
|
307
|
-
* commentary from the film.
|
|
308
|
-
*
|
|
309
|
-
* @param {object[]} bannerTracks
|
|
310
|
-
* @param {object[]} declared
|
|
311
|
-
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
312
|
-
*/
|
|
313
|
-
static mergeAudioFlags(bannerTracks, declared) {
|
|
314
|
-
return Container.mergeDeclaredFlags(
|
|
315
|
-
bannerTracks,
|
|
316
|
-
declared,
|
|
317
|
-
"audio",
|
|
318
|
-
(track, banner) => ({
|
|
319
|
-
isOriginal: track.isOriginal === true,
|
|
320
|
-
isCommentary: track.isCommentary === true,
|
|
321
|
-
isVisualImpaired: track.isVisualImpaired === true,
|
|
322
|
-
isEnabled: track.isEnabled !== false,
|
|
323
|
-
isDefault: track.isDefault === true,
|
|
324
|
-
declaresDefault: track.declaresDefault === true,
|
|
325
|
-
languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
326
|
-
channels: Number.isFinite(track.channels) ? track.channels : null,
|
|
327
|
-
title:
|
|
328
|
-
typeof banner?.title === "string" && banner.title.length > 0
|
|
329
|
-
? banner.title
|
|
330
|
-
: (typeof track.name === "string" ? track.name : "")
|
|
331
|
-
}),
|
|
332
|
-
{
|
|
333
|
-
declaresDefault: false,
|
|
334
|
-
isOriginal: false,
|
|
335
|
-
isCommentary: false,
|
|
336
|
-
isVisualImpaired: false,
|
|
337
|
-
isEnabled: true,
|
|
338
|
-
languageBcp47: "",
|
|
339
|
-
channels: null
|
|
340
|
-
}
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
/** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
|
|
346
|
-
get formatName() {
|
|
347
|
-
return "unknown";
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** Whether `head` (first bytes) looks like this container. */
|
|
351
|
-
static detect(_head) {
|
|
352
|
-
return false;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* All tracks declared by the container, in container order.
|
|
357
|
-
* Includes disabled tracks (isEnabled=false) to preserve declaredIndex alignment with ffmpeg.
|
|
358
|
-
* @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
|
|
359
|
-
*/
|
|
360
|
-
async readTracks() {
|
|
361
|
-
throw new Error("readTracks not implemented");
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* This container's subtitle tracks and where their cues are, in ONE shape
|
|
367
|
-
* whichever container answers.
|
|
368
|
-
*
|
|
369
|
-
* `tracks` carries a `clusterPositions` list for a container that stores cues
|
|
370
|
-
* in clusters and a `samples` list for one that states each cue's own byte
|
|
371
|
-
* range; a caller reads neither, and asks {@link Container#readHeldCues}
|
|
372
|
-
* instead. `declared` is what the container says about its subtitle tracks in
|
|
373
|
-
* its own order, and empty means the container said nothing — a real answer,
|
|
374
|
-
* not a missing one.
|
|
375
|
-
*
|
|
376
|
-
* @returns {Promise<{ tracks: object[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
377
|
-
* Null where this container declares no subtitles at all.
|
|
378
|
-
*/
|
|
379
|
-
async readSubtitlePlan() {
|
|
380
|
-
return null;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* The cues this container can read RIGHT NOW for one track, without fetching.
|
|
385
|
-
*
|
|
386
|
-
* Every container answers this, and each reads what its own specification
|
|
387
|
-
* says: Matroska walks the clusters its Cues table names, an MP4 reads the
|
|
388
|
-
* samples its table states. The caller therefore chooses a container once —
|
|
389
|
-
* from the bytes — and never again. It used to choose twice, once by file
|
|
390
|
-
* extension for the container and once by whether a track carried a sample
|
|
391
|
-
* list for the reading, and two choices that must agree and are made from
|
|
392
|
-
* different evidence are a disagreement waiting to happen.
|
|
393
|
-
*
|
|
394
|
-
* `progress` is what has already been read, kept by the caller because it
|
|
395
|
-
* belongs to the file rather than to one pass: `walked` holds cluster
|
|
396
|
-
* positions, `harvested` holds sample offsets per track. Each container adds
|
|
397
|
-
* to the one it uses.
|
|
398
|
-
*
|
|
399
|
-
* @param {object} _plan - This file's subtitle plan.
|
|
400
|
-
* @param {object} _track - The track asked about.
|
|
401
|
-
* @param {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
|
|
402
|
-
* @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
|
|
403
|
-
* `found` is track number to the cues found in THIS pass — Matroska fills
|
|
404
|
-
* every track from one walk, so it is a map and not a list.
|
|
405
|
-
*/
|
|
406
|
-
async readHeldCues(_plan, _track, _progress) {
|
|
407
|
-
return { found: new Map(), covered: 0, indexed: 0 };
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Keyframe times for the video track, ascending seconds. Null when index
|
|
412
|
-
* absent (MPEG-TS, fragmented MP4, truncated).
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* The
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* and the format
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
*
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
*
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
) {
|
|
587
|
-
return true;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
(EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
|
|
598
|
-
(bannerTitle.length === 0 || containerName.length === 0)
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Base Container — abstract per RFC 9559 / ISO 14496-12.
|
|
3
|
+
*
|
|
4
|
+
* A Container knows how to read its own format's track table and index.
|
|
5
|
+
* Concrete containers (MatroskaContainer, Mp4Container, AviContainer) implement
|
|
6
|
+
* spec-specific parsing. All byte access goes through `readRange(start,end)` so
|
|
7
|
+
* the class works over torrent piece windows.
|
|
8
|
+
*
|
|
9
|
+
* Spec refs:
|
|
10
|
+
* - Matroska RFC 9559 §5: EBML, Segment, SeekHead, Tracks, Cues, Clusters
|
|
11
|
+
* - MP4 ISO/IEC 14496-12 §8: ftyp, moov, trak, tkhd, mdhd, hdlr, elng, stbl
|
|
12
|
+
* - AVI RIFF §: LIST hdrl, idx1
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What one file declares about itself. Every field is either a value the
|
|
17
|
+
* container states or `null`, which means the container does not state it —
|
|
18
|
+
* a defined absence, not "nobody has looked yet".
|
|
19
|
+
*
|
|
20
|
+
* @typedef {object} ContainerMediaInfo
|
|
21
|
+
* @property {string} format - "matroska" | "mp4" | "avi" | "unknown".
|
|
22
|
+
* @property {number | null} durationSeconds
|
|
23
|
+
* @property {number | null} startTimeSeconds - Where this file's own timeline
|
|
24
|
+
* begins. Two files of one release need not agree on it, and the difference
|
|
25
|
+
* is what keeps a soundtrack shipped separately aligned with its picture.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export class Container {
|
|
29
|
+
/**
|
|
30
|
+
* Two ways of reading, because two readers of one file want different things
|
|
31
|
+
* and the file is opened once.
|
|
32
|
+
*
|
|
33
|
+
* `readRange` fetches what is missing: the head and the track table are
|
|
34
|
+
* kilobytes, they are needed before anything can be offered, and the codec
|
|
35
|
+
* probe has already pulled the head of every file that plays. `readHeld`
|
|
36
|
+
* reads only what is already downloaded and asks the swarm for nothing, which
|
|
37
|
+
* is what the cue walk needs — turning subtitles on must not pull bytes the
|
|
38
|
+
* viewer is not waiting for. `isHeld` says whether a range can be read that
|
|
39
|
+
* way at all, so the walk can leave a cluster for next time instead of
|
|
40
|
+
* blocking on it.
|
|
41
|
+
*
|
|
42
|
+
* They are given per container rather than per call because a container is
|
|
43
|
+
* cached per file and both readers want the same parsed head. Where the
|
|
44
|
+
* caller supplies only `readRange`, the held reader is that one and every
|
|
45
|
+
* range counts as held — which is the right answer for a local file, and for
|
|
46
|
+
* a torrent it is the caller's job to say otherwise.
|
|
47
|
+
*
|
|
48
|
+
* The torrent is NOT passed in and must not be: `readHeld` and `isHeld` are
|
|
49
|
+
* the only two facts about it this layer needs, and reducing it to two
|
|
50
|
+
* functions is what keeps the container ignorant of where its bytes live.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} params
|
|
53
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
|
|
54
|
+
* @param {number} params.fileSize
|
|
55
|
+
* @param {string} [params.label]
|
|
56
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} [params.readHeld]
|
|
57
|
+
* @param {(start:number,end:number)=>boolean} [params.isHeld]
|
|
58
|
+
*/
|
|
59
|
+
constructor({ readRange, fileSize, label = "", readHeld, isHeld }) {
|
|
60
|
+
this.readRange = readRange;
|
|
61
|
+
this.fileSize = fileSize;
|
|
62
|
+
this.label = label;
|
|
63
|
+
this.readHeld = typeof readHeld === "function" ? readHeld : readRange;
|
|
64
|
+
this.isHeld = typeof isHeld === "function" ? isHeld : () => true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether one of ffmpeg's banner streams and one of this container's tracks
|
|
70
|
+
* can be the same track.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ language?: string, title?: string }} banner
|
|
73
|
+
* @param {{ language?: string, name?: string }} container
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
static pairingHolds(banner, container) {
|
|
77
|
+
return pairingHolds(banner, container);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ffmpeg's description of a file's subtitle streams, corrected by what the
|
|
82
|
+
* container itself declares.
|
|
83
|
+
*
|
|
84
|
+
* The banner cannot express the difference between "no track is marked" and
|
|
85
|
+
* "every track is marked", because Matroska's `FlagDefault` defaults to 1 and
|
|
86
|
+
* ffmpeg has already applied that default by the time it prints. The
|
|
87
|
+
* container can be asked, and this is where the two readings are lined up —
|
|
88
|
+
* by position, checked pair by pair rather than assumed.
|
|
89
|
+
*
|
|
90
|
+
* @param {object[]} bannerTracks
|
|
91
|
+
* @param {object[]} declared - This container's own subtitle tracks, in its order.
|
|
92
|
+
* @returns {object[]}
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* The picture's facts, from the two readings that state them.
|
|
96
|
+
*
|
|
97
|
+
* Audio and subtitles have had this since the flags were first read from the
|
|
98
|
+
* file; video never did. Every figure the encode is planned from — the size,
|
|
99
|
+
* the frame rate, whether it is HDR, how many bits a sample carries — was
|
|
100
|
+
* taken from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
|
|
101
|
+
* declares was read and then used for nothing but a line in the log.
|
|
102
|
+
*
|
|
103
|
+
* Which reading wins is decided per field by what each one IS, and the rule
|
|
104
|
+
* is the one `readMediaInfo` already states: a fact the container DECLARES is
|
|
105
|
+
* read from the container; a fact only the media has is measured from the
|
|
106
|
+
* media.
|
|
107
|
+
*
|
|
108
|
+
* - the coded size and the frame rate are the BANNER's. Both are declared by
|
|
109
|
+
* the container as well, but what the encoder receives is what the decoder
|
|
110
|
+
* produced, and the ladder and the scale filter have to be sized to that. A
|
|
111
|
+
* container that declares something else is mis-declaring, and using its
|
|
112
|
+
* numbers would size the encode to a picture that never arrives;
|
|
113
|
+
* - the bit depth and the HDR signalling are the CONTAINER's where it states
|
|
114
|
+
* them. They are not properties of the decoded frames at all — they are the
|
|
115
|
+
* file saying how its samples are to be read — and ffmpeg prints them only
|
|
116
|
+
* as a side effect of naming a pixel format. HDR is not compared for
|
|
117
|
+
* disagreement: both sides give a boolean, and a boolean cannot say "I did
|
|
118
|
+
* not look";
|
|
119
|
+
* - the display size is the container's alone; the banner has no such field.
|
|
120
|
+
*
|
|
121
|
+
* Where only one side states a field, that side answers whatever the rule
|
|
122
|
+
* would have preferred. Where both state it and they DISAGREE, the
|
|
123
|
+
* disagreement is reported: it is a fact about the file, and until now
|
|
124
|
+
* nothing could see it.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ width?: number|null, height?: number|null, fps?: number|null, isHdr?: boolean, bitDepth?: number|null }} banner
|
|
127
|
+
* @param {object | null} declared - The container's own `VideoTrack`.
|
|
128
|
+
* @returns {{ width: number|null, height: number|null, fps: number|null, isHdr: boolean, bitDepth: number|null, displayWidth: number|null, displayHeight: number|null, disagreements: string[] }}
|
|
129
|
+
*/
|
|
130
|
+
static mergeVideoFacts(banner, declared) {
|
|
131
|
+
const number = (value) => (Number.isFinite(value) && value > 0 ? Number(value) : null);
|
|
132
|
+
const fromBanner = {
|
|
133
|
+
width: number(banner?.width),
|
|
134
|
+
height: number(banner?.height),
|
|
135
|
+
fps: number(banner?.fps),
|
|
136
|
+
isHdr: banner?.isHdr === true,
|
|
137
|
+
bitDepth: number(banner?.bitDepth)
|
|
138
|
+
};
|
|
139
|
+
if (!declared) {
|
|
140
|
+
return { ...fromBanner, displayWidth: null, displayHeight: null, disagreements: [] };
|
|
141
|
+
}
|
|
142
|
+
const fromContainer = {
|
|
143
|
+
width: number(declared.width),
|
|
144
|
+
height: number(declared.height),
|
|
145
|
+
fps: number(declared.fps),
|
|
146
|
+
isHdr: declared.isHdr === true,
|
|
147
|
+
bitDepth: number(declared.bitDepth)
|
|
148
|
+
};
|
|
149
|
+
const disagreements = [];
|
|
150
|
+
const note = (field, mine, theirs) => {
|
|
151
|
+
if (mine !== null && theirs !== null && mine !== theirs) {
|
|
152
|
+
disagreements.push(`${field} ${theirs} in the container against ${mine} in the probe`);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
note("width", fromBanner.width, fromContainer.width);
|
|
156
|
+
note("height", fromBanner.height, fromContainer.height);
|
|
157
|
+
if (fromBanner.bitDepth !== null && fromContainer.bitDepth !== null && fromBanner.bitDepth !== fromContainer.bitDepth) {
|
|
158
|
+
disagreements.push(
|
|
159
|
+
`bit depth ${fromContainer.bitDepth} in the container against ${fromBanner.bitDepth} in the probe`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
// HDR is deliberately NOT compared. Both sides give it as a boolean, and a
|
|
163
|
+
// boolean cannot say "I did not look": a container with no Colour element
|
|
164
|
+
// and one that states SDR are the same `false`, as are a probe that printed
|
|
165
|
+
// no colour metadata and one that printed BT.709. Reporting that as a
|
|
166
|
+
// disagreement would report it on almost every file. Either side saying yes
|
|
167
|
+
// is taken as yes, which is the safe direction — the cost of tone mapping a
|
|
168
|
+
// picture that did not need it is smaller than showing a washed-out one.
|
|
169
|
+
return {
|
|
170
|
+
width: fromBanner.width ?? fromContainer.width,
|
|
171
|
+
height: fromBanner.height ?? fromContainer.height,
|
|
172
|
+
fps: fromBanner.fps ?? fromContainer.fps,
|
|
173
|
+
// The container declares these; the probe only reflects them.
|
|
174
|
+
bitDepth: fromContainer.bitDepth ?? fromBanner.bitDepth,
|
|
175
|
+
isHdr: fromContainer.isHdr || fromBanner.isHdr,
|
|
176
|
+
displayWidth: number(declared.displayWidth),
|
|
177
|
+
displayHeight: number(declared.displayHeight),
|
|
178
|
+
disagreements
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Line ffmpeg's banner up with what the container declares, and say whether the
|
|
184
|
+
* two are describing the same thing in the same order.
|
|
185
|
+
*
|
|
186
|
+
* ffmpeg numbers a file's streams `0:a:N` / `0:s:N` over every stream of that
|
|
187
|
+
* kind, in the order the container declares them; the container reading is a
|
|
188
|
+
* list in that same order. So position is the correspondence — but a position
|
|
189
|
+
* match that is merely assumed is worth nothing, and it is CHECKED: each pair
|
|
190
|
+
* has to agree on language or on title. One pair that agrees on neither, or a
|
|
191
|
+
* length that differs, means the two readings are not about the same thing, and
|
|
192
|
+
* then the container reading is not used AT ALL. A wrong flag is worse than a
|
|
193
|
+
* missing one, because `0:a:N` is what the encoder is given.
|
|
194
|
+
*
|
|
195
|
+
* One alignment for every media kind, because it is one rule. It was written
|
|
196
|
+
* twice — once for subtitles, once for audio — down to a `pairingHolds` that
|
|
197
|
+
* was byte-for-byte the same function under two names.
|
|
198
|
+
*
|
|
199
|
+
* @param {object[]} bannerTracks
|
|
200
|
+
* @param {object[]} declared
|
|
201
|
+
* @param {string} noun - "subtitle" or "audio"; only for the reason text.
|
|
202
|
+
* @returns {{ aligned: boolean, reason: string, banner: object[], container: object[] }}
|
|
203
|
+
*/
|
|
204
|
+
static alignWithBanner(bannerTracks, declared, noun) {
|
|
205
|
+
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
206
|
+
const container = Array.isArray(declared) ? declared : [];
|
|
207
|
+
if (banner.length === 0) {
|
|
208
|
+
return { aligned: false, reason: `the probe found no ${noun} stream`, banner, container };
|
|
209
|
+
}
|
|
210
|
+
if (container.length === 0) {
|
|
211
|
+
return { aligned: false, reason: `the container declares no ${noun} track`, banner, container };
|
|
212
|
+
}
|
|
213
|
+
if (container.length !== banner.length) {
|
|
214
|
+
return {
|
|
215
|
+
aligned: false,
|
|
216
|
+
reason: `the container declares ${container.length} ${noun} tracks and the probe found ${banner.length}`,
|
|
217
|
+
banner,
|
|
218
|
+
container
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
for (const [order, track] of banner.entries()) {
|
|
222
|
+
if (!Container.pairingHolds(track, container[order])) {
|
|
223
|
+
return {
|
|
224
|
+
aligned: false,
|
|
225
|
+
reason:
|
|
226
|
+
`${noun} ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
227
|
+
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
228
|
+
`${normalise(container[order]?.language) || "-"} in the container`,
|
|
229
|
+
banner,
|
|
230
|
+
container
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { aligned: true, reason: "", banner, container };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What the banner says, with the flags only the container knows added — or the
|
|
239
|
+
* banner alone where the two could not be lined up.
|
|
240
|
+
*
|
|
241
|
+
* `take` says which of the container's fields this kind of track wants, and is
|
|
242
|
+
* the only part that differs between them.
|
|
243
|
+
*
|
|
244
|
+
* @param {object[]} bannerTracks
|
|
245
|
+
* @param {object[]} declared
|
|
246
|
+
* @param {string} noun
|
|
247
|
+
* @param {(containerTrack: object, bannerTrack: object) => object} take
|
|
248
|
+
* @param {object} absent - The same fields as `take` returns, for a track whose
|
|
249
|
+
* container reading could not be used. Not "the container says no" — the
|
|
250
|
+
* container has not been heard from.
|
|
251
|
+
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
252
|
+
*/
|
|
253
|
+
static mergeDeclaredFlags(bannerTracks, declared, noun, take, absent) {
|
|
254
|
+
const { aligned, reason, banner, container } = Container.alignWithBanner(bannerTracks, declared, noun);
|
|
255
|
+
if (!aligned) {
|
|
256
|
+
return {
|
|
257
|
+
tracks: banner.map((track) => ({ ...track, ...absent })),
|
|
258
|
+
aligned,
|
|
259
|
+
reason
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
tracks: banner.map((track, order) => ({ ...track, ...take(container[order], track) })),
|
|
264
|
+
aligned: true,
|
|
265
|
+
reason: ""
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
static mergeSubtitleFlags(bannerTracks, declared) {
|
|
270
|
+
return Container.mergeDeclaredFlags(
|
|
271
|
+
bannerTracks,
|
|
272
|
+
declared,
|
|
273
|
+
"subtitle",
|
|
274
|
+
(track) => ({
|
|
275
|
+
isDefault: track.isDefault === true,
|
|
276
|
+
declaresDefault: track.declaresDefault === true,
|
|
277
|
+
// Read from the file rather than guessed from the track's name. Both
|
|
278
|
+
// are stated by the container itself (RFC 9559 §5.1.4.1) and neither
|
|
279
|
+
// reaches ffmpeg's `-i` banner, which is where every other field here
|
|
280
|
+
// comes from.
|
|
281
|
+
isForced: track.isForced === true,
|
|
282
|
+
isHearingImpaired: track.isHearingImpaired === true,
|
|
283
|
+
// FlagEnabled, so the browser can leave an unusable track out of the
|
|
284
|
+
// menu. It stays in the list and keeps its number: ffmpeg creates a
|
|
285
|
+
// stream for it either way.
|
|
286
|
+
isEnabled: track.isEnabled !== false,
|
|
287
|
+
// The RFC 5646 tag where the file writes one, kept BESIDE the code
|
|
288
|
+
// rather than replacing it: this list is aligned against ffmpeg's
|
|
289
|
+
// banner, which prints the three-letter form.
|
|
290
|
+
languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : ""
|
|
291
|
+
}),
|
|
292
|
+
{
|
|
293
|
+
declaresDefault: false,
|
|
294
|
+
isForced: false,
|
|
295
|
+
isHearingImpaired: false,
|
|
296
|
+
isEnabled: true,
|
|
297
|
+
languageBcp47: ""
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The same for audio, with the flags RFC 9559 §5.1.4.1 defines for it.
|
|
304
|
+
*
|
|
305
|
+
* `FlagOriginal`, `FlagCommentary` and `FlagVisualImpaired` do not appear in
|
|
306
|
+
* the banner at all, so without this the audio menu cannot tell a director's
|
|
307
|
+
* commentary from the film.
|
|
308
|
+
*
|
|
309
|
+
* @param {object[]} bannerTracks
|
|
310
|
+
* @param {object[]} declared
|
|
311
|
+
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
312
|
+
*/
|
|
313
|
+
static mergeAudioFlags(bannerTracks, declared) {
|
|
314
|
+
return Container.mergeDeclaredFlags(
|
|
315
|
+
bannerTracks,
|
|
316
|
+
declared,
|
|
317
|
+
"audio",
|
|
318
|
+
(track, banner) => ({
|
|
319
|
+
isOriginal: track.isOriginal === true,
|
|
320
|
+
isCommentary: track.isCommentary === true,
|
|
321
|
+
isVisualImpaired: track.isVisualImpaired === true,
|
|
322
|
+
isEnabled: track.isEnabled !== false,
|
|
323
|
+
isDefault: track.isDefault === true,
|
|
324
|
+
declaresDefault: track.declaresDefault === true,
|
|
325
|
+
languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
326
|
+
channels: Number.isFinite(track.channels) ? track.channels : null,
|
|
327
|
+
title:
|
|
328
|
+
typeof banner?.title === "string" && banner.title.length > 0
|
|
329
|
+
? banner.title
|
|
330
|
+
: (typeof track.name === "string" ? track.name : "")
|
|
331
|
+
}),
|
|
332
|
+
{
|
|
333
|
+
declaresDefault: false,
|
|
334
|
+
isOriginal: false,
|
|
335
|
+
isCommentary: false,
|
|
336
|
+
isVisualImpaired: false,
|
|
337
|
+
isEnabled: true,
|
|
338
|
+
languageBcp47: "",
|
|
339
|
+
channels: null
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
/** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
|
|
346
|
+
get formatName() {
|
|
347
|
+
return "unknown";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Whether `head` (first bytes) looks like this container. */
|
|
351
|
+
static detect(_head) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* All tracks declared by the container, in container order.
|
|
357
|
+
* Includes disabled tracks (isEnabled=false) to preserve declaredIndex alignment with ffmpeg.
|
|
358
|
+
* @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
|
|
359
|
+
*/
|
|
360
|
+
async readTracks() {
|
|
361
|
+
throw new Error("readTracks not implemented");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* This container's subtitle tracks and where their cues are, in ONE shape
|
|
367
|
+
* whichever container answers.
|
|
368
|
+
*
|
|
369
|
+
* `tracks` carries a `clusterPositions` list for a container that stores cues
|
|
370
|
+
* in clusters and a `samples` list for one that states each cue's own byte
|
|
371
|
+
* range; a caller reads neither, and asks {@link Container#readHeldCues}
|
|
372
|
+
* instead. `declared` is what the container says about its subtitle tracks in
|
|
373
|
+
* its own order, and empty means the container said nothing — a real answer,
|
|
374
|
+
* not a missing one.
|
|
375
|
+
*
|
|
376
|
+
* @returns {Promise<{ tracks: object[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
377
|
+
* Null where this container declares no subtitles at all.
|
|
378
|
+
*/
|
|
379
|
+
async readSubtitlePlan() {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The cues this container can read RIGHT NOW for one track, without fetching.
|
|
385
|
+
*
|
|
386
|
+
* Every container answers this, and each reads what its own specification
|
|
387
|
+
* says: Matroska walks the clusters its Cues table names, an MP4 reads the
|
|
388
|
+
* samples its table states. The caller therefore chooses a container once —
|
|
389
|
+
* from the bytes — and never again. It used to choose twice, once by file
|
|
390
|
+
* extension for the container and once by whether a track carried a sample
|
|
391
|
+
* list for the reading, and two choices that must agree and are made from
|
|
392
|
+
* different evidence are a disagreement waiting to happen.
|
|
393
|
+
*
|
|
394
|
+
* `progress` is what has already been read, kept by the caller because it
|
|
395
|
+
* belongs to the file rather than to one pass: `walked` holds cluster
|
|
396
|
+
* positions, `harvested` holds sample offsets per track. Each container adds
|
|
397
|
+
* to the one it uses.
|
|
398
|
+
*
|
|
399
|
+
* @param {object} _plan - This file's subtitle plan.
|
|
400
|
+
* @param {object} _track - The track asked about.
|
|
401
|
+
* @param {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
|
|
402
|
+
* @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
|
|
403
|
+
* `found` is track number to the cues found in THIS pass — Matroska fills
|
|
404
|
+
* every track from one walk, so it is a map and not a list.
|
|
405
|
+
*/
|
|
406
|
+
async readHeldCues(_plan, _track, _progress) {
|
|
407
|
+
return { found: new Map(), covered: 0, indexed: 0 };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Keyframe times for the video track, ascending seconds. Null when index
|
|
412
|
+
* absent (MPEG-TS, fragmented MP4, truncated).
|
|
413
|
+
*
|
|
414
|
+
* **It reads, and does not remember.** It used to keep the answer, and that
|
|
415
|
+
* made the table the one fact in this proxy stored in two places: here, and
|
|
416
|
+
* in the file's `KeyframeTable` on the main thread. Traced 2026-09-15, this
|
|
417
|
+
* copy had no reader: nothing ever asked a container twice for its keyframes.
|
|
418
|
+
* The parse itself has since moved to the thread the sessions are on, so
|
|
419
|
+
* there is no longer a second thread for a second copy to sit in either.
|
|
420
|
+
*
|
|
421
|
+
* The remembering lives where the fact is USED, which is also the only place
|
|
422
|
+
* that can do the rest of the job: bound how long a viewer waits for it
|
|
423
|
+
* while letting the read go on, join a second asker to a read already
|
|
424
|
+
* running, and keep the answer reachable by a session created before it
|
|
425
|
+
* landed. A container cannot do any of those — it does not know there is a
|
|
426
|
+
* viewer — so a copy here was never the whole answer, only a second one.
|
|
427
|
+
*
|
|
428
|
+
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
429
|
+
*/
|
|
430
|
+
async readKeyframeIndex() {
|
|
431
|
+
return this.parseKeyframeIndex();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* How THIS format states where its keyframes are. Overridden by every
|
|
436
|
+
* container that has such a table; the default is the honest answer for one
|
|
437
|
+
* that does not.
|
|
438
|
+
*
|
|
439
|
+
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
440
|
+
*/
|
|
441
|
+
async parseKeyframeIndex() {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* What this file DECLARES about itself as a whole, as distinct from what its
|
|
447
|
+
* individual tracks declare.
|
|
448
|
+
*
|
|
449
|
+
* The rule this method exists to hold: a fact the container declares is read
|
|
450
|
+
* from the container; a fact only the media itself has is measured from the
|
|
451
|
+
* media. Both halves used to be asked of ffmpeg, so the same header was read
|
|
452
|
+
* twice — measured 2026-09-03, this layer read one `.mka` header in 8 ms while
|
|
453
|
+
* a second ffmpeg read the same header over HTTP for 8121 ms, in the same
|
|
454
|
+
* second, on the same file.
|
|
455
|
+
*
|
|
456
|
+
* `null` is not "unknown, ask someone else". It means the container does not
|
|
457
|
+
* declare the field, which is a final answer about the container and the point
|
|
458
|
+
* at which a caller may go to the media — see `docs/container-architecture.md`.
|
|
459
|
+
*
|
|
460
|
+
* @returns {Promise<import("./Container.js").ContainerMediaInfo>}
|
|
461
|
+
*/
|
|
462
|
+
async readMediaInfo() {
|
|
463
|
+
if (!this.mediaInfo) {
|
|
464
|
+
this.mediaInfo = {
|
|
465
|
+
format: this.formatName,
|
|
466
|
+
durationSeconds: null,
|
|
467
|
+
startTimeSeconds: null
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return this.mediaInfo;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Subtitle-specific: where cues live (Matroska cluster positions or MP4 sample ranges).
|
|
475
|
+
* Returned via track objects' clusterPositions/samples, so base has no extra method — tracks carry it.
|
|
476
|
+
*/
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The TEXT FIELD of one subtitle cue, taken out of this container's framing.
|
|
480
|
+
*
|
|
481
|
+
* How a cue's bytes are wrapped is stated by the container's own
|
|
482
|
+
* specification, so each subclass answers for itself: Matroska reorders an ASS
|
|
483
|
+
* dialogue row, drops its two timing fields and prepends a read order
|
|
484
|
+
* (`matroska.org/technical/subtitles.html`); an MP4 prefixes a `tx3g` sample
|
|
485
|
+
* with its length (ISO/IEC 14496-12 §12.6); a subtitle FILE states its own
|
|
486
|
+
* field order in `[Events]`. None of that is a fact about the subtitle format,
|
|
487
|
+
* and the format's own markup — `{\pos(…)}`, `\N` — is not a fact about the
|
|
488
|
+
* container. The second half is `TextSubtitleTrack`; this is the
|
|
489
|
+
* first, and the two are applied in that order.
|
|
490
|
+
*
|
|
491
|
+
* Static because de-framing reads no instance state: a caller that has bytes
|
|
492
|
+
* and knows the format needs no container built over the whole file. The
|
|
493
|
+
* instance form below exists so a caller that DOES hold a container gets the
|
|
494
|
+
* right answer without naming the subclass.
|
|
495
|
+
*
|
|
496
|
+
* @param {Buffer} _payload - The cue's bytes as the container stores them.
|
|
497
|
+
* @param {string} _codecId - CodecID / sample entry type / file extension.
|
|
498
|
+
* @returns {string} The text field, markup still in place.
|
|
499
|
+
*/
|
|
500
|
+
static cueTextOf(_payload, _codecId) {
|
|
501
|
+
throw new Error("cueTextOf not implemented");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* @param {Buffer} payload
|
|
506
|
+
* @param {string} codecId
|
|
507
|
+
* @returns {string}
|
|
508
|
+
*/
|
|
509
|
+
cueTextOf(payload, codecId) {
|
|
510
|
+
return /** @type {typeof Container} */ (this.constructor).cueTextOf(payload, codecId);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Lining ffmpeg's banner up with what a container declares. Here because the
|
|
516
|
+
// correction is about what a CONTAINER states and the banner cannot.
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
/**
|
|
519
|
+
* Which subtitle track the FILE says to show, read from the file rather than
|
|
520
|
+
* from ffmpeg's description of it.
|
|
521
|
+
*
|
|
522
|
+
* Why this exists. The browser decides which subtitle track to turn on from
|
|
523
|
+
* `isDefault`, and until now that came from ffmpeg's `-i` banner, which prints
|
|
524
|
+
* `(default)`. In Matroska `FlagDefault` DEFAULTS TO 1 and ffmpeg has already
|
|
525
|
+
* applied that default by the time it prints — so a file whose muxer wrote the
|
|
526
|
+
* flag on no track arrives looking exactly like one that wrote it on every
|
|
527
|
+
* track: everything marked. The banner cannot tell the two apart, and the
|
|
528
|
+
* difference is the whole question, because one of them means "show this one"
|
|
529
|
+
* and the other means "the file has no opinion".
|
|
530
|
+
*
|
|
531
|
+
* The container itself can be asked, and the EBML reader already walks the
|
|
532
|
+
* Tracks element for subtitle extraction. What it now also records is whether
|
|
533
|
+
* the element was WRITTEN, which is the fact the banner destroys.
|
|
534
|
+
*
|
|
535
|
+
* The awkward part is lining the two readings up. ffmpeg numbers its subtitle
|
|
536
|
+
* streams `0:s:0`, `0:s:1`, … over EVERY subtitle stream, picture-based ones
|
|
537
|
+
* included, in the order the container declares them; the container reading is
|
|
538
|
+
* a list in that same order. So position is the correspondence — but a position
|
|
539
|
+
* match that is merely assumed is worth nothing, so it is CHECKED: each pair
|
|
540
|
+
* has to agree on language or on title. One pair that agrees on neither, or a
|
|
541
|
+
* length that differs, means the two readings are not describing the same
|
|
542
|
+
* thing in the same order, and then the container reading is not used at all.
|
|
543
|
+
*/
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Language codes that carry no information, and so cannot confirm a pairing.
|
|
547
|
+
*
|
|
548
|
+
* ffmpeg prints `und` for a stream with no language; Matroska's own default
|
|
549
|
+
* for `Language` is `eng`, which is why an absent element cannot be read as a
|
|
550
|
+
* statement either — but `eng` is also a real answer, so it is not listed here
|
|
551
|
+
* and is compared like any other.
|
|
552
|
+
*/
|
|
553
|
+
const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @param {unknown} value
|
|
557
|
+
* @returns {string}
|
|
558
|
+
*/
|
|
559
|
+
function normalise(value) {
|
|
560
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Whether one banner stream and one container track can be the same track.
|
|
565
|
+
*
|
|
566
|
+
* Agreement on either the language or the name is enough; both being empty is
|
|
567
|
+
* not agreement, because two tracks that say nothing about themselves say
|
|
568
|
+
* nothing about their pairing either.
|
|
569
|
+
*
|
|
570
|
+
* @param {{ language?: string, title?: string }} banner
|
|
571
|
+
* @param {{ language?: string, name?: string }} container
|
|
572
|
+
* @returns {boolean}
|
|
573
|
+
*/
|
|
574
|
+
function pairingHolds(banner, container) {
|
|
575
|
+
const bannerLanguage = normalise(banner?.language);
|
|
576
|
+
const containerLanguage = normalise(container?.language);
|
|
577
|
+
if (
|
|
578
|
+
!EMPTY_LANGUAGES.has(bannerLanguage) &&
|
|
579
|
+
!EMPTY_LANGUAGES.has(containerLanguage) &&
|
|
580
|
+
bannerLanguage === containerLanguage
|
|
581
|
+
) {
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
const bannerTitle = normalise(banner?.title);
|
|
585
|
+
const containerName = normalise(container?.name);
|
|
586
|
+
if (bannerTitle.length > 0 && bannerTitle === containerName) {
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
// Nothing to compare on either side. Not a disagreement — a file may name
|
|
590
|
+
// neither — so it does not break the alignment; it simply adds no support.
|
|
591
|
+
return (
|
|
592
|
+
(EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
|
|
593
|
+
(bannerTitle.length === 0 || containerName.length === 0)
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|