@torrent-tv/proxy 2.87.21 → 2.88.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/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +4 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/browser-proxy-contract.md +133 -133
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +50 -50
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +106 -100
- 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 +173 -173
- 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 +380 -380
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/server.js +725 -725
- 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 +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +813 -813
- package/services/encode/EncodeRuns.js +1235 -1235
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +758 -758
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2184 -2184
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1141 -1141
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +315 -315
- 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 +754 -754
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +456 -456
- package/services/encode/segment-formats/index.js +85 -85
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +515 -515
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +245 -245
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +931 -931
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1150 -1150
- package/services/server/ViewerRequests.js +760 -760
- 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/playback-readiness.js +696 -696
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +992 -992
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +539 -539
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1931 -1931
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1240 -1240
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +382 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/episode-naming.js +182 -0
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2675 -2675
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +765 -765
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +371 -371
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/torrent/worker/worker.js +1129 -1129
- package/services/transport/data-channel-handler.js +1369 -1369
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +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/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-inventory.test.js +212 -212
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/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 +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +162 -162
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/episode-naming.test.js +131 -0
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-presentation-clock.test.js +147 -147
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +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 +453 -453
- package/test/playback-start-regressions.test.js +99 -99
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +12 -2
- 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 +209 -209
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/utils/torrent-source-key.js +38 -38
|
@@ -1,1244 +1,1244 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file MP4/MOV container — ISO/IEC 14496-12.
|
|
3
|
-
*
|
|
4
|
-
* Parses moov for all track types in one walk:
|
|
5
|
-
* - tkhd: track_ID, flags track_enabled (0x000001), alternate_group, width/height
|
|
6
|
-
* - mdhd: timescale, language (packed 5-bit), version handling
|
|
7
|
-
* - hdlr: handler_type (vide/soun/text/sbtl/subt/subp/clcp)
|
|
8
|
-
* - elng: extendedLanguage BCP47 (when present, replaces mdhd language per spec)
|
|
9
|
-
* - stsd: sample entry format (avc1/hev1/mp4a/tx3g/wvtt/stpp)
|
|
10
|
-
* - stbl tables for subtitle cue ranges (stts/stsz/stsc/stco/co64) and video keyframes (stss/stts/ctts/elst)
|
|
11
|
-
*
|
|
12
|
-
* Keyframe reading is delegated to mp4.js. The subtitle sample table is read in
|
|
13
|
-
* this module, because every rule in it is a statement of ISO/IEC 14496-12 about
|
|
14
|
-
* this container. Track creation is centralised here so that track_enabled,
|
|
15
|
-
* `elng` and alternate_group are handled once for every media type.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { Container } from "./Container.js";
|
|
19
|
-
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
20
|
-
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
21
|
-
import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
|
|
22
|
-
import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
|
|
23
|
-
|
|
24
|
-
/** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
|
|
25
|
-
const HEADER_BYTES = 8;
|
|
26
|
-
const LARGE_SIZE_MARKER = 1;
|
|
27
|
-
const LARGE_HEADER_BYTES = 16;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* One box header, per ISO/IEC 14496-12 §4.2.
|
|
31
|
-
*
|
|
32
|
-
* @param {Buffer} buf
|
|
33
|
-
* @param {number} off
|
|
34
|
-
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
35
|
-
*/
|
|
36
|
-
function readBox(buffer, offset) {
|
|
37
|
-
if (offset + HEADER_BYTES > buffer.length) {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
let size = buffer.readUInt32BE(offset);
|
|
41
|
-
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
42
|
-
let headerBytes = HEADER_BYTES;
|
|
43
|
-
// A size of 1 means the real size is the 64-bit value after the type.
|
|
44
|
-
if (size === LARGE_SIZE_MARKER) {
|
|
45
|
-
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
size = Number(buffer.readBigUInt64BE(offset + HEADER_BYTES));
|
|
49
|
-
headerBytes = LARGE_HEADER_BYTES;
|
|
50
|
-
}
|
|
51
|
-
if (size < headerBytes) {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
return { type, size, dataOffset: offset + headerBytes, end: offset + size };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Every direct child box of the given type.
|
|
59
|
-
*
|
|
60
|
-
* @param {Buffer} buf
|
|
61
|
-
* @param {number} start
|
|
62
|
-
* @param {number} end
|
|
63
|
-
* @param {string} type
|
|
64
|
-
* @returns {Array<{ type: string, size: number, dataOffset: number, end: number }>}
|
|
65
|
-
*/
|
|
66
|
-
function childrenOf(buf, start, end, type) {
|
|
67
|
-
const out = [];
|
|
68
|
-
let p = start;
|
|
69
|
-
while (p + 8 <= end) {
|
|
70
|
-
const b = readBox(buf, p);
|
|
71
|
-
if (!b) break;
|
|
72
|
-
if (b.type === type) out.push(b);
|
|
73
|
-
p = b.end;
|
|
74
|
-
}
|
|
75
|
-
return out;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The first direct child box of the given type, or null.
|
|
80
|
-
*
|
|
81
|
-
* @param {Buffer} buf
|
|
82
|
-
* @param {number} s
|
|
83
|
-
* @param {number} e
|
|
84
|
-
* @param {string} t
|
|
85
|
-
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
86
|
-
*/
|
|
87
|
-
function childOf(buf, s, e, t) {
|
|
88
|
-
return childrenOf(buf, s, e, t)[0] ?? null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export class Mp4Container extends Container {
|
|
92
|
-
get formatName() {
|
|
93
|
-
return "mp4";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
static detect(head) {
|
|
97
|
-
return isMp4(head);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The keyframe times this container's own index states, in ascending seconds.
|
|
102
|
-
*
|
|
103
|
-
* Static so a caller that has bytes and no container can ask; the instance
|
|
104
|
-
* form is {@link Container#readKeyframeIndex}.
|
|
105
|
-
*
|
|
106
|
-
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
107
|
-
* @param {number} fileSize
|
|
108
|
-
* @returns {Promise<number[]|null>} Null where the container has no index.
|
|
109
|
-
*/
|
|
110
|
-
static readKeyframeTimes(readRange, fileSize) {
|
|
111
|
-
return readMp4KeyframeTimes(readRange, fileSize);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* This container's text subtitle tracks, with every cue's time and byte
|
|
116
|
-
* range — ISO/IEC 14496-12 §8.5 and §8.7.
|
|
117
|
-
*
|
|
118
|
-
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
119
|
-
* @param {number} fileSize
|
|
120
|
-
* @returns {Promise<{ tracks: object[] } | null>}
|
|
121
|
-
*/
|
|
122
|
-
static readSubtitlePlan(readRange, fileSize) {
|
|
123
|
-
return readMp4SubtitlePlan(readRange, fileSize);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* The same reading, over the file this container was built on.
|
|
128
|
-
*
|
|
129
|
-
* The static form exists for a caller that has bytes and no container; this
|
|
130
|
-
* is the one to use otherwise, because the reader is already here.
|
|
131
|
-
*
|
|
132
|
-
* @returns {Promise<object|null>}
|
|
133
|
-
*/
|
|
134
|
-
async readSubtitlePlan() {
|
|
135
|
-
const plan = await Mp4Container.readSubtitlePlan(this.readRange, this.fileSize);
|
|
136
|
-
if (!plan) {
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
// An MP4 states every sample's byte range in its own table, so a cue costs
|
|
140
|
-
// its own few dozen bytes rather than the cluster around it — which is why
|
|
141
|
-
// there are `samples` here and no `clusterPositions`.
|
|
142
|
-
//
|
|
143
|
-
// An MP4 has no element meaning "show this subtitle track by default", so
|
|
144
|
-
// `declared` is empty: the container states nothing, and nothing is shown
|
|
145
|
-
// unasked.
|
|
146
|
-
return {
|
|
147
|
-
tracks: plan.tracks.map((track, order) => ({
|
|
148
|
-
trackNumber: track.trackId,
|
|
149
|
-
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
150
|
-
codecId: track.format,
|
|
151
|
-
language: track.language,
|
|
152
|
-
name: "",
|
|
153
|
-
isDefault: order === 0,
|
|
154
|
-
codecPrivate: "",
|
|
155
|
-
clusterPositions: [],
|
|
156
|
-
samples: track.samples
|
|
157
|
-
})),
|
|
158
|
-
declared: [],
|
|
159
|
-
secondsPerTick: 0.001,
|
|
160
|
-
segmentDataOffset: 0
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Read the samples of one subtitle track that are HELD now.
|
|
167
|
-
*
|
|
168
|
-
* An MP4 states every sample's byte range in its own table, so a cue costs its
|
|
169
|
-
* own few dozen bytes rather than the cluster around it — which is why this
|
|
170
|
-
* reads per sample where Matroska reads per cluster.
|
|
171
|
-
*
|
|
172
|
-
* Nothing is fetched: a sample whose bytes are not downloaded is left for the
|
|
173
|
-
* next call.
|
|
174
|
-
*
|
|
175
|
-
* @param {{ codecId: string, samples: {offset: number, size: number, startSeconds: number, endSeconds: number}[] }} track
|
|
176
|
-
* @param {Set<number>} harvested - Sample offsets already read; added to.
|
|
177
|
-
* @returns {Promise<{startSeconds: number, endSeconds: number, text: string}[]>}
|
|
178
|
-
* The cues found in THIS pass.
|
|
179
|
-
*/
|
|
180
|
-
async readHeldCues(_plan, track, progress) {
|
|
181
|
-
let harvested = progress.harvested.get(track.trackNumber);
|
|
182
|
-
if (!harvested) {
|
|
183
|
-
harvested = new Set();
|
|
184
|
-
progress.harvested.set(track.trackNumber, harvested);
|
|
185
|
-
}
|
|
186
|
-
const cues = await this.readHeldSamples(track, harvested);
|
|
187
|
-
return {
|
|
188
|
-
found: cues.length > 0 ? new Map([[track.trackNumber, cues]]) : new Map(),
|
|
189
|
-
covered: harvested.size,
|
|
190
|
-
indexed: track?.samples?.length ?? 0
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async readHeldSamples(track, harvested) {
|
|
195
|
-
const found = [];
|
|
196
|
-
for (const sample of track?.samples ?? []) {
|
|
197
|
-
if (harvested.has(sample.offset)) {
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
const last = Math.min(this.fileSize - 1, sample.offset + sample.size - 1);
|
|
201
|
-
if (!this.isHeld(sample.offset, last)) {
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
const bytes = await this.readHeld(sample.offset, last);
|
|
205
|
-
if (!bytes) {
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
harvested.add(sample.offset);
|
|
209
|
-
// The MP4 has framed this cue and is the one that unframes it.
|
|
210
|
-
const text = Mp4Container.cueTextOf(bytes, track.codecId);
|
|
211
|
-
if (text) {
|
|
212
|
-
found.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return found;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async readTracks() {
|
|
219
|
-
const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
|
|
220
|
-
if (!head || !isMp4(head)) return [];
|
|
221
|
-
|
|
222
|
-
// Use the subtitle plan reader's moov parsing as source for subtitle tracks,
|
|
223
|
-
// and a direct moov walk for video/audio to collect tkhd/mdhd/hdlr/elng uniformly.
|
|
224
|
-
// For simplicity, delegate entirely to readMp4SubtitlePlan for subtitles and
|
|
225
|
-
// do a lightweight moov walk for video/audio here — then merge.
|
|
226
|
-
const subtitlePlan = await readMp4SubtitlePlan(this.readRange, this.fileSize).catch(() => null);
|
|
227
|
-
const subtitleByDecl = new Map();
|
|
228
|
-
if (subtitlePlan?.tracks) {
|
|
229
|
-
for (const t of subtitlePlan.tracks) subtitleByDecl.set(t.declaredIndex, t);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Minimal moov walk for video/audio: reuse isMp4 + findMoov logic by reading via existing helper
|
|
233
|
-
// Instead of duplicating, parse tracks via a second full moov read that collects vide/soun.
|
|
234
|
-
// We read moov box directly to extract video/audio tracks.
|
|
235
|
-
const tracks = await this.#readVideoAudioTracks();
|
|
236
|
-
// Append subtitle tracks from plan, converting to domain objects
|
|
237
|
-
if (subtitlePlan?.tracks) {
|
|
238
|
-
for (const s of subtitlePlan.tracks) {
|
|
239
|
-
const isText = TEXT_FORMATS_MP4.has(s.format);
|
|
240
|
-
const Cls = isText ? TextSubtitleTrack : ImageSubtitleTrack;
|
|
241
|
-
tracks.push(new Cls({
|
|
242
|
-
trackNumber: s.trackId,
|
|
243
|
-
declaredIndex: s.declaredIndex,
|
|
244
|
-
codecId: s.format,
|
|
245
|
-
language: s.language,
|
|
246
|
-
languageBcp47: "",
|
|
247
|
-
name: "",
|
|
248
|
-
isEnabled: true,
|
|
249
|
-
isDefault: s.declaredIndex === 0,
|
|
250
|
-
declaresDefault: false,
|
|
251
|
-
codecPrivateB64: "",
|
|
252
|
-
// The sample entry's own words, read below. Either
|
|
253
|
-
// bit is enough: a file that sets only "all samples are forced" is
|
|
254
|
-
// saying what a well-formed one says twice.
|
|
255
|
-
isForced: s.someSamplesForced === true || s.allSamplesForced === true,
|
|
256
|
-
isHearingImpaired: false,
|
|
257
|
-
clusterPositions: [],
|
|
258
|
-
samples: s.samples
|
|
259
|
-
}));
|
|
260
|
-
}
|
|
261
|
-
// Count non-text subtitle handlers (subp/clcp/stpp) for declaredIndex correctness — they are already
|
|
262
|
-
// accounted for in subtitlePlan's declaredIndex via SUBTITLE_HANDLERS, but we didn't create objects for
|
|
263
|
-
// them above when they were stpp (non-text not in plan's tracks). The plan already excludes stpp from tracks
|
|
264
|
-
// but increments declaredIndex, so alignment holds: we don't need extra placeholders.
|
|
265
|
-
}
|
|
266
|
-
return tracks;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* The `moov` box, read whole.
|
|
271
|
-
*
|
|
272
|
-
* Held on the instance because every question this class answers is inside
|
|
273
|
-
* it, and the box can be tens of megabytes off a torrent — reading it once
|
|
274
|
-
* per file is the difference between one fetch and one per question.
|
|
275
|
-
*
|
|
276
|
-
* @returns {Promise<{ moov: Buffer, header: number } | null>}
|
|
277
|
-
*/
|
|
278
|
-
async #moovBuffer() {
|
|
279
|
-
if (this.moovHeld !== undefined) {
|
|
280
|
-
return this.moovHeld;
|
|
281
|
-
}
|
|
282
|
-
const PROBE = 64;
|
|
283
|
-
const MAX_MOOV = 32 * 1024 * 1024;
|
|
284
|
-
let at = 0;
|
|
285
|
-
let moovBox = null;
|
|
286
|
-
while (at < this.fileSize) {
|
|
287
|
-
const probe = await this.readRange(at, Math.min(this.fileSize - 1, at + PROBE - 1));
|
|
288
|
-
if (!probe || probe.length < 8) break;
|
|
289
|
-
let size = probe.readUInt32BE(0);
|
|
290
|
-
const type = probe.toString("latin1", 4, 8);
|
|
291
|
-
let header = 8;
|
|
292
|
-
if (size === 1) {
|
|
293
|
-
if (probe.length < 16) break;
|
|
294
|
-
size = Number(probe.readBigUInt64BE(8));
|
|
295
|
-
header = 16;
|
|
296
|
-
}
|
|
297
|
-
if (size <= 0) break;
|
|
298
|
-
if (type === "moov") { moovBox = { offset: at, size, header }; break; }
|
|
299
|
-
at += size;
|
|
300
|
-
}
|
|
301
|
-
if (!moovBox || moovBox.size > MAX_MOOV) {
|
|
302
|
-
this.moovHeld = null;
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
const moov = await this.readRange(moovBox.offset, Math.min(this.fileSize - 1, moovBox.offset + moovBox.size - 1));
|
|
306
|
-
this.moovHeld = moov ? { moov, header: moovBox.header } : null;
|
|
307
|
-
return this.moovHeld;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Duration from `mvhd` and the presentation offset from the first track's
|
|
312
|
-
* edit list, per ISO/IEC 14496-12 §8.2.2 and §8.6.6.
|
|
313
|
-
*
|
|
314
|
-
* An edit entry whose `media_time` is -1 is an EMPTY edit: it presents
|
|
315
|
-
* nothing for `segment_duration`, which shifts everything after it later by
|
|
316
|
-
* that much. That shift is what a player reports as the file's start, and it
|
|
317
|
-
* is the only way an MP4 states one — a file without such an edit begins at
|
|
318
|
-
* zero, which is a declaration, not an absence.
|
|
319
|
-
*
|
|
320
|
-
* @returns {Promise<import("./Container.js").ContainerMediaInfo>}
|
|
321
|
-
*/
|
|
322
|
-
async readMediaInfo() {
|
|
323
|
-
if (this.mediaInfo) {
|
|
324
|
-
return this.mediaInfo;
|
|
325
|
-
}
|
|
326
|
-
/** @type {import("./Container.js").ContainerMediaInfo} */
|
|
327
|
-
const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
|
|
328
|
-
this.mediaInfo = info;
|
|
329
|
-
const held = await this.#moovBuffer();
|
|
330
|
-
if (!held) {
|
|
331
|
-
return info;
|
|
332
|
-
}
|
|
333
|
-
const { moov, header } = held;
|
|
334
|
-
const mvhd = childOf(moov, header, moov.length, "mvhd");
|
|
335
|
-
let movieTimescale = 0;
|
|
336
|
-
if (mvhd) {
|
|
337
|
-
const version = moov[mvhd.dataOffset];
|
|
338
|
-
// version 0: creation(4) modification(4) timescale(4) duration(4)
|
|
339
|
-
// version 1: creation(8) modification(8) timescale(4) duration(8)
|
|
340
|
-
const at = version === 1 ? mvhd.dataOffset + 20 : mvhd.dataOffset + 12;
|
|
341
|
-
if (at + 8 <= moov.length) {
|
|
342
|
-
movieTimescale = moov.readUInt32BE(at);
|
|
343
|
-
const duration = version === 1 ? Number(moov.readBigUInt64BE(at + 4)) : moov.readUInt32BE(at + 4);
|
|
344
|
-
if (movieTimescale > 0 && duration > 0) {
|
|
345
|
-
info.durationSeconds = duration / movieTimescale;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
info.startTimeSeconds = movieTimescale > 0
|
|
350
|
-
? Mp4Container.#emptyEditSeconds(moov, header, movieTimescale)
|
|
351
|
-
: null;
|
|
352
|
-
return info;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* The presentation shift of the first empty edit, in seconds; 0 when no track
|
|
357
|
-
* declares one.
|
|
358
|
-
*
|
|
359
|
-
* @param {Buffer} moov
|
|
360
|
-
* @param {number} header
|
|
361
|
-
* @param {number} movieTimescale
|
|
362
|
-
* @returns {number}
|
|
363
|
-
*/
|
|
364
|
-
static #emptyEditSeconds(moov, header, movieTimescale) {
|
|
365
|
-
let shift = 0;
|
|
366
|
-
for (const trak of childrenOf(moov, header, moov.length, "trak")) {
|
|
367
|
-
const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
|
|
368
|
-
const elst = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
|
|
369
|
-
if (!elst) {
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
const version = moov[elst.dataOffset];
|
|
373
|
-
const count = moov.readUInt32BE(elst.dataOffset + 4);
|
|
374
|
-
if (count < 1) {
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
const entry = elst.dataOffset + 8;
|
|
378
|
-
const segmentDuration = version === 1
|
|
379
|
-
? Number(moov.readBigUInt64BE(entry))
|
|
380
|
-
: moov.readUInt32BE(entry);
|
|
381
|
-
const mediaTime = version === 1
|
|
382
|
-
? Number(moov.readBigInt64BE(entry + 8))
|
|
383
|
-
: moov.readInt32BE(entry + 4);
|
|
384
|
-
if (mediaTime === -1 && segmentDuration > 0) {
|
|
385
|
-
shift = Math.max(shift, segmentDuration / movieTimescale);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
return shift;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
async #readVideoAudioTracks() {
|
|
392
|
-
const held = await this.#moovBuffer();
|
|
393
|
-
if (!held) return [];
|
|
394
|
-
const { moov, header: moovHeader } = held;
|
|
395
|
-
|
|
396
|
-
const result = [];
|
|
397
|
-
let videoIdx = -1;
|
|
398
|
-
let audioIdx = -1;
|
|
399
|
-
|
|
400
|
-
const moovContentStart = moovHeader;
|
|
401
|
-
const moovEnd = moov.length;
|
|
402
|
-
for (const trak of childrenOf(moov, moovContentStart, moovEnd, "trak")) {
|
|
403
|
-
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
404
|
-
if (!mdia) continue;
|
|
405
|
-
const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
|
|
406
|
-
const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
407
|
-
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
408
|
-
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
409
|
-
let language = "";
|
|
410
|
-
if (mdhd) {
|
|
411
|
-
const ver = moov[mdhd.dataOffset];
|
|
412
|
-
const langAt = ver === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
|
|
413
|
-
if (langAt + 2 <= mdhd.end) {
|
|
414
|
-
const packed = moov.readUInt16BE(langAt);
|
|
415
|
-
language = [10, 5, 0].map((s) => String.fromCharCode(((packed >> s) & 0x1f) + 0x60)).join("").replace(/[^a-z]/g, "");
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
// elng overrides mdhd language per spec §8.4.6
|
|
419
|
-
let languageBcp47 = "";
|
|
420
|
-
const elng = childOf(moov, mdia.dataOffset, mdia.end, "elng");
|
|
421
|
-
if (elng && elng.end - elng.dataOffset >= 4) {
|
|
422
|
-
languageBcp47 = moov.toString("utf8", elng.dataOffset + 4, elng.end).replace(/\0+$/, "");
|
|
423
|
-
}
|
|
424
|
-
let trackId = 0;
|
|
425
|
-
let isEnabled = true;
|
|
426
|
-
let alternateGroup = 0;
|
|
427
|
-
let width = null;
|
|
428
|
-
let height = null;
|
|
429
|
-
if (tkhd) {
|
|
430
|
-
const ver = moov[tkhd.dataOffset];
|
|
431
|
-
const flags = moov.readUInt32BE(tkhd.dataOffset + 1) & 0xffffff; // 3 bytes after version
|
|
432
|
-
isEnabled = (flags & 0x000001) !== 0;
|
|
433
|
-
trackId = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12);
|
|
434
|
-
alternateGroup = moov.readUInt16BE(ver === 1 ? tkhd.dataOffset + 26 : tkhd.dataOffset + 18);
|
|
435
|
-
// width/height are 16.16 fixed point at end of tkhd
|
|
436
|
-
if (tkhd.end - tkhd.dataOffset >= 84) {
|
|
437
|
-
const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 76 : tkhd.dataOffset + 68);
|
|
438
|
-
const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 80 : tkhd.dataOffset + 72);
|
|
439
|
-
width = w / 65536;
|
|
440
|
-
height = h / 65536;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
const resolvedLang = languageBcp47 || language;
|
|
444
|
-
if (handler === "vide") {
|
|
445
|
-
videoIdx += 1;
|
|
446
|
-
// stsd format for codecId
|
|
447
|
-
let codecId = "";
|
|
448
|
-
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
449
|
-
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
450
|
-
const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
451
|
-
if (stsd) {
|
|
452
|
-
const first = readBox(moov, stsd.dataOffset + 8);
|
|
453
|
-
if (first) codecId = first.type;
|
|
454
|
-
}
|
|
455
|
-
result.push(new VideoTrack({
|
|
456
|
-
trackNumber: trackId,
|
|
457
|
-
declaredIndex: videoIdx,
|
|
458
|
-
codecId,
|
|
459
|
-
language: resolvedLang,
|
|
460
|
-
languageBcp47,
|
|
461
|
-
name: "",
|
|
462
|
-
isEnabled,
|
|
463
|
-
isDefault: true,
|
|
464
|
-
declaresDefault: false,
|
|
465
|
-
codecPrivateB64: "",
|
|
466
|
-
alternateGroup,
|
|
467
|
-
width,
|
|
468
|
-
height
|
|
469
|
-
}));
|
|
470
|
-
} else if (handler === "soun") {
|
|
471
|
-
audioIdx += 1;
|
|
472
|
-
let codecId = "";
|
|
473
|
-
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
474
|
-
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
475
|
-
const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
476
|
-
if (stsd) {
|
|
477
|
-
const first = readBox(moov, stsd.dataOffset + 8);
|
|
478
|
-
if (first) codecId = first.type;
|
|
479
|
-
}
|
|
480
|
-
result.push(new AudioTrack({
|
|
481
|
-
trackNumber: trackId,
|
|
482
|
-
declaredIndex: audioIdx,
|
|
483
|
-
codecId,
|
|
484
|
-
language: resolvedLang,
|
|
485
|
-
languageBcp47,
|
|
486
|
-
name: "",
|
|
487
|
-
isEnabled,
|
|
488
|
-
isDefault: true,
|
|
489
|
-
declaresDefault: false,
|
|
490
|
-
codecPrivateB64: "",
|
|
491
|
-
alternateGroup,
|
|
492
|
-
isOriginal: false,
|
|
493
|
-
isCommentary: false,
|
|
494
|
-
isVisualImpaired: false
|
|
495
|
-
}));
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
return result;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* The text field of one cue as MP4 frames it.
|
|
503
|
-
*
|
|
504
|
-
* A `tx3g`/`text` sample is a 16-bit big-endian length followed by that many
|
|
505
|
-
* bytes of UTF-8 (ISO/IEC 14496-12 §12.6.3 and Apple's text sample format); a
|
|
506
|
-
* `wvtt` sample is a sequence of boxes whose `vttc`/`payl` holds the cue text
|
|
507
|
-
* (§12.6.3.2). Neither carries the subtitle format's own markup, so the
|
|
508
|
-
* markup step that follows has nothing to take off — it is applied all the
|
|
509
|
-
* same, because which step applies is decided by the codec and not here.
|
|
510
|
-
*
|
|
511
|
-
* The byte reading itself is this module's,
|
|
512
|
-
* alongside the sample-table walk that found the range.
|
|
513
|
-
*
|
|
514
|
-
* @param {Buffer} payload - The sample's own bytes.
|
|
515
|
-
* @param {string} codecId - Sample entry type: `tx3g`, `text` or `wvtt`.
|
|
516
|
-
* @returns {string}
|
|
517
|
-
*/
|
|
518
|
-
static cueTextOf(payload, codecId) {
|
|
519
|
-
return decodeSubtitleSample(payload, codecId);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
async parseKeyframeIndex() {
|
|
523
|
-
const r = await readMp4KeyframeTimes(this.readRange, this.fileSize);
|
|
524
|
-
if (!r) return null;
|
|
525
|
-
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|
|
526
|
-
return r;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// ---------------------------------------------------------------------------
|
|
531
|
-
// The MP4's own reading of its subtitle sample table. Here because every rule
|
|
532
|
-
// in it is ISO/IEC 14496-12 speaking about this container, and the class is
|
|
533
|
-
// the only way in.
|
|
534
|
-
// ---------------------------------------------------------------------------
|
|
535
|
-
/**
|
|
536
|
-
* @file The text subtitle tracks of an MP4, and where each cue's bytes are.
|
|
537
|
-
*
|
|
538
|
-
* The same rule as the Matroska side: nothing is extracted with ffmpeg and
|
|
539
|
-
* nothing is fetched for its own sake. Here it is cheaper still. Matroska hides
|
|
540
|
-
* its subtitle blocks inside clusters shared with the picture, so a cue costs
|
|
541
|
-
* whatever cluster holds it; an MP4 states every sample's offset and length in
|
|
542
|
-
* the sample table, so a cue costs its own bytes and nothing more — usually a
|
|
543
|
-
* few dozen of them.
|
|
544
|
-
*
|
|
545
|
-
* The tables, from ISO/IEC 14496-12:
|
|
546
|
-
*
|
|
547
|
-
* stsd — what the samples are (`tx3g` timed text, `wvtt` WebVTT, `stpp` TTML)
|
|
548
|
-
* stts — how long each sample lasts, run-length encoded (§8.6.1.2)
|
|
549
|
-
* stsz — how long each sample is, in bytes (§8.7.3)
|
|
550
|
-
* stsc — how samples are grouped into chunks (§8.7.4)
|
|
551
|
-
* stco / co64 — where each chunk begins in the file (§8.7.5)
|
|
552
|
-
*
|
|
553
|
-
* Together they give, for sample N: when it starts, how long it stays, and the
|
|
554
|
-
* exact byte range holding it. That is everything needed to show a cue without
|
|
555
|
-
* reading anything else.
|
|
556
|
-
*/
|
|
557
|
-
|
|
558
|
-
const PROBE_BYTES = 64;
|
|
559
|
-
const MAX_MOOV_BYTES = 32 * 1024 * 1024;
|
|
560
|
-
|
|
561
|
-
/** Handlers that mean "this track is text on screen". */
|
|
562
|
-
const TEXT_HANDLERS = new Set(["text", "sbtl", "subt"]);
|
|
563
|
-
/**
|
|
564
|
-
* Every handler ffmpeg's mov demuxer turns into a SUBTITLE stream, whether or
|
|
565
|
-
* not this file can read it — `subp` is a DVD subpicture and `clcp` closed
|
|
566
|
-
* captions, both pictures or caption data rather than text. They are counted
|
|
567
|
-
* because `declaredIndex` has to equal ffmpeg's `0:s:N`, and a track left out
|
|
568
|
-
* of the count shifts every text track after it, which is the very defect
|
|
569
|
-
* `declaredIndex` exists to remove.
|
|
570
|
-
*/
|
|
571
|
-
const SUBTITLE_HANDLERS = new Set([...TEXT_HANDLERS, "subp", "clcp"]);
|
|
572
|
-
/** Sample formats this can turn into cues. `stpp` (TTML) is XML and is not one. */
|
|
573
|
-
const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* Sample durations, expanded from the run-length table.
|
|
581
|
-
*
|
|
582
|
-
* @param {Buffer} moov
|
|
583
|
-
* @param {{ dataOffset: number, end: number }} stts
|
|
584
|
-
* @param {number} total - How many samples the size table declares.
|
|
585
|
-
* @returns {number[]} Ticks each sample lasts.
|
|
586
|
-
*/
|
|
587
|
-
function sampleDurations(moov, stts, total) {
|
|
588
|
-
const durations = new Array(total).fill(0);
|
|
589
|
-
const entries = moov.readUInt32BE(stts.dataOffset + 4);
|
|
590
|
-
let at = stts.dataOffset + 8;
|
|
591
|
-
let sample = 0;
|
|
592
|
-
for (let entry = 0; entry < entries && at + 8 <= stts.end && sample < total; entry += 1, at += 8) {
|
|
593
|
-
const count = moov.readUInt32BE(at);
|
|
594
|
-
const delta = moov.readUInt32BE(at + 4);
|
|
595
|
-
for (let index = 0; index < count && sample < total; index += 1, sample += 1) {
|
|
596
|
-
durations[sample] = delta;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
return durations;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Sample sizes, whether the table states one for all or one for each.
|
|
604
|
-
*
|
|
605
|
-
* @param {Buffer} moov
|
|
606
|
-
* @param {{ dataOffset: number, end: number }} stsz
|
|
607
|
-
* @returns {number[]}
|
|
608
|
-
*/
|
|
609
|
-
function sampleSizes(moov, stsz) {
|
|
610
|
-
const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
|
|
611
|
-
const count = moov.readUInt32BE(stsz.dataOffset + 8);
|
|
612
|
-
if (uniform > 0) {
|
|
613
|
-
return new Array(count).fill(uniform);
|
|
614
|
-
}
|
|
615
|
-
const sizes = new Array(count).fill(0);
|
|
616
|
-
let at = stsz.dataOffset + 12;
|
|
617
|
-
for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
|
|
618
|
-
sizes[index] = moov.readUInt32BE(at);
|
|
619
|
-
}
|
|
620
|
-
return sizes;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* Where every sample of a track begins in the file.
|
|
625
|
-
*
|
|
626
|
-
* The sample-to-chunk table says how many samples each run of chunks holds, and
|
|
627
|
-
* the chunk-offset table says where each chunk starts; a sample's own offset is
|
|
628
|
-
* its chunk's start plus the sizes of the samples before it in that chunk.
|
|
629
|
-
*
|
|
630
|
-
* @param {Buffer} moov
|
|
631
|
-
* @param {{ dataOffset: number, end: number }} stsc
|
|
632
|
-
* @param {number[]} chunkOffsets
|
|
633
|
-
* @param {number[]} sizes
|
|
634
|
-
* @returns {number[]}
|
|
635
|
-
*/
|
|
636
|
-
function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
637
|
-
const offsets = new Array(sizes.length).fill(0);
|
|
638
|
-
const entries = moov.readUInt32BE(stsc.dataOffset + 4);
|
|
639
|
-
/** @type {{ firstChunk: number, perChunk: number }[]} */
|
|
640
|
-
const runs = [];
|
|
641
|
-
let at = stsc.dataOffset + 8;
|
|
642
|
-
for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
|
|
643
|
-
runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
|
|
644
|
-
}
|
|
645
|
-
let sample = 0;
|
|
646
|
-
for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
|
|
647
|
-
const from = runs[run].firstChunk;
|
|
648
|
-
const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
|
|
649
|
-
for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
|
|
650
|
-
let inChunk = chunkOffsets[chunk - 1];
|
|
651
|
-
if (inChunk === undefined) {
|
|
652
|
-
break;
|
|
653
|
-
}
|
|
654
|
-
for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
|
|
655
|
-
offsets[sample] = inChunk;
|
|
656
|
-
inChunk += sizes[sample];
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
return offsets;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* @typedef {object} Mp4SubtitleSample
|
|
665
|
-
* @property {number} startSeconds
|
|
666
|
-
* @property {number} endSeconds
|
|
667
|
-
* @property {number} offset - Where the sample's bytes are in the file.
|
|
668
|
-
* @property {number} size
|
|
669
|
-
*/
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* @typedef {object} Mp4SubtitleTrack
|
|
673
|
-
* @property {number} trackId
|
|
674
|
-
* @property {number} declaredIndex - Its position among ALL of the file's
|
|
675
|
-
* subtitle tracks, including the ones whose sample format this cannot turn
|
|
676
|
-
* into cues (`stpp` TTML). That is the number ffmpeg gives the same stream in
|
|
677
|
-
* `0:s:N`, which is the number the browser names; counting only the readable
|
|
678
|
-
* ones would shift every track after a TTML one.
|
|
679
|
-
* @property {string} format - `tx3g`, `text` or `wvtt`.
|
|
680
|
-
* @property {string} language - Three letters, as the file declares them.
|
|
681
|
-
* @property {boolean} someSamplesForced - The sample entry says at least one
|
|
682
|
-
* cue carries a forced (`frcd`) atom.
|
|
683
|
-
* @property {boolean} allSamplesForced - The sample entry says every cue is to
|
|
684
|
-
* be treated as forced, whether or not it carries that atom.
|
|
685
|
-
* @property {Mp4SubtitleSample[]} samples - In time order.
|
|
686
|
-
*/
|
|
687
|
-
|
|
688
|
-
/**
|
|
689
|
-
* The text subtitle tracks of an MP4, with every cue's time and byte range.
|
|
690
|
-
*
|
|
691
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
692
|
-
* @param {number} fileSize
|
|
693
|
-
* @returns {Promise<{ tracks: Mp4SubtitleTrack[] } | null>}
|
|
694
|
-
*/
|
|
695
|
-
async function readMp4SubtitlePlan(readRange, fileSize) {
|
|
696
|
-
const found = await findMoov(readRange, fileSize);
|
|
697
|
-
if (!found || found.size > MAX_MOOV_BYTES) {
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
const moov = await readRange(found.offset, Math.min(fileSize - 1, found.offset + found.size - 1));
|
|
701
|
-
if (!moov || moov.length < HEADER_BYTES) {
|
|
702
|
-
return null;
|
|
703
|
-
}
|
|
704
|
-
const moovBox = readBox(moov, 0);
|
|
705
|
-
if (!moovBox) {
|
|
706
|
-
return null;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/** @type {Mp4SubtitleTrack[]} */
|
|
710
|
-
const tracks = [];
|
|
711
|
-
// Counts every subtitle track the file has, whether or not this can read it,
|
|
712
|
-
// so the number handed out matches ffmpeg's `0:s:N`. See `declaredIndex`.
|
|
713
|
-
let declaredIndex = -1;
|
|
714
|
-
for (const trak of childrenOf(moov, moovBox.dataOffset, moov.length, "trak")) {
|
|
715
|
-
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
716
|
-
if (!mdia) {
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
|
|
720
|
-
const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
721
|
-
if (!SUBTITLE_HANDLERS.has(handler)) {
|
|
722
|
-
continue;
|
|
723
|
-
}
|
|
724
|
-
// Counted before the readability checks below, and before the handler is
|
|
725
|
-
// narrowed to the text ones: this number is the track's place in the file,
|
|
726
|
-
// not its place among the tracks this code can turn into cues.
|
|
727
|
-
declaredIndex += 1;
|
|
728
|
-
if (!TEXT_HANDLERS.has(handler)) {
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
732
|
-
if (!mdhd) {
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
const version = moov[mdhd.dataOffset];
|
|
736
|
-
const timescale = moov.readUInt32BE(version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12);
|
|
737
|
-
if (!timescale) {
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
// The language is five bits per letter, offset from 0x60, packed into two
|
|
741
|
-
// bytes after the times (ISO/IEC 14496-12 §8.4.2.3).
|
|
742
|
-
const languageAt = version === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
|
|
743
|
-
let language = "";
|
|
744
|
-
if (languageAt + 2 <= mdhd.end) {
|
|
745
|
-
const packed = moov.readUInt16BE(languageAt);
|
|
746
|
-
language = [10, 5, 0]
|
|
747
|
-
.map((shift) => String.fromCharCode(((packed >> shift) & 0x1f) + 0x60))
|
|
748
|
-
.join("")
|
|
749
|
-
.replace(/[^a-z]/g, "");
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
753
|
-
const trackId = tkhd
|
|
754
|
-
? moov.readUInt32BE(moov[tkhd.dataOffset] === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12)
|
|
755
|
-
: tracks.length + 1;
|
|
756
|
-
|
|
757
|
-
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
758
|
-
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
759
|
-
if (!stbl) {
|
|
760
|
-
continue;
|
|
761
|
-
}
|
|
762
|
-
const stsd = childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
763
|
-
const first = stsd && readBox(moov, stsd.dataOffset + 8);
|
|
764
|
-
const format = first ? first.type : "";
|
|
765
|
-
if (!TEXT_FORMATS.has(format)) {
|
|
766
|
-
continue;
|
|
767
|
-
}
|
|
768
|
-
// Whether the file itself says this track is forced — subtitles shown even
|
|
769
|
-
// to a viewer who did not ask for subtitles, because the dialogue on screen
|
|
770
|
-
// is in a language the soundtrack is not.
|
|
771
|
-
//
|
|
772
|
-
// Apple's QuickTime File Format, "Display flags" under Subtitle sample
|
|
773
|
-
// description, defines the two bits read here: `0x40000000` "Some samples
|
|
774
|
-
// are forced" ("at least one sample contains a forced (`frcd`) atom") and
|
|
775
|
-
// `0x80000000` "All samples are forced" ("the subtitle media handler treats
|
|
776
|
-
// all samples as forced subtitles, regardless of the presence or absence of
|
|
777
|
-
// a `frcd` atom"), with the note that setting the second requires the first
|
|
778
|
-
// — the pair together being `0xC0000000`. We honour that requirement rather
|
|
779
|
-
// than trusting a writer to have met it: either bit alone is enough to call
|
|
780
|
-
// the track forced, because a file that sets only `0x80000000` is saying
|
|
781
|
-
// exactly what a well-formed one would say twice.
|
|
782
|
-
//
|
|
783
|
-
// The field sits at a fixed place in the sample entry. `dataOffset` is
|
|
784
|
-
// already past the box header, and every sample entry opens with 6 reserved
|
|
785
|
-
// bytes and a 2-byte data reference index (ISO/IEC 14496-12 §8.5.2.2), so
|
|
786
|
-
// `displayFlags` is the 32 bits eight bytes in.
|
|
787
|
-
let someSamplesForced = false;
|
|
788
|
-
let allSamplesForced = false;
|
|
789
|
-
if (format === "tx3g" && first && first.dataOffset + 8 + 4 <= first.end) {
|
|
790
|
-
const displayFlags = moov.readUInt32BE(first.dataOffset + 8);
|
|
791
|
-
someSamplesForced = (displayFlags & 0x40000000) !== 0;
|
|
792
|
-
allSamplesForced = (displayFlags & 0x80000000) !== 0;
|
|
793
|
-
}
|
|
794
|
-
const stts = childOf(moov, stbl.dataOffset, stbl.end, "stts");
|
|
795
|
-
const stsz = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
|
|
796
|
-
const stsc = childOf(moov, stbl.dataOffset, stbl.end, "stsc");
|
|
797
|
-
const stco = childOf(moov, stbl.dataOffset, stbl.end, "stco");
|
|
798
|
-
const co64 = childOf(moov, stbl.dataOffset, stbl.end, "co64");
|
|
799
|
-
if (!stts || !stsz || !stsc || (!stco && !co64)) {
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const sizes = sampleSizes(moov, stsz);
|
|
804
|
-
const durations = sampleDurations(moov, stts, sizes.length);
|
|
805
|
-
const chunkOffsets = [];
|
|
806
|
-
if (stco) {
|
|
807
|
-
const count = moov.readUInt32BE(stco.dataOffset + 4);
|
|
808
|
-
let at = stco.dataOffset + 8;
|
|
809
|
-
for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
|
|
810
|
-
chunkOffsets.push(moov.readUInt32BE(at));
|
|
811
|
-
}
|
|
812
|
-
} else {
|
|
813
|
-
const count = moov.readUInt32BE(co64.dataOffset + 4);
|
|
814
|
-
let at = co64.dataOffset + 8;
|
|
815
|
-
for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
|
|
816
|
-
chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
|
|
820
|
-
|
|
821
|
-
/** @type {Mp4SubtitleSample[]} */
|
|
822
|
-
const samples = [];
|
|
823
|
-
let ticks = 0;
|
|
824
|
-
for (let index = 0; index < sizes.length; index += 1) {
|
|
825
|
-
const start = ticks / timescale;
|
|
826
|
-
ticks += durations[index];
|
|
827
|
-
// An empty sample is a gap between cues, which the format uses to say
|
|
828
|
-
// "nothing on screen"; it is not a cue and would show as a blank line.
|
|
829
|
-
if (sizes[index] > 2) {
|
|
830
|
-
samples.push({
|
|
831
|
-
startSeconds: start,
|
|
832
|
-
endSeconds: ticks / timescale,
|
|
833
|
-
offset: offsets[index],
|
|
834
|
-
size: sizes[index]
|
|
835
|
-
});
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
tracks.push({
|
|
839
|
-
trackId,
|
|
840
|
-
declaredIndex,
|
|
841
|
-
format,
|
|
842
|
-
language,
|
|
843
|
-
someSamplesForced,
|
|
844
|
-
allSamplesForced,
|
|
845
|
-
samples
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
return { tracks };
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
/**
|
|
852
|
-
* The text of one sample.
|
|
853
|
-
*
|
|
854
|
-
* `tx3g` is a two-byte length followed by UTF-8; anything after that is styling
|
|
855
|
-
* boxes, which this deliberately drops. `wvtt` is a sequence of boxes, and the
|
|
856
|
-
* text lives in the `payl` inside a `vttc`.
|
|
857
|
-
*
|
|
858
|
-
* @param {Buffer} bytes
|
|
859
|
-
* @param {string} format
|
|
860
|
-
* @returns {string}
|
|
861
|
-
*/
|
|
862
|
-
function decodeSubtitleSample(bytes, format) {
|
|
863
|
-
if (format === "wvtt") {
|
|
864
|
-
let at = 0;
|
|
865
|
-
const parts = [];
|
|
866
|
-
while (at + HEADER_BYTES <= bytes.length) {
|
|
867
|
-
const box = readBox(bytes, at);
|
|
868
|
-
if (!box) {
|
|
869
|
-
break;
|
|
870
|
-
}
|
|
871
|
-
if (box.type === "vttc") {
|
|
872
|
-
const payl = childOf(bytes, box.dataOffset, Math.min(bytes.length, box.end), "payl");
|
|
873
|
-
if (payl) {
|
|
874
|
-
parts.push(bytes.toString("utf8", payl.dataOffset, Math.min(bytes.length, payl.end)));
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
at = box.end;
|
|
878
|
-
}
|
|
879
|
-
return parts.join("\n").trim();
|
|
880
|
-
}
|
|
881
|
-
if (bytes.length < 2) {
|
|
882
|
-
return "";
|
|
883
|
-
}
|
|
884
|
-
const length = bytes.readUInt16BE(0);
|
|
885
|
-
return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// ---------------------------------------------------------------------------
|
|
889
|
-
// ISO/IEC 14496-12 speaking about MP4: stss, stts, ctts and the edit list.
|
|
890
|
-
// Here because the class is the only way in.
|
|
891
|
-
// ---------------------------------------------------------------------------
|
|
892
|
-
/**
|
|
893
|
-
* @file Keyframe index for MP4/MOV, read without downloading the file.
|
|
894
|
-
*
|
|
895
|
-
* MP4 keeps its tables in a `moov` box: `stss` lists which samples are sync
|
|
896
|
-
* samples (keyframes) by number, and `stts` gives each sample's duration, so
|
|
897
|
-
* the two together turn "sample #N" into "second T". `moov` sits either at the
|
|
898
|
-
* start (files written for streaming) or at the end (the common case for a
|
|
899
|
-
* plain mux); box headers state their own size, so it is found by stepping over
|
|
900
|
-
* top-level boxes rather than scanning bytes — a couple of 64-byte reads even
|
|
901
|
-
* when `mdat` is gigabytes.
|
|
902
|
-
*
|
|
903
|
-
* Same purpose as the Matroska reader: on the video-COPY path the segment
|
|
904
|
-
* boundaries ARE the source's keyframes, and inventing an even grid instead
|
|
905
|
-
* makes players walk the whole file or present audio with no picture.
|
|
906
|
-
*/
|
|
907
|
-
|
|
908
|
-
// A 64-bit box size is signalled by a 32-bit size of 1, the real size following
|
|
909
|
-
// in the next 8 bytes.
|
|
910
|
-
// Enough to read any box header while walking the top level.
|
|
911
|
-
// Cap on the moov read. A feature-length file indexes to a few hundred KB;
|
|
912
|
-
// beyond this is not a normal index and not worth pulling over a torrent.
|
|
913
|
-
|
|
914
|
-
/**
|
|
915
|
-
* Whether this looks like MP4/MOV — every real file opens with an `ftyp` box.
|
|
916
|
-
*
|
|
917
|
-
* @param {Buffer} head
|
|
918
|
-
* @returns {boolean}
|
|
919
|
-
*/
|
|
920
|
-
function isMp4(head) {
|
|
921
|
-
return head.length >= 12 && head.toString("latin1", 4, 8) === "ftyp";
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Read a box header at `offset`.
|
|
926
|
-
*
|
|
927
|
-
* @param {Buffer} buffer
|
|
928
|
-
* @param {number} offset
|
|
929
|
-
* @returns {{ type: string, size: number, headerBytes: number } | null}
|
|
930
|
-
*/
|
|
931
|
-
function readBoxHeader(buffer, offset) {
|
|
932
|
-
if (offset + HEADER_BYTES > buffer.length) {
|
|
933
|
-
return null;
|
|
934
|
-
}
|
|
935
|
-
const size32 = buffer.readUInt32BE(offset);
|
|
936
|
-
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
937
|
-
if (size32 === LARGE_SIZE_MARKER) {
|
|
938
|
-
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
939
|
-
return null;
|
|
940
|
-
}
|
|
941
|
-
// High word is zero for any file we can practically handle.
|
|
942
|
-
const high = buffer.readUInt32BE(offset + 8);
|
|
943
|
-
const low = buffer.readUInt32BE(offset + 12);
|
|
944
|
-
return { type, size: high * 4294967296 + low, headerBytes: LARGE_HEADER_BYTES };
|
|
945
|
-
}
|
|
946
|
-
return { type, size: size32, headerBytes: HEADER_BYTES };
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
/**
|
|
950
|
-
* Walk the top-level boxes to find `moov`, reading only each box header.
|
|
951
|
-
*
|
|
952
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
953
|
-
* @param {number} fileSize
|
|
954
|
-
* @returns {Promise<{ offset: number, size: number, headerBytes: number } | null>}
|
|
955
|
-
*/
|
|
956
|
-
async function findMoov(readRange, fileSize) {
|
|
957
|
-
let offset = 0;
|
|
958
|
-
while (offset < fileSize) {
|
|
959
|
-
const probe = await readRange(offset, Math.min(fileSize - 1, offset + PROBE_BYTES - 1));
|
|
960
|
-
if (!probe || probe.length < HEADER_BYTES) {
|
|
961
|
-
return null;
|
|
962
|
-
}
|
|
963
|
-
const header = readBoxHeader(probe, 0);
|
|
964
|
-
// Size 0 means "extends to end of file" — legal only for the last box, and
|
|
965
|
-
// never for one we would step over.
|
|
966
|
-
if (!header || header.size <= 0) {
|
|
967
|
-
return null;
|
|
968
|
-
}
|
|
969
|
-
if (header.type === "moov") {
|
|
970
|
-
return { offset, size: header.size, headerBytes: header.headerBytes };
|
|
971
|
-
}
|
|
972
|
-
offset += header.size;
|
|
973
|
-
}
|
|
974
|
-
return null;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
/**
|
|
978
|
-
* Find the first box of `type` directly inside a range of an already-read buffer.
|
|
979
|
-
*
|
|
980
|
-
* @param {Buffer} buffer
|
|
981
|
-
* @param {number} start
|
|
982
|
-
* @param {number} end
|
|
983
|
-
* @param {string} type
|
|
984
|
-
* @returns {{ dataOffset: number, end: number } | null}
|
|
985
|
-
*/
|
|
986
|
-
function findBox(buffer, start, end, type) {
|
|
987
|
-
let offset = start;
|
|
988
|
-
while (offset + HEADER_BYTES <= end) {
|
|
989
|
-
const header = readBoxHeader(buffer, offset);
|
|
990
|
-
if (!header || header.size <= 0) {
|
|
991
|
-
return null;
|
|
992
|
-
}
|
|
993
|
-
if (header.type === type) {
|
|
994
|
-
return { dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) };
|
|
995
|
-
}
|
|
996
|
-
offset += header.size;
|
|
997
|
-
}
|
|
998
|
-
return null;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
/**
|
|
1002
|
-
* All boxes of `type` directly inside a range.
|
|
1003
|
-
*
|
|
1004
|
-
* @param {Buffer} buffer
|
|
1005
|
-
* @param {number} start
|
|
1006
|
-
* @param {number} end
|
|
1007
|
-
* @param {string} type
|
|
1008
|
-
* @returns {{ dataOffset: number, end: number }[]}
|
|
1009
|
-
*/
|
|
1010
|
-
function findAllBoxes(buffer, start, end, type) {
|
|
1011
|
-
const found = [];
|
|
1012
|
-
let offset = start;
|
|
1013
|
-
while (offset + HEADER_BYTES <= end) {
|
|
1014
|
-
const header = readBoxHeader(buffer, offset);
|
|
1015
|
-
if (!header || header.size <= 0) {
|
|
1016
|
-
break;
|
|
1017
|
-
}
|
|
1018
|
-
if (header.type === type) {
|
|
1019
|
-
found.push({ dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) });
|
|
1020
|
-
}
|
|
1021
|
-
offset += header.size;
|
|
1022
|
-
}
|
|
1023
|
-
return found;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Turn sample numbers into seconds using the time-to-sample table.
|
|
1028
|
-
*
|
|
1029
|
-
* `stts` is run-length encoded — pairs of (sample count, per-sample duration) —
|
|
1030
|
-
* so one walk yields every sample's start time without expanding the table.
|
|
1031
|
-
*
|
|
1032
|
-
* @param {Buffer} buffer
|
|
1033
|
-
* @param {{ dataOffset: number, end: number }} stts
|
|
1034
|
-
* @param {number} timescale - Ticks per second.
|
|
1035
|
-
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
1036
|
-
* @returns {number[]} Seconds, ascending.
|
|
1037
|
-
*/
|
|
1038
|
-
function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
|
|
1039
|
-
const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
|
|
1040
|
-
const times = [];
|
|
1041
|
-
let sampleNumber = 1;
|
|
1042
|
-
let ticks = 0;
|
|
1043
|
-
let cursor = stts.dataOffset + 8;
|
|
1044
|
-
for (let entry = 0; entry < entryCount && cursor + 8 <= stts.end; entry += 1) {
|
|
1045
|
-
const count = buffer.readUInt32BE(cursor);
|
|
1046
|
-
const delta = buffer.readUInt32BE(cursor + 4);
|
|
1047
|
-
for (let index = 0; index < count; index += 1) {
|
|
1048
|
-
if (wanted.has(sampleNumber)) {
|
|
1049
|
-
// `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
|
|
1050
|
-
// what turns decode order into the order frames are shown in, and it is
|
|
1051
|
-
// the timeline ffmpeg cuts on.
|
|
1052
|
-
times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
|
|
1053
|
-
}
|
|
1054
|
-
ticks += delta;
|
|
1055
|
-
sampleNumber += 1;
|
|
1056
|
-
}
|
|
1057
|
-
cursor += 8;
|
|
1058
|
-
}
|
|
1059
|
-
return times;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* Composition offsets for the sample numbers asked for.
|
|
1064
|
-
*
|
|
1065
|
-
* `ctts` is run-length encoded like `stts`, and version 1 carries SIGNED
|
|
1066
|
-
* offsets — which is what the version exists for: a frame may be shown before
|
|
1067
|
-
* it is decoded. Reading them as unsigned turns a small negative offset into
|
|
1068
|
-
* roughly four billion ticks.
|
|
1069
|
-
*
|
|
1070
|
-
* @param {Buffer} buffer
|
|
1071
|
-
* @param {{ dataOffset: number, end: number }} ctts
|
|
1072
|
-
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
1073
|
-
* @returns {Map<number, number>} Sample number to offset in media ticks.
|
|
1074
|
-
*/
|
|
1075
|
-
function readCompositionOffsets(buffer, ctts, wanted) {
|
|
1076
|
-
const version = buffer[ctts.dataOffset];
|
|
1077
|
-
const entryCount = buffer.readUInt32BE(ctts.dataOffset + 4);
|
|
1078
|
-
const offsets = new Map();
|
|
1079
|
-
let sampleNumber = 1;
|
|
1080
|
-
let cursor = ctts.dataOffset + 8;
|
|
1081
|
-
for (let entry = 0; entry < entryCount && cursor + 8 <= ctts.end; entry += 1) {
|
|
1082
|
-
const count = buffer.readUInt32BE(cursor);
|
|
1083
|
-
const offset = version === 1 ? buffer.readInt32BE(cursor + 4) : buffer.readUInt32BE(cursor + 4);
|
|
1084
|
-
for (let index = 0; index < count; index += 1) {
|
|
1085
|
-
if (wanted.has(sampleNumber)) {
|
|
1086
|
-
offsets.set(sampleNumber, offset);
|
|
1087
|
-
}
|
|
1088
|
-
sampleNumber += 1;
|
|
1089
|
-
}
|
|
1090
|
-
cursor += 8;
|
|
1091
|
-
}
|
|
1092
|
-
return offsets;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
/**
|
|
1096
|
-
* How far the edit list shifts this track's composition timeline, in media
|
|
1097
|
-
* ticks.
|
|
1098
|
-
*
|
|
1099
|
-
* ISO/IEC 14496-12 §8.6.6.3: `media_time` is the start of the edit within the
|
|
1100
|
-
* media, in the MEDIA timescale and in composition time, while
|
|
1101
|
-
* `segment_duration` is in the MOVIE timescale — two different units in one
|
|
1102
|
-
* structure, which is why only the first is read here. `media_time = -1` is an
|
|
1103
|
-
* empty edit: it inserts blank presentation time and starts no media, so the
|
|
1104
|
-
* first real edit is the one that matters.
|
|
1105
|
-
*
|
|
1106
|
-
* Measured 2026-08-19: every LostFilm MP4 that carries a composition offset
|
|
1107
|
-
* also carries an edit list cancelling it exactly, which is why decode times
|
|
1108
|
-
* have been right on those files. `Firefly.S01E03` has the offset and NO edit
|
|
1109
|
-
* list, and its times were 62.1 ms early on all 34 keyframes checked.
|
|
1110
|
-
*
|
|
1111
|
-
* @param {Buffer} buffer
|
|
1112
|
-
* @param {{ dataOffset: number, end: number }} elst
|
|
1113
|
-
* @returns {number} Ticks to subtract; zero when nothing is shifted.
|
|
1114
|
-
*/
|
|
1115
|
-
function readEditShift(buffer, elst) {
|
|
1116
|
-
const version = buffer[elst.dataOffset];
|
|
1117
|
-
const entryCount = buffer.readUInt32BE(elst.dataOffset + 4);
|
|
1118
|
-
const wide = version === 1;
|
|
1119
|
-
const entryBytes = wide ? 20 : 12;
|
|
1120
|
-
let cursor = elst.dataOffset + 8;
|
|
1121
|
-
for (let entry = 0; entry < entryCount && cursor + entryBytes <= elst.end; entry += 1) {
|
|
1122
|
-
const mediaTime = wide
|
|
1123
|
-
? Number(buffer.readBigInt64BE(cursor + 8))
|
|
1124
|
-
: buffer.readInt32BE(cursor + 4);
|
|
1125
|
-
if (mediaTime >= 0) {
|
|
1126
|
-
return mediaTime;
|
|
1127
|
-
}
|
|
1128
|
-
cursor += entryBytes;
|
|
1129
|
-
}
|
|
1130
|
-
return 0;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
/**
|
|
1134
|
-
* Whether this track's handler says it carries video.
|
|
1135
|
-
*
|
|
1136
|
-
* The standard identifies a track by its `hdlr`, and nothing else does. Picking
|
|
1137
|
-
* "the first track that happens to carry sync samples" worked only because the
|
|
1138
|
-
* seven releases measured all put video first; a file whose audio track carries
|
|
1139
|
-
* them, or one that leads with a cover-art video track, would be read from the
|
|
1140
|
-
* wrong place. That is the same defect that was fixed in the Matroska reader on
|
|
1141
|
-
* 2026-08-18, arrived at from the other side.
|
|
1142
|
-
*
|
|
1143
|
-
* @param {Buffer} buffer
|
|
1144
|
-
* @param {{ dataOffset: number, end: number }} mdia
|
|
1145
|
-
* @returns {boolean}
|
|
1146
|
-
*/
|
|
1147
|
-
function isVideoTrack(buffer, mdia) {
|
|
1148
|
-
const hdlr = findBox(buffer, mdia.dataOffset, mdia.end, "hdlr");
|
|
1149
|
-
if (!hdlr || hdlr.dataOffset + 12 > hdlr.end) {
|
|
1150
|
-
return false;
|
|
1151
|
-
}
|
|
1152
|
-
// FullBox header (4) then a reserved pre_defined (4), then the handler type.
|
|
1153
|
-
return buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) === "vide";
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
/**
|
|
1157
|
-
* Read the keyframe times of an MP4/MOV file.
|
|
1158
|
-
*
|
|
1159
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
1160
|
-
* @param {number} fileSize
|
|
1161
|
-
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
1162
|
-
* carries no usable index (fragmented MP4, truncated or damaged `moov`).
|
|
1163
|
-
*/
|
|
1164
|
-
async function readMp4KeyframeTimes(readRange, fileSize) {
|
|
1165
|
-
const moovBox = await findMoov(readRange, fileSize);
|
|
1166
|
-
if (!moovBox || moovBox.size > MAX_MOOV_BYTES) {
|
|
1167
|
-
return null;
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
const moov = await readRange(moovBox.offset, Math.min(fileSize - 1, moovBox.offset + moovBox.size - 1));
|
|
1171
|
-
if (!moov || moov.length < moovBox.headerBytes) {
|
|
1172
|
-
return null;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// Examine every track, and take the one whose HANDLER says it is video. A
|
|
1176
|
-
// track with no `stss` has every sample a keyframe, so it constrains nothing
|
|
1177
|
-
// and is skipped even when it is the video one.
|
|
1178
|
-
for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
|
|
1179
|
-
const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
|
|
1180
|
-
if (!mdia || !isVideoTrack(moov, mdia)) {
|
|
1181
|
-
continue;
|
|
1182
|
-
}
|
|
1183
|
-
const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
1184
|
-
if (!mdhd) {
|
|
1185
|
-
continue;
|
|
1186
|
-
}
|
|
1187
|
-
// mdhd layout: version(1) + flags(3), then creation/modification times —
|
|
1188
|
-
// 32-bit each in version 0, 64-bit in version 1 — then the timescale.
|
|
1189
|
-
const version = moov[mdhd.dataOffset];
|
|
1190
|
-
const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
|
|
1191
|
-
if (timescaleOffset + 4 > mdhd.end) {
|
|
1192
|
-
continue;
|
|
1193
|
-
}
|
|
1194
|
-
const timescale = moov.readUInt32BE(timescaleOffset);
|
|
1195
|
-
if (!timescale) {
|
|
1196
|
-
continue;
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
|
|
1200
|
-
const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
|
|
1201
|
-
if (!stbl) {
|
|
1202
|
-
continue;
|
|
1203
|
-
}
|
|
1204
|
-
const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
|
|
1205
|
-
const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
|
|
1206
|
-
if (!stss || !stts) {
|
|
1207
|
-
continue;
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
|
|
1211
|
-
const wanted = new Set();
|
|
1212
|
-
for (let index = 0; index < syncCount; index += 1) {
|
|
1213
|
-
const at = stss.dataOffset + 8 + index * 4;
|
|
1214
|
-
if (at + 4 > stss.end) {
|
|
1215
|
-
break;
|
|
1216
|
-
}
|
|
1217
|
-
wanted.add(moov.readUInt32BE(at));
|
|
1218
|
-
}
|
|
1219
|
-
if (wanted.size === 0) {
|
|
1220
|
-
continue;
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// The two terms that turn decode times into the timeline ffmpeg cuts on.
|
|
1224
|
-
// Both are optional: a file without them is one whose decode and
|
|
1225
|
-
// composition orders already agree, and then nothing is added or taken.
|
|
1226
|
-
const ctts = findBox(moov, stbl.dataOffset, stbl.end, "ctts");
|
|
1227
|
-
const offsets = ctts ? readCompositionOffsets(moov, ctts, wanted) : null;
|
|
1228
|
-
const edts = findBox(moov, trak.dataOffset, trak.end, "edts");
|
|
1229
|
-
const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
|
|
1230
|
-
const editShift = elst ? readEditShift(moov, elst) : 0;
|
|
1231
|
-
|
|
1232
|
-
const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
|
|
1233
|
-
if (times.length > 0) {
|
|
1234
|
-
// A shift applied after the division would be in the wrong units: the
|
|
1235
|
-
// edit's `media_time` is in MEDIA ticks, like everything else here.
|
|
1236
|
-
const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
|
|
1237
|
-
// A negative time is not a position in the file. It happens when an edit
|
|
1238
|
-
// starts later than a keyframe the table lists, and those frames are not
|
|
1239
|
-
// presented at all.
|
|
1240
|
-
return shifted.filter((time) => time >= 0);
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
return null;
|
|
1244
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file MP4/MOV container — ISO/IEC 14496-12.
|
|
3
|
+
*
|
|
4
|
+
* Parses moov for all track types in one walk:
|
|
5
|
+
* - tkhd: track_ID, flags track_enabled (0x000001), alternate_group, width/height
|
|
6
|
+
* - mdhd: timescale, language (packed 5-bit), version handling
|
|
7
|
+
* - hdlr: handler_type (vide/soun/text/sbtl/subt/subp/clcp)
|
|
8
|
+
* - elng: extendedLanguage BCP47 (when present, replaces mdhd language per spec)
|
|
9
|
+
* - stsd: sample entry format (avc1/hev1/mp4a/tx3g/wvtt/stpp)
|
|
10
|
+
* - stbl tables for subtitle cue ranges (stts/stsz/stsc/stco/co64) and video keyframes (stss/stts/ctts/elst)
|
|
11
|
+
*
|
|
12
|
+
* Keyframe reading is delegated to mp4.js. The subtitle sample table is read in
|
|
13
|
+
* this module, because every rule in it is a statement of ISO/IEC 14496-12 about
|
|
14
|
+
* this container. Track creation is centralised here so that track_enabled,
|
|
15
|
+
* `elng` and alternate_group are handled once for every media type.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Container } from "./Container.js";
|
|
19
|
+
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
20
|
+
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
21
|
+
import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
|
|
22
|
+
import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
|
|
23
|
+
|
|
24
|
+
/** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
|
|
25
|
+
const HEADER_BYTES = 8;
|
|
26
|
+
const LARGE_SIZE_MARKER = 1;
|
|
27
|
+
const LARGE_HEADER_BYTES = 16;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One box header, per ISO/IEC 14496-12 §4.2.
|
|
31
|
+
*
|
|
32
|
+
* @param {Buffer} buf
|
|
33
|
+
* @param {number} off
|
|
34
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
35
|
+
*/
|
|
36
|
+
function readBox(buffer, offset) {
|
|
37
|
+
if (offset + HEADER_BYTES > buffer.length) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
let size = buffer.readUInt32BE(offset);
|
|
41
|
+
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
42
|
+
let headerBytes = HEADER_BYTES;
|
|
43
|
+
// A size of 1 means the real size is the 64-bit value after the type.
|
|
44
|
+
if (size === LARGE_SIZE_MARKER) {
|
|
45
|
+
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
size = Number(buffer.readBigUInt64BE(offset + HEADER_BYTES));
|
|
49
|
+
headerBytes = LARGE_HEADER_BYTES;
|
|
50
|
+
}
|
|
51
|
+
if (size < headerBytes) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return { type, size, dataOffset: offset + headerBytes, end: offset + size };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every direct child box of the given type.
|
|
59
|
+
*
|
|
60
|
+
* @param {Buffer} buf
|
|
61
|
+
* @param {number} start
|
|
62
|
+
* @param {number} end
|
|
63
|
+
* @param {string} type
|
|
64
|
+
* @returns {Array<{ type: string, size: number, dataOffset: number, end: number }>}
|
|
65
|
+
*/
|
|
66
|
+
function childrenOf(buf, start, end, type) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let p = start;
|
|
69
|
+
while (p + 8 <= end) {
|
|
70
|
+
const b = readBox(buf, p);
|
|
71
|
+
if (!b) break;
|
|
72
|
+
if (b.type === type) out.push(b);
|
|
73
|
+
p = b.end;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The first direct child box of the given type, or null.
|
|
80
|
+
*
|
|
81
|
+
* @param {Buffer} buf
|
|
82
|
+
* @param {number} s
|
|
83
|
+
* @param {number} e
|
|
84
|
+
* @param {string} t
|
|
85
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
86
|
+
*/
|
|
87
|
+
function childOf(buf, s, e, t) {
|
|
88
|
+
return childrenOf(buf, s, e, t)[0] ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class Mp4Container extends Container {
|
|
92
|
+
get formatName() {
|
|
93
|
+
return "mp4";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static detect(head) {
|
|
97
|
+
return isMp4(head);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The keyframe times this container's own index states, in ascending seconds.
|
|
102
|
+
*
|
|
103
|
+
* Static so a caller that has bytes and no container can ask; the instance
|
|
104
|
+
* form is {@link Container#readKeyframeIndex}.
|
|
105
|
+
*
|
|
106
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
107
|
+
* @param {number} fileSize
|
|
108
|
+
* @returns {Promise<number[]|null>} Null where the container has no index.
|
|
109
|
+
*/
|
|
110
|
+
static readKeyframeTimes(readRange, fileSize) {
|
|
111
|
+
return readMp4KeyframeTimes(readRange, fileSize);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* This container's text subtitle tracks, with every cue's time and byte
|
|
116
|
+
* range — ISO/IEC 14496-12 §8.5 and §8.7.
|
|
117
|
+
*
|
|
118
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
119
|
+
* @param {number} fileSize
|
|
120
|
+
* @returns {Promise<{ tracks: object[] } | null>}
|
|
121
|
+
*/
|
|
122
|
+
static readSubtitlePlan(readRange, fileSize) {
|
|
123
|
+
return readMp4SubtitlePlan(readRange, fileSize);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The same reading, over the file this container was built on.
|
|
128
|
+
*
|
|
129
|
+
* The static form exists for a caller that has bytes and no container; this
|
|
130
|
+
* is the one to use otherwise, because the reader is already here.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<object|null>}
|
|
133
|
+
*/
|
|
134
|
+
async readSubtitlePlan() {
|
|
135
|
+
const plan = await Mp4Container.readSubtitlePlan(this.readRange, this.fileSize);
|
|
136
|
+
if (!plan) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
// An MP4 states every sample's byte range in its own table, so a cue costs
|
|
140
|
+
// its own few dozen bytes rather than the cluster around it — which is why
|
|
141
|
+
// there are `samples` here and no `clusterPositions`.
|
|
142
|
+
//
|
|
143
|
+
// An MP4 has no element meaning "show this subtitle track by default", so
|
|
144
|
+
// `declared` is empty: the container states nothing, and nothing is shown
|
|
145
|
+
// unasked.
|
|
146
|
+
return {
|
|
147
|
+
tracks: plan.tracks.map((track, order) => ({
|
|
148
|
+
trackNumber: track.trackId,
|
|
149
|
+
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
150
|
+
codecId: track.format,
|
|
151
|
+
language: track.language,
|
|
152
|
+
name: "",
|
|
153
|
+
isDefault: order === 0,
|
|
154
|
+
codecPrivate: "",
|
|
155
|
+
clusterPositions: [],
|
|
156
|
+
samples: track.samples
|
|
157
|
+
})),
|
|
158
|
+
declared: [],
|
|
159
|
+
secondsPerTick: 0.001,
|
|
160
|
+
segmentDataOffset: 0
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Read the samples of one subtitle track that are HELD now.
|
|
167
|
+
*
|
|
168
|
+
* An MP4 states every sample's byte range in its own table, so a cue costs its
|
|
169
|
+
* own few dozen bytes rather than the cluster around it — which is why this
|
|
170
|
+
* reads per sample where Matroska reads per cluster.
|
|
171
|
+
*
|
|
172
|
+
* Nothing is fetched: a sample whose bytes are not downloaded is left for the
|
|
173
|
+
* next call.
|
|
174
|
+
*
|
|
175
|
+
* @param {{ codecId: string, samples: {offset: number, size: number, startSeconds: number, endSeconds: number}[] }} track
|
|
176
|
+
* @param {Set<number>} harvested - Sample offsets already read; added to.
|
|
177
|
+
* @returns {Promise<{startSeconds: number, endSeconds: number, text: string}[]>}
|
|
178
|
+
* The cues found in THIS pass.
|
|
179
|
+
*/
|
|
180
|
+
async readHeldCues(_plan, track, progress) {
|
|
181
|
+
let harvested = progress.harvested.get(track.trackNumber);
|
|
182
|
+
if (!harvested) {
|
|
183
|
+
harvested = new Set();
|
|
184
|
+
progress.harvested.set(track.trackNumber, harvested);
|
|
185
|
+
}
|
|
186
|
+
const cues = await this.readHeldSamples(track, harvested);
|
|
187
|
+
return {
|
|
188
|
+
found: cues.length > 0 ? new Map([[track.trackNumber, cues]]) : new Map(),
|
|
189
|
+
covered: harvested.size,
|
|
190
|
+
indexed: track?.samples?.length ?? 0
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async readHeldSamples(track, harvested) {
|
|
195
|
+
const found = [];
|
|
196
|
+
for (const sample of track?.samples ?? []) {
|
|
197
|
+
if (harvested.has(sample.offset)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const last = Math.min(this.fileSize - 1, sample.offset + sample.size - 1);
|
|
201
|
+
if (!this.isHeld(sample.offset, last)) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const bytes = await this.readHeld(sample.offset, last);
|
|
205
|
+
if (!bytes) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
harvested.add(sample.offset);
|
|
209
|
+
// The MP4 has framed this cue and is the one that unframes it.
|
|
210
|
+
const text = Mp4Container.cueTextOf(bytes, track.codecId);
|
|
211
|
+
if (text) {
|
|
212
|
+
found.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return found;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async readTracks() {
|
|
219
|
+
const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
|
|
220
|
+
if (!head || !isMp4(head)) return [];
|
|
221
|
+
|
|
222
|
+
// Use the subtitle plan reader's moov parsing as source for subtitle tracks,
|
|
223
|
+
// and a direct moov walk for video/audio to collect tkhd/mdhd/hdlr/elng uniformly.
|
|
224
|
+
// For simplicity, delegate entirely to readMp4SubtitlePlan for subtitles and
|
|
225
|
+
// do a lightweight moov walk for video/audio here — then merge.
|
|
226
|
+
const subtitlePlan = await readMp4SubtitlePlan(this.readRange, this.fileSize).catch(() => null);
|
|
227
|
+
const subtitleByDecl = new Map();
|
|
228
|
+
if (subtitlePlan?.tracks) {
|
|
229
|
+
for (const t of subtitlePlan.tracks) subtitleByDecl.set(t.declaredIndex, t);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Minimal moov walk for video/audio: reuse isMp4 + findMoov logic by reading via existing helper
|
|
233
|
+
// Instead of duplicating, parse tracks via a second full moov read that collects vide/soun.
|
|
234
|
+
// We read moov box directly to extract video/audio tracks.
|
|
235
|
+
const tracks = await this.#readVideoAudioTracks();
|
|
236
|
+
// Append subtitle tracks from plan, converting to domain objects
|
|
237
|
+
if (subtitlePlan?.tracks) {
|
|
238
|
+
for (const s of subtitlePlan.tracks) {
|
|
239
|
+
const isText = TEXT_FORMATS_MP4.has(s.format);
|
|
240
|
+
const Cls = isText ? TextSubtitleTrack : ImageSubtitleTrack;
|
|
241
|
+
tracks.push(new Cls({
|
|
242
|
+
trackNumber: s.trackId,
|
|
243
|
+
declaredIndex: s.declaredIndex,
|
|
244
|
+
codecId: s.format,
|
|
245
|
+
language: s.language,
|
|
246
|
+
languageBcp47: "",
|
|
247
|
+
name: "",
|
|
248
|
+
isEnabled: true,
|
|
249
|
+
isDefault: s.declaredIndex === 0,
|
|
250
|
+
declaresDefault: false,
|
|
251
|
+
codecPrivateB64: "",
|
|
252
|
+
// The sample entry's own words, read below. Either
|
|
253
|
+
// bit is enough: a file that sets only "all samples are forced" is
|
|
254
|
+
// saying what a well-formed one says twice.
|
|
255
|
+
isForced: s.someSamplesForced === true || s.allSamplesForced === true,
|
|
256
|
+
isHearingImpaired: false,
|
|
257
|
+
clusterPositions: [],
|
|
258
|
+
samples: s.samples
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
// Count non-text subtitle handlers (subp/clcp/stpp) for declaredIndex correctness — they are already
|
|
262
|
+
// accounted for in subtitlePlan's declaredIndex via SUBTITLE_HANDLERS, but we didn't create objects for
|
|
263
|
+
// them above when they were stpp (non-text not in plan's tracks). The plan already excludes stpp from tracks
|
|
264
|
+
// but increments declaredIndex, so alignment holds: we don't need extra placeholders.
|
|
265
|
+
}
|
|
266
|
+
return tracks;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The `moov` box, read whole.
|
|
271
|
+
*
|
|
272
|
+
* Held on the instance because every question this class answers is inside
|
|
273
|
+
* it, and the box can be tens of megabytes off a torrent — reading it once
|
|
274
|
+
* per file is the difference between one fetch and one per question.
|
|
275
|
+
*
|
|
276
|
+
* @returns {Promise<{ moov: Buffer, header: number } | null>}
|
|
277
|
+
*/
|
|
278
|
+
async #moovBuffer() {
|
|
279
|
+
if (this.moovHeld !== undefined) {
|
|
280
|
+
return this.moovHeld;
|
|
281
|
+
}
|
|
282
|
+
const PROBE = 64;
|
|
283
|
+
const MAX_MOOV = 32 * 1024 * 1024;
|
|
284
|
+
let at = 0;
|
|
285
|
+
let moovBox = null;
|
|
286
|
+
while (at < this.fileSize) {
|
|
287
|
+
const probe = await this.readRange(at, Math.min(this.fileSize - 1, at + PROBE - 1));
|
|
288
|
+
if (!probe || probe.length < 8) break;
|
|
289
|
+
let size = probe.readUInt32BE(0);
|
|
290
|
+
const type = probe.toString("latin1", 4, 8);
|
|
291
|
+
let header = 8;
|
|
292
|
+
if (size === 1) {
|
|
293
|
+
if (probe.length < 16) break;
|
|
294
|
+
size = Number(probe.readBigUInt64BE(8));
|
|
295
|
+
header = 16;
|
|
296
|
+
}
|
|
297
|
+
if (size <= 0) break;
|
|
298
|
+
if (type === "moov") { moovBox = { offset: at, size, header }; break; }
|
|
299
|
+
at += size;
|
|
300
|
+
}
|
|
301
|
+
if (!moovBox || moovBox.size > MAX_MOOV) {
|
|
302
|
+
this.moovHeld = null;
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const moov = await this.readRange(moovBox.offset, Math.min(this.fileSize - 1, moovBox.offset + moovBox.size - 1));
|
|
306
|
+
this.moovHeld = moov ? { moov, header: moovBox.header } : null;
|
|
307
|
+
return this.moovHeld;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Duration from `mvhd` and the presentation offset from the first track's
|
|
312
|
+
* edit list, per ISO/IEC 14496-12 §8.2.2 and §8.6.6.
|
|
313
|
+
*
|
|
314
|
+
* An edit entry whose `media_time` is -1 is an EMPTY edit: it presents
|
|
315
|
+
* nothing for `segment_duration`, which shifts everything after it later by
|
|
316
|
+
* that much. That shift is what a player reports as the file's start, and it
|
|
317
|
+
* is the only way an MP4 states one — a file without such an edit begins at
|
|
318
|
+
* zero, which is a declaration, not an absence.
|
|
319
|
+
*
|
|
320
|
+
* @returns {Promise<import("./Container.js").ContainerMediaInfo>}
|
|
321
|
+
*/
|
|
322
|
+
async readMediaInfo() {
|
|
323
|
+
if (this.mediaInfo) {
|
|
324
|
+
return this.mediaInfo;
|
|
325
|
+
}
|
|
326
|
+
/** @type {import("./Container.js").ContainerMediaInfo} */
|
|
327
|
+
const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
|
|
328
|
+
this.mediaInfo = info;
|
|
329
|
+
const held = await this.#moovBuffer();
|
|
330
|
+
if (!held) {
|
|
331
|
+
return info;
|
|
332
|
+
}
|
|
333
|
+
const { moov, header } = held;
|
|
334
|
+
const mvhd = childOf(moov, header, moov.length, "mvhd");
|
|
335
|
+
let movieTimescale = 0;
|
|
336
|
+
if (mvhd) {
|
|
337
|
+
const version = moov[mvhd.dataOffset];
|
|
338
|
+
// version 0: creation(4) modification(4) timescale(4) duration(4)
|
|
339
|
+
// version 1: creation(8) modification(8) timescale(4) duration(8)
|
|
340
|
+
const at = version === 1 ? mvhd.dataOffset + 20 : mvhd.dataOffset + 12;
|
|
341
|
+
if (at + 8 <= moov.length) {
|
|
342
|
+
movieTimescale = moov.readUInt32BE(at);
|
|
343
|
+
const duration = version === 1 ? Number(moov.readBigUInt64BE(at + 4)) : moov.readUInt32BE(at + 4);
|
|
344
|
+
if (movieTimescale > 0 && duration > 0) {
|
|
345
|
+
info.durationSeconds = duration / movieTimescale;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
info.startTimeSeconds = movieTimescale > 0
|
|
350
|
+
? Mp4Container.#emptyEditSeconds(moov, header, movieTimescale)
|
|
351
|
+
: null;
|
|
352
|
+
return info;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The presentation shift of the first empty edit, in seconds; 0 when no track
|
|
357
|
+
* declares one.
|
|
358
|
+
*
|
|
359
|
+
* @param {Buffer} moov
|
|
360
|
+
* @param {number} header
|
|
361
|
+
* @param {number} movieTimescale
|
|
362
|
+
* @returns {number}
|
|
363
|
+
*/
|
|
364
|
+
static #emptyEditSeconds(moov, header, movieTimescale) {
|
|
365
|
+
let shift = 0;
|
|
366
|
+
for (const trak of childrenOf(moov, header, moov.length, "trak")) {
|
|
367
|
+
const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
|
|
368
|
+
const elst = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
|
|
369
|
+
if (!elst) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const version = moov[elst.dataOffset];
|
|
373
|
+
const count = moov.readUInt32BE(elst.dataOffset + 4);
|
|
374
|
+
if (count < 1) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const entry = elst.dataOffset + 8;
|
|
378
|
+
const segmentDuration = version === 1
|
|
379
|
+
? Number(moov.readBigUInt64BE(entry))
|
|
380
|
+
: moov.readUInt32BE(entry);
|
|
381
|
+
const mediaTime = version === 1
|
|
382
|
+
? Number(moov.readBigInt64BE(entry + 8))
|
|
383
|
+
: moov.readInt32BE(entry + 4);
|
|
384
|
+
if (mediaTime === -1 && segmentDuration > 0) {
|
|
385
|
+
shift = Math.max(shift, segmentDuration / movieTimescale);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return shift;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async #readVideoAudioTracks() {
|
|
392
|
+
const held = await this.#moovBuffer();
|
|
393
|
+
if (!held) return [];
|
|
394
|
+
const { moov, header: moovHeader } = held;
|
|
395
|
+
|
|
396
|
+
const result = [];
|
|
397
|
+
let videoIdx = -1;
|
|
398
|
+
let audioIdx = -1;
|
|
399
|
+
|
|
400
|
+
const moovContentStart = moovHeader;
|
|
401
|
+
const moovEnd = moov.length;
|
|
402
|
+
for (const trak of childrenOf(moov, moovContentStart, moovEnd, "trak")) {
|
|
403
|
+
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
404
|
+
if (!mdia) continue;
|
|
405
|
+
const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
|
|
406
|
+
const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
407
|
+
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
408
|
+
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
409
|
+
let language = "";
|
|
410
|
+
if (mdhd) {
|
|
411
|
+
const ver = moov[mdhd.dataOffset];
|
|
412
|
+
const langAt = ver === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
|
|
413
|
+
if (langAt + 2 <= mdhd.end) {
|
|
414
|
+
const packed = moov.readUInt16BE(langAt);
|
|
415
|
+
language = [10, 5, 0].map((s) => String.fromCharCode(((packed >> s) & 0x1f) + 0x60)).join("").replace(/[^a-z]/g, "");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// elng overrides mdhd language per spec §8.4.6
|
|
419
|
+
let languageBcp47 = "";
|
|
420
|
+
const elng = childOf(moov, mdia.dataOffset, mdia.end, "elng");
|
|
421
|
+
if (elng && elng.end - elng.dataOffset >= 4) {
|
|
422
|
+
languageBcp47 = moov.toString("utf8", elng.dataOffset + 4, elng.end).replace(/\0+$/, "");
|
|
423
|
+
}
|
|
424
|
+
let trackId = 0;
|
|
425
|
+
let isEnabled = true;
|
|
426
|
+
let alternateGroup = 0;
|
|
427
|
+
let width = null;
|
|
428
|
+
let height = null;
|
|
429
|
+
if (tkhd) {
|
|
430
|
+
const ver = moov[tkhd.dataOffset];
|
|
431
|
+
const flags = moov.readUInt32BE(tkhd.dataOffset + 1) & 0xffffff; // 3 bytes after version
|
|
432
|
+
isEnabled = (flags & 0x000001) !== 0;
|
|
433
|
+
trackId = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12);
|
|
434
|
+
alternateGroup = moov.readUInt16BE(ver === 1 ? tkhd.dataOffset + 26 : tkhd.dataOffset + 18);
|
|
435
|
+
// width/height are 16.16 fixed point at end of tkhd
|
|
436
|
+
if (tkhd.end - tkhd.dataOffset >= 84) {
|
|
437
|
+
const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 76 : tkhd.dataOffset + 68);
|
|
438
|
+
const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 80 : tkhd.dataOffset + 72);
|
|
439
|
+
width = w / 65536;
|
|
440
|
+
height = h / 65536;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const resolvedLang = languageBcp47 || language;
|
|
444
|
+
if (handler === "vide") {
|
|
445
|
+
videoIdx += 1;
|
|
446
|
+
// stsd format for codecId
|
|
447
|
+
let codecId = "";
|
|
448
|
+
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
449
|
+
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
450
|
+
const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
451
|
+
if (stsd) {
|
|
452
|
+
const first = readBox(moov, stsd.dataOffset + 8);
|
|
453
|
+
if (first) codecId = first.type;
|
|
454
|
+
}
|
|
455
|
+
result.push(new VideoTrack({
|
|
456
|
+
trackNumber: trackId,
|
|
457
|
+
declaredIndex: videoIdx,
|
|
458
|
+
codecId,
|
|
459
|
+
language: resolvedLang,
|
|
460
|
+
languageBcp47,
|
|
461
|
+
name: "",
|
|
462
|
+
isEnabled,
|
|
463
|
+
isDefault: true,
|
|
464
|
+
declaresDefault: false,
|
|
465
|
+
codecPrivateB64: "",
|
|
466
|
+
alternateGroup,
|
|
467
|
+
width,
|
|
468
|
+
height
|
|
469
|
+
}));
|
|
470
|
+
} else if (handler === "soun") {
|
|
471
|
+
audioIdx += 1;
|
|
472
|
+
let codecId = "";
|
|
473
|
+
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
474
|
+
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
475
|
+
const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
476
|
+
if (stsd) {
|
|
477
|
+
const first = readBox(moov, stsd.dataOffset + 8);
|
|
478
|
+
if (first) codecId = first.type;
|
|
479
|
+
}
|
|
480
|
+
result.push(new AudioTrack({
|
|
481
|
+
trackNumber: trackId,
|
|
482
|
+
declaredIndex: audioIdx,
|
|
483
|
+
codecId,
|
|
484
|
+
language: resolvedLang,
|
|
485
|
+
languageBcp47,
|
|
486
|
+
name: "",
|
|
487
|
+
isEnabled,
|
|
488
|
+
isDefault: true,
|
|
489
|
+
declaresDefault: false,
|
|
490
|
+
codecPrivateB64: "",
|
|
491
|
+
alternateGroup,
|
|
492
|
+
isOriginal: false,
|
|
493
|
+
isCommentary: false,
|
|
494
|
+
isVisualImpaired: false
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The text field of one cue as MP4 frames it.
|
|
503
|
+
*
|
|
504
|
+
* A `tx3g`/`text` sample is a 16-bit big-endian length followed by that many
|
|
505
|
+
* bytes of UTF-8 (ISO/IEC 14496-12 §12.6.3 and Apple's text sample format); a
|
|
506
|
+
* `wvtt` sample is a sequence of boxes whose `vttc`/`payl` holds the cue text
|
|
507
|
+
* (§12.6.3.2). Neither carries the subtitle format's own markup, so the
|
|
508
|
+
* markup step that follows has nothing to take off — it is applied all the
|
|
509
|
+
* same, because which step applies is decided by the codec and not here.
|
|
510
|
+
*
|
|
511
|
+
* The byte reading itself is this module's,
|
|
512
|
+
* alongside the sample-table walk that found the range.
|
|
513
|
+
*
|
|
514
|
+
* @param {Buffer} payload - The sample's own bytes.
|
|
515
|
+
* @param {string} codecId - Sample entry type: `tx3g`, `text` or `wvtt`.
|
|
516
|
+
* @returns {string}
|
|
517
|
+
*/
|
|
518
|
+
static cueTextOf(payload, codecId) {
|
|
519
|
+
return decodeSubtitleSample(payload, codecId);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async parseKeyframeIndex() {
|
|
523
|
+
const r = await readMp4KeyframeTimes(this.readRange, this.fileSize);
|
|
524
|
+
if (!r) return null;
|
|
525
|
+
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|
|
526
|
+
return r;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// The MP4's own reading of its subtitle sample table. Here because every rule
|
|
532
|
+
// in it is ISO/IEC 14496-12 speaking about this container, and the class is
|
|
533
|
+
// the only way in.
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
/**
|
|
536
|
+
* @file The text subtitle tracks of an MP4, and where each cue's bytes are.
|
|
537
|
+
*
|
|
538
|
+
* The same rule as the Matroska side: nothing is extracted with ffmpeg and
|
|
539
|
+
* nothing is fetched for its own sake. Here it is cheaper still. Matroska hides
|
|
540
|
+
* its subtitle blocks inside clusters shared with the picture, so a cue costs
|
|
541
|
+
* whatever cluster holds it; an MP4 states every sample's offset and length in
|
|
542
|
+
* the sample table, so a cue costs its own bytes and nothing more — usually a
|
|
543
|
+
* few dozen of them.
|
|
544
|
+
*
|
|
545
|
+
* The tables, from ISO/IEC 14496-12:
|
|
546
|
+
*
|
|
547
|
+
* stsd — what the samples are (`tx3g` timed text, `wvtt` WebVTT, `stpp` TTML)
|
|
548
|
+
* stts — how long each sample lasts, run-length encoded (§8.6.1.2)
|
|
549
|
+
* stsz — how long each sample is, in bytes (§8.7.3)
|
|
550
|
+
* stsc — how samples are grouped into chunks (§8.7.4)
|
|
551
|
+
* stco / co64 — where each chunk begins in the file (§8.7.5)
|
|
552
|
+
*
|
|
553
|
+
* Together they give, for sample N: when it starts, how long it stays, and the
|
|
554
|
+
* exact byte range holding it. That is everything needed to show a cue without
|
|
555
|
+
* reading anything else.
|
|
556
|
+
*/
|
|
557
|
+
|
|
558
|
+
const PROBE_BYTES = 64;
|
|
559
|
+
const MAX_MOOV_BYTES = 32 * 1024 * 1024;
|
|
560
|
+
|
|
561
|
+
/** Handlers that mean "this track is text on screen". */
|
|
562
|
+
const TEXT_HANDLERS = new Set(["text", "sbtl", "subt"]);
|
|
563
|
+
/**
|
|
564
|
+
* Every handler ffmpeg's mov demuxer turns into a SUBTITLE stream, whether or
|
|
565
|
+
* not this file can read it — `subp` is a DVD subpicture and `clcp` closed
|
|
566
|
+
* captions, both pictures or caption data rather than text. They are counted
|
|
567
|
+
* because `declaredIndex` has to equal ffmpeg's `0:s:N`, and a track left out
|
|
568
|
+
* of the count shifts every text track after it, which is the very defect
|
|
569
|
+
* `declaredIndex` exists to remove.
|
|
570
|
+
*/
|
|
571
|
+
const SUBTITLE_HANDLERS = new Set([...TEXT_HANDLERS, "subp", "clcp"]);
|
|
572
|
+
/** Sample formats this can turn into cues. `stpp` (TTML) is XML and is not one. */
|
|
573
|
+
const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Sample durations, expanded from the run-length table.
|
|
581
|
+
*
|
|
582
|
+
* @param {Buffer} moov
|
|
583
|
+
* @param {{ dataOffset: number, end: number }} stts
|
|
584
|
+
* @param {number} total - How many samples the size table declares.
|
|
585
|
+
* @returns {number[]} Ticks each sample lasts.
|
|
586
|
+
*/
|
|
587
|
+
function sampleDurations(moov, stts, total) {
|
|
588
|
+
const durations = new Array(total).fill(0);
|
|
589
|
+
const entries = moov.readUInt32BE(stts.dataOffset + 4);
|
|
590
|
+
let at = stts.dataOffset + 8;
|
|
591
|
+
let sample = 0;
|
|
592
|
+
for (let entry = 0; entry < entries && at + 8 <= stts.end && sample < total; entry += 1, at += 8) {
|
|
593
|
+
const count = moov.readUInt32BE(at);
|
|
594
|
+
const delta = moov.readUInt32BE(at + 4);
|
|
595
|
+
for (let index = 0; index < count && sample < total; index += 1, sample += 1) {
|
|
596
|
+
durations[sample] = delta;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return durations;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Sample sizes, whether the table states one for all or one for each.
|
|
604
|
+
*
|
|
605
|
+
* @param {Buffer} moov
|
|
606
|
+
* @param {{ dataOffset: number, end: number }} stsz
|
|
607
|
+
* @returns {number[]}
|
|
608
|
+
*/
|
|
609
|
+
function sampleSizes(moov, stsz) {
|
|
610
|
+
const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
|
|
611
|
+
const count = moov.readUInt32BE(stsz.dataOffset + 8);
|
|
612
|
+
if (uniform > 0) {
|
|
613
|
+
return new Array(count).fill(uniform);
|
|
614
|
+
}
|
|
615
|
+
const sizes = new Array(count).fill(0);
|
|
616
|
+
let at = stsz.dataOffset + 12;
|
|
617
|
+
for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
|
|
618
|
+
sizes[index] = moov.readUInt32BE(at);
|
|
619
|
+
}
|
|
620
|
+
return sizes;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Where every sample of a track begins in the file.
|
|
625
|
+
*
|
|
626
|
+
* The sample-to-chunk table says how many samples each run of chunks holds, and
|
|
627
|
+
* the chunk-offset table says where each chunk starts; a sample's own offset is
|
|
628
|
+
* its chunk's start plus the sizes of the samples before it in that chunk.
|
|
629
|
+
*
|
|
630
|
+
* @param {Buffer} moov
|
|
631
|
+
* @param {{ dataOffset: number, end: number }} stsc
|
|
632
|
+
* @param {number[]} chunkOffsets
|
|
633
|
+
* @param {number[]} sizes
|
|
634
|
+
* @returns {number[]}
|
|
635
|
+
*/
|
|
636
|
+
function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
637
|
+
const offsets = new Array(sizes.length).fill(0);
|
|
638
|
+
const entries = moov.readUInt32BE(stsc.dataOffset + 4);
|
|
639
|
+
/** @type {{ firstChunk: number, perChunk: number }[]} */
|
|
640
|
+
const runs = [];
|
|
641
|
+
let at = stsc.dataOffset + 8;
|
|
642
|
+
for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
|
|
643
|
+
runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
|
|
644
|
+
}
|
|
645
|
+
let sample = 0;
|
|
646
|
+
for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
|
|
647
|
+
const from = runs[run].firstChunk;
|
|
648
|
+
const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
|
|
649
|
+
for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
|
|
650
|
+
let inChunk = chunkOffsets[chunk - 1];
|
|
651
|
+
if (inChunk === undefined) {
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
|
|
655
|
+
offsets[sample] = inChunk;
|
|
656
|
+
inChunk += sizes[sample];
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return offsets;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* @typedef {object} Mp4SubtitleSample
|
|
665
|
+
* @property {number} startSeconds
|
|
666
|
+
* @property {number} endSeconds
|
|
667
|
+
* @property {number} offset - Where the sample's bytes are in the file.
|
|
668
|
+
* @property {number} size
|
|
669
|
+
*/
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* @typedef {object} Mp4SubtitleTrack
|
|
673
|
+
* @property {number} trackId
|
|
674
|
+
* @property {number} declaredIndex - Its position among ALL of the file's
|
|
675
|
+
* subtitle tracks, including the ones whose sample format this cannot turn
|
|
676
|
+
* into cues (`stpp` TTML). That is the number ffmpeg gives the same stream in
|
|
677
|
+
* `0:s:N`, which is the number the browser names; counting only the readable
|
|
678
|
+
* ones would shift every track after a TTML one.
|
|
679
|
+
* @property {string} format - `tx3g`, `text` or `wvtt`.
|
|
680
|
+
* @property {string} language - Three letters, as the file declares them.
|
|
681
|
+
* @property {boolean} someSamplesForced - The sample entry says at least one
|
|
682
|
+
* cue carries a forced (`frcd`) atom.
|
|
683
|
+
* @property {boolean} allSamplesForced - The sample entry says every cue is to
|
|
684
|
+
* be treated as forced, whether or not it carries that atom.
|
|
685
|
+
* @property {Mp4SubtitleSample[]} samples - In time order.
|
|
686
|
+
*/
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The text subtitle tracks of an MP4, with every cue's time and byte range.
|
|
690
|
+
*
|
|
691
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
692
|
+
* @param {number} fileSize
|
|
693
|
+
* @returns {Promise<{ tracks: Mp4SubtitleTrack[] } | null>}
|
|
694
|
+
*/
|
|
695
|
+
async function readMp4SubtitlePlan(readRange, fileSize) {
|
|
696
|
+
const found = await findMoov(readRange, fileSize);
|
|
697
|
+
if (!found || found.size > MAX_MOOV_BYTES) {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
const moov = await readRange(found.offset, Math.min(fileSize - 1, found.offset + found.size - 1));
|
|
701
|
+
if (!moov || moov.length < HEADER_BYTES) {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
const moovBox = readBox(moov, 0);
|
|
705
|
+
if (!moovBox) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** @type {Mp4SubtitleTrack[]} */
|
|
710
|
+
const tracks = [];
|
|
711
|
+
// Counts every subtitle track the file has, whether or not this can read it,
|
|
712
|
+
// so the number handed out matches ffmpeg's `0:s:N`. See `declaredIndex`.
|
|
713
|
+
let declaredIndex = -1;
|
|
714
|
+
for (const trak of childrenOf(moov, moovBox.dataOffset, moov.length, "trak")) {
|
|
715
|
+
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
716
|
+
if (!mdia) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
|
|
720
|
+
const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
721
|
+
if (!SUBTITLE_HANDLERS.has(handler)) {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
// Counted before the readability checks below, and before the handler is
|
|
725
|
+
// narrowed to the text ones: this number is the track's place in the file,
|
|
726
|
+
// not its place among the tracks this code can turn into cues.
|
|
727
|
+
declaredIndex += 1;
|
|
728
|
+
if (!TEXT_HANDLERS.has(handler)) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
732
|
+
if (!mdhd) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const version = moov[mdhd.dataOffset];
|
|
736
|
+
const timescale = moov.readUInt32BE(version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12);
|
|
737
|
+
if (!timescale) {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
// The language is five bits per letter, offset from 0x60, packed into two
|
|
741
|
+
// bytes after the times (ISO/IEC 14496-12 §8.4.2.3).
|
|
742
|
+
const languageAt = version === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
|
|
743
|
+
let language = "";
|
|
744
|
+
if (languageAt + 2 <= mdhd.end) {
|
|
745
|
+
const packed = moov.readUInt16BE(languageAt);
|
|
746
|
+
language = [10, 5, 0]
|
|
747
|
+
.map((shift) => String.fromCharCode(((packed >> shift) & 0x1f) + 0x60))
|
|
748
|
+
.join("")
|
|
749
|
+
.replace(/[^a-z]/g, "");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
753
|
+
const trackId = tkhd
|
|
754
|
+
? moov.readUInt32BE(moov[tkhd.dataOffset] === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12)
|
|
755
|
+
: tracks.length + 1;
|
|
756
|
+
|
|
757
|
+
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
758
|
+
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
759
|
+
if (!stbl) {
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const stsd = childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
763
|
+
const first = stsd && readBox(moov, stsd.dataOffset + 8);
|
|
764
|
+
const format = first ? first.type : "";
|
|
765
|
+
if (!TEXT_FORMATS.has(format)) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
// Whether the file itself says this track is forced — subtitles shown even
|
|
769
|
+
// to a viewer who did not ask for subtitles, because the dialogue on screen
|
|
770
|
+
// is in a language the soundtrack is not.
|
|
771
|
+
//
|
|
772
|
+
// Apple's QuickTime File Format, "Display flags" under Subtitle sample
|
|
773
|
+
// description, defines the two bits read here: `0x40000000` "Some samples
|
|
774
|
+
// are forced" ("at least one sample contains a forced (`frcd`) atom") and
|
|
775
|
+
// `0x80000000` "All samples are forced" ("the subtitle media handler treats
|
|
776
|
+
// all samples as forced subtitles, regardless of the presence or absence of
|
|
777
|
+
// a `frcd` atom"), with the note that setting the second requires the first
|
|
778
|
+
// — the pair together being `0xC0000000`. We honour that requirement rather
|
|
779
|
+
// than trusting a writer to have met it: either bit alone is enough to call
|
|
780
|
+
// the track forced, because a file that sets only `0x80000000` is saying
|
|
781
|
+
// exactly what a well-formed one would say twice.
|
|
782
|
+
//
|
|
783
|
+
// The field sits at a fixed place in the sample entry. `dataOffset` is
|
|
784
|
+
// already past the box header, and every sample entry opens with 6 reserved
|
|
785
|
+
// bytes and a 2-byte data reference index (ISO/IEC 14496-12 §8.5.2.2), so
|
|
786
|
+
// `displayFlags` is the 32 bits eight bytes in.
|
|
787
|
+
let someSamplesForced = false;
|
|
788
|
+
let allSamplesForced = false;
|
|
789
|
+
if (format === "tx3g" && first && first.dataOffset + 8 + 4 <= first.end) {
|
|
790
|
+
const displayFlags = moov.readUInt32BE(first.dataOffset + 8);
|
|
791
|
+
someSamplesForced = (displayFlags & 0x40000000) !== 0;
|
|
792
|
+
allSamplesForced = (displayFlags & 0x80000000) !== 0;
|
|
793
|
+
}
|
|
794
|
+
const stts = childOf(moov, stbl.dataOffset, stbl.end, "stts");
|
|
795
|
+
const stsz = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
|
|
796
|
+
const stsc = childOf(moov, stbl.dataOffset, stbl.end, "stsc");
|
|
797
|
+
const stco = childOf(moov, stbl.dataOffset, stbl.end, "stco");
|
|
798
|
+
const co64 = childOf(moov, stbl.dataOffset, stbl.end, "co64");
|
|
799
|
+
if (!stts || !stsz || !stsc || (!stco && !co64)) {
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const sizes = sampleSizes(moov, stsz);
|
|
804
|
+
const durations = sampleDurations(moov, stts, sizes.length);
|
|
805
|
+
const chunkOffsets = [];
|
|
806
|
+
if (stco) {
|
|
807
|
+
const count = moov.readUInt32BE(stco.dataOffset + 4);
|
|
808
|
+
let at = stco.dataOffset + 8;
|
|
809
|
+
for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
|
|
810
|
+
chunkOffsets.push(moov.readUInt32BE(at));
|
|
811
|
+
}
|
|
812
|
+
} else {
|
|
813
|
+
const count = moov.readUInt32BE(co64.dataOffset + 4);
|
|
814
|
+
let at = co64.dataOffset + 8;
|
|
815
|
+
for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
|
|
816
|
+
chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
|
|
820
|
+
|
|
821
|
+
/** @type {Mp4SubtitleSample[]} */
|
|
822
|
+
const samples = [];
|
|
823
|
+
let ticks = 0;
|
|
824
|
+
for (let index = 0; index < sizes.length; index += 1) {
|
|
825
|
+
const start = ticks / timescale;
|
|
826
|
+
ticks += durations[index];
|
|
827
|
+
// An empty sample is a gap between cues, which the format uses to say
|
|
828
|
+
// "nothing on screen"; it is not a cue and would show as a blank line.
|
|
829
|
+
if (sizes[index] > 2) {
|
|
830
|
+
samples.push({
|
|
831
|
+
startSeconds: start,
|
|
832
|
+
endSeconds: ticks / timescale,
|
|
833
|
+
offset: offsets[index],
|
|
834
|
+
size: sizes[index]
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
tracks.push({
|
|
839
|
+
trackId,
|
|
840
|
+
declaredIndex,
|
|
841
|
+
format,
|
|
842
|
+
language,
|
|
843
|
+
someSamplesForced,
|
|
844
|
+
allSamplesForced,
|
|
845
|
+
samples
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return { tracks };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The text of one sample.
|
|
853
|
+
*
|
|
854
|
+
* `tx3g` is a two-byte length followed by UTF-8; anything after that is styling
|
|
855
|
+
* boxes, which this deliberately drops. `wvtt` is a sequence of boxes, and the
|
|
856
|
+
* text lives in the `payl` inside a `vttc`.
|
|
857
|
+
*
|
|
858
|
+
* @param {Buffer} bytes
|
|
859
|
+
* @param {string} format
|
|
860
|
+
* @returns {string}
|
|
861
|
+
*/
|
|
862
|
+
function decodeSubtitleSample(bytes, format) {
|
|
863
|
+
if (format === "wvtt") {
|
|
864
|
+
let at = 0;
|
|
865
|
+
const parts = [];
|
|
866
|
+
while (at + HEADER_BYTES <= bytes.length) {
|
|
867
|
+
const box = readBox(bytes, at);
|
|
868
|
+
if (!box) {
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
if (box.type === "vttc") {
|
|
872
|
+
const payl = childOf(bytes, box.dataOffset, Math.min(bytes.length, box.end), "payl");
|
|
873
|
+
if (payl) {
|
|
874
|
+
parts.push(bytes.toString("utf8", payl.dataOffset, Math.min(bytes.length, payl.end)));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
at = box.end;
|
|
878
|
+
}
|
|
879
|
+
return parts.join("\n").trim();
|
|
880
|
+
}
|
|
881
|
+
if (bytes.length < 2) {
|
|
882
|
+
return "";
|
|
883
|
+
}
|
|
884
|
+
const length = bytes.readUInt16BE(0);
|
|
885
|
+
return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
// ISO/IEC 14496-12 speaking about MP4: stss, stts, ctts and the edit list.
|
|
890
|
+
// Here because the class is the only way in.
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
/**
|
|
893
|
+
* @file Keyframe index for MP4/MOV, read without downloading the file.
|
|
894
|
+
*
|
|
895
|
+
* MP4 keeps its tables in a `moov` box: `stss` lists which samples are sync
|
|
896
|
+
* samples (keyframes) by number, and `stts` gives each sample's duration, so
|
|
897
|
+
* the two together turn "sample #N" into "second T". `moov` sits either at the
|
|
898
|
+
* start (files written for streaming) or at the end (the common case for a
|
|
899
|
+
* plain mux); box headers state their own size, so it is found by stepping over
|
|
900
|
+
* top-level boxes rather than scanning bytes — a couple of 64-byte reads even
|
|
901
|
+
* when `mdat` is gigabytes.
|
|
902
|
+
*
|
|
903
|
+
* Same purpose as the Matroska reader: on the video-COPY path the segment
|
|
904
|
+
* boundaries ARE the source's keyframes, and inventing an even grid instead
|
|
905
|
+
* makes players walk the whole file or present audio with no picture.
|
|
906
|
+
*/
|
|
907
|
+
|
|
908
|
+
// A 64-bit box size is signalled by a 32-bit size of 1, the real size following
|
|
909
|
+
// in the next 8 bytes.
|
|
910
|
+
// Enough to read any box header while walking the top level.
|
|
911
|
+
// Cap on the moov read. A feature-length file indexes to a few hundred KB;
|
|
912
|
+
// beyond this is not a normal index and not worth pulling over a torrent.
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Whether this looks like MP4/MOV — every real file opens with an `ftyp` box.
|
|
916
|
+
*
|
|
917
|
+
* @param {Buffer} head
|
|
918
|
+
* @returns {boolean}
|
|
919
|
+
*/
|
|
920
|
+
function isMp4(head) {
|
|
921
|
+
return head.length >= 12 && head.toString("latin1", 4, 8) === "ftyp";
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Read a box header at `offset`.
|
|
926
|
+
*
|
|
927
|
+
* @param {Buffer} buffer
|
|
928
|
+
* @param {number} offset
|
|
929
|
+
* @returns {{ type: string, size: number, headerBytes: number } | null}
|
|
930
|
+
*/
|
|
931
|
+
function readBoxHeader(buffer, offset) {
|
|
932
|
+
if (offset + HEADER_BYTES > buffer.length) {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
const size32 = buffer.readUInt32BE(offset);
|
|
936
|
+
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
937
|
+
if (size32 === LARGE_SIZE_MARKER) {
|
|
938
|
+
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
// High word is zero for any file we can practically handle.
|
|
942
|
+
const high = buffer.readUInt32BE(offset + 8);
|
|
943
|
+
const low = buffer.readUInt32BE(offset + 12);
|
|
944
|
+
return { type, size: high * 4294967296 + low, headerBytes: LARGE_HEADER_BYTES };
|
|
945
|
+
}
|
|
946
|
+
return { type, size: size32, headerBytes: HEADER_BYTES };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Walk the top-level boxes to find `moov`, reading only each box header.
|
|
951
|
+
*
|
|
952
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
953
|
+
* @param {number} fileSize
|
|
954
|
+
* @returns {Promise<{ offset: number, size: number, headerBytes: number } | null>}
|
|
955
|
+
*/
|
|
956
|
+
async function findMoov(readRange, fileSize) {
|
|
957
|
+
let offset = 0;
|
|
958
|
+
while (offset < fileSize) {
|
|
959
|
+
const probe = await readRange(offset, Math.min(fileSize - 1, offset + PROBE_BYTES - 1));
|
|
960
|
+
if (!probe || probe.length < HEADER_BYTES) {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
const header = readBoxHeader(probe, 0);
|
|
964
|
+
// Size 0 means "extends to end of file" — legal only for the last box, and
|
|
965
|
+
// never for one we would step over.
|
|
966
|
+
if (!header || header.size <= 0) {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
if (header.type === "moov") {
|
|
970
|
+
return { offset, size: header.size, headerBytes: header.headerBytes };
|
|
971
|
+
}
|
|
972
|
+
offset += header.size;
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Find the first box of `type` directly inside a range of an already-read buffer.
|
|
979
|
+
*
|
|
980
|
+
* @param {Buffer} buffer
|
|
981
|
+
* @param {number} start
|
|
982
|
+
* @param {number} end
|
|
983
|
+
* @param {string} type
|
|
984
|
+
* @returns {{ dataOffset: number, end: number } | null}
|
|
985
|
+
*/
|
|
986
|
+
function findBox(buffer, start, end, type) {
|
|
987
|
+
let offset = start;
|
|
988
|
+
while (offset + HEADER_BYTES <= end) {
|
|
989
|
+
const header = readBoxHeader(buffer, offset);
|
|
990
|
+
if (!header || header.size <= 0) {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
if (header.type === type) {
|
|
994
|
+
return { dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) };
|
|
995
|
+
}
|
|
996
|
+
offset += header.size;
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* All boxes of `type` directly inside a range.
|
|
1003
|
+
*
|
|
1004
|
+
* @param {Buffer} buffer
|
|
1005
|
+
* @param {number} start
|
|
1006
|
+
* @param {number} end
|
|
1007
|
+
* @param {string} type
|
|
1008
|
+
* @returns {{ dataOffset: number, end: number }[]}
|
|
1009
|
+
*/
|
|
1010
|
+
function findAllBoxes(buffer, start, end, type) {
|
|
1011
|
+
const found = [];
|
|
1012
|
+
let offset = start;
|
|
1013
|
+
while (offset + HEADER_BYTES <= end) {
|
|
1014
|
+
const header = readBoxHeader(buffer, offset);
|
|
1015
|
+
if (!header || header.size <= 0) {
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
if (header.type === type) {
|
|
1019
|
+
found.push({ dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) });
|
|
1020
|
+
}
|
|
1021
|
+
offset += header.size;
|
|
1022
|
+
}
|
|
1023
|
+
return found;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Turn sample numbers into seconds using the time-to-sample table.
|
|
1028
|
+
*
|
|
1029
|
+
* `stts` is run-length encoded — pairs of (sample count, per-sample duration) —
|
|
1030
|
+
* so one walk yields every sample's start time without expanding the table.
|
|
1031
|
+
*
|
|
1032
|
+
* @param {Buffer} buffer
|
|
1033
|
+
* @param {{ dataOffset: number, end: number }} stts
|
|
1034
|
+
* @param {number} timescale - Ticks per second.
|
|
1035
|
+
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
1036
|
+
* @returns {number[]} Seconds, ascending.
|
|
1037
|
+
*/
|
|
1038
|
+
function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
|
|
1039
|
+
const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
|
|
1040
|
+
const times = [];
|
|
1041
|
+
let sampleNumber = 1;
|
|
1042
|
+
let ticks = 0;
|
|
1043
|
+
let cursor = stts.dataOffset + 8;
|
|
1044
|
+
for (let entry = 0; entry < entryCount && cursor + 8 <= stts.end; entry += 1) {
|
|
1045
|
+
const count = buffer.readUInt32BE(cursor);
|
|
1046
|
+
const delta = buffer.readUInt32BE(cursor + 4);
|
|
1047
|
+
for (let index = 0; index < count; index += 1) {
|
|
1048
|
+
if (wanted.has(sampleNumber)) {
|
|
1049
|
+
// `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
|
|
1050
|
+
// what turns decode order into the order frames are shown in, and it is
|
|
1051
|
+
// the timeline ffmpeg cuts on.
|
|
1052
|
+
times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
|
|
1053
|
+
}
|
|
1054
|
+
ticks += delta;
|
|
1055
|
+
sampleNumber += 1;
|
|
1056
|
+
}
|
|
1057
|
+
cursor += 8;
|
|
1058
|
+
}
|
|
1059
|
+
return times;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Composition offsets for the sample numbers asked for.
|
|
1064
|
+
*
|
|
1065
|
+
* `ctts` is run-length encoded like `stts`, and version 1 carries SIGNED
|
|
1066
|
+
* offsets — which is what the version exists for: a frame may be shown before
|
|
1067
|
+
* it is decoded. Reading them as unsigned turns a small negative offset into
|
|
1068
|
+
* roughly four billion ticks.
|
|
1069
|
+
*
|
|
1070
|
+
* @param {Buffer} buffer
|
|
1071
|
+
* @param {{ dataOffset: number, end: number }} ctts
|
|
1072
|
+
* @param {Set<number>} wanted - Sample numbers (1-based).
|
|
1073
|
+
* @returns {Map<number, number>} Sample number to offset in media ticks.
|
|
1074
|
+
*/
|
|
1075
|
+
function readCompositionOffsets(buffer, ctts, wanted) {
|
|
1076
|
+
const version = buffer[ctts.dataOffset];
|
|
1077
|
+
const entryCount = buffer.readUInt32BE(ctts.dataOffset + 4);
|
|
1078
|
+
const offsets = new Map();
|
|
1079
|
+
let sampleNumber = 1;
|
|
1080
|
+
let cursor = ctts.dataOffset + 8;
|
|
1081
|
+
for (let entry = 0; entry < entryCount && cursor + 8 <= ctts.end; entry += 1) {
|
|
1082
|
+
const count = buffer.readUInt32BE(cursor);
|
|
1083
|
+
const offset = version === 1 ? buffer.readInt32BE(cursor + 4) : buffer.readUInt32BE(cursor + 4);
|
|
1084
|
+
for (let index = 0; index < count; index += 1) {
|
|
1085
|
+
if (wanted.has(sampleNumber)) {
|
|
1086
|
+
offsets.set(sampleNumber, offset);
|
|
1087
|
+
}
|
|
1088
|
+
sampleNumber += 1;
|
|
1089
|
+
}
|
|
1090
|
+
cursor += 8;
|
|
1091
|
+
}
|
|
1092
|
+
return offsets;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* How far the edit list shifts this track's composition timeline, in media
|
|
1097
|
+
* ticks.
|
|
1098
|
+
*
|
|
1099
|
+
* ISO/IEC 14496-12 §8.6.6.3: `media_time` is the start of the edit within the
|
|
1100
|
+
* media, in the MEDIA timescale and in composition time, while
|
|
1101
|
+
* `segment_duration` is in the MOVIE timescale — two different units in one
|
|
1102
|
+
* structure, which is why only the first is read here. `media_time = -1` is an
|
|
1103
|
+
* empty edit: it inserts blank presentation time and starts no media, so the
|
|
1104
|
+
* first real edit is the one that matters.
|
|
1105
|
+
*
|
|
1106
|
+
* Measured 2026-08-19: every LostFilm MP4 that carries a composition offset
|
|
1107
|
+
* also carries an edit list cancelling it exactly, which is why decode times
|
|
1108
|
+
* have been right on those files. `Firefly.S01E03` has the offset and NO edit
|
|
1109
|
+
* list, and its times were 62.1 ms early on all 34 keyframes checked.
|
|
1110
|
+
*
|
|
1111
|
+
* @param {Buffer} buffer
|
|
1112
|
+
* @param {{ dataOffset: number, end: number }} elst
|
|
1113
|
+
* @returns {number} Ticks to subtract; zero when nothing is shifted.
|
|
1114
|
+
*/
|
|
1115
|
+
function readEditShift(buffer, elst) {
|
|
1116
|
+
const version = buffer[elst.dataOffset];
|
|
1117
|
+
const entryCount = buffer.readUInt32BE(elst.dataOffset + 4);
|
|
1118
|
+
const wide = version === 1;
|
|
1119
|
+
const entryBytes = wide ? 20 : 12;
|
|
1120
|
+
let cursor = elst.dataOffset + 8;
|
|
1121
|
+
for (let entry = 0; entry < entryCount && cursor + entryBytes <= elst.end; entry += 1) {
|
|
1122
|
+
const mediaTime = wide
|
|
1123
|
+
? Number(buffer.readBigInt64BE(cursor + 8))
|
|
1124
|
+
: buffer.readInt32BE(cursor + 4);
|
|
1125
|
+
if (mediaTime >= 0) {
|
|
1126
|
+
return mediaTime;
|
|
1127
|
+
}
|
|
1128
|
+
cursor += entryBytes;
|
|
1129
|
+
}
|
|
1130
|
+
return 0;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Whether this track's handler says it carries video.
|
|
1135
|
+
*
|
|
1136
|
+
* The standard identifies a track by its `hdlr`, and nothing else does. Picking
|
|
1137
|
+
* "the first track that happens to carry sync samples" worked only because the
|
|
1138
|
+
* seven releases measured all put video first; a file whose audio track carries
|
|
1139
|
+
* them, or one that leads with a cover-art video track, would be read from the
|
|
1140
|
+
* wrong place. That is the same defect that was fixed in the Matroska reader on
|
|
1141
|
+
* 2026-08-18, arrived at from the other side.
|
|
1142
|
+
*
|
|
1143
|
+
* @param {Buffer} buffer
|
|
1144
|
+
* @param {{ dataOffset: number, end: number }} mdia
|
|
1145
|
+
* @returns {boolean}
|
|
1146
|
+
*/
|
|
1147
|
+
function isVideoTrack(buffer, mdia) {
|
|
1148
|
+
const hdlr = findBox(buffer, mdia.dataOffset, mdia.end, "hdlr");
|
|
1149
|
+
if (!hdlr || hdlr.dataOffset + 12 > hdlr.end) {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
// FullBox header (4) then a reserved pre_defined (4), then the handler type.
|
|
1153
|
+
return buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) === "vide";
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Read the keyframe times of an MP4/MOV file.
|
|
1158
|
+
*
|
|
1159
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
1160
|
+
* @param {number} fileSize
|
|
1161
|
+
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
1162
|
+
* carries no usable index (fragmented MP4, truncated or damaged `moov`).
|
|
1163
|
+
*/
|
|
1164
|
+
async function readMp4KeyframeTimes(readRange, fileSize) {
|
|
1165
|
+
const moovBox = await findMoov(readRange, fileSize);
|
|
1166
|
+
if (!moovBox || moovBox.size > MAX_MOOV_BYTES) {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const moov = await readRange(moovBox.offset, Math.min(fileSize - 1, moovBox.offset + moovBox.size - 1));
|
|
1171
|
+
if (!moov || moov.length < moovBox.headerBytes) {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Examine every track, and take the one whose HANDLER says it is video. A
|
|
1176
|
+
// track with no `stss` has every sample a keyframe, so it constrains nothing
|
|
1177
|
+
// and is skipped even when it is the video one.
|
|
1178
|
+
for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
|
|
1179
|
+
const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
|
|
1180
|
+
if (!mdia || !isVideoTrack(moov, mdia)) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
1184
|
+
if (!mdhd) {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
// mdhd layout: version(1) + flags(3), then creation/modification times —
|
|
1188
|
+
// 32-bit each in version 0, 64-bit in version 1 — then the timescale.
|
|
1189
|
+
const version = moov[mdhd.dataOffset];
|
|
1190
|
+
const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
|
|
1191
|
+
if (timescaleOffset + 4 > mdhd.end) {
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const timescale = moov.readUInt32BE(timescaleOffset);
|
|
1195
|
+
if (!timescale) {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
|
|
1200
|
+
const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
|
|
1201
|
+
if (!stbl) {
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
|
|
1205
|
+
const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
|
|
1206
|
+
if (!stss || !stts) {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
|
|
1211
|
+
const wanted = new Set();
|
|
1212
|
+
for (let index = 0; index < syncCount; index += 1) {
|
|
1213
|
+
const at = stss.dataOffset + 8 + index * 4;
|
|
1214
|
+
if (at + 4 > stss.end) {
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
wanted.add(moov.readUInt32BE(at));
|
|
1218
|
+
}
|
|
1219
|
+
if (wanted.size === 0) {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// The two terms that turn decode times into the timeline ffmpeg cuts on.
|
|
1224
|
+
// Both are optional: a file without them is one whose decode and
|
|
1225
|
+
// composition orders already agree, and then nothing is added or taken.
|
|
1226
|
+
const ctts = findBox(moov, stbl.dataOffset, stbl.end, "ctts");
|
|
1227
|
+
const offsets = ctts ? readCompositionOffsets(moov, ctts, wanted) : null;
|
|
1228
|
+
const edts = findBox(moov, trak.dataOffset, trak.end, "edts");
|
|
1229
|
+
const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
|
|
1230
|
+
const editShift = elst ? readEditShift(moov, elst) : 0;
|
|
1231
|
+
|
|
1232
|
+
const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
|
|
1233
|
+
if (times.length > 0) {
|
|
1234
|
+
// A shift applied after the division would be in the wrong units: the
|
|
1235
|
+
// edit's `media_time` is in MEDIA ticks, like everything else here.
|
|
1236
|
+
const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
|
|
1237
|
+
// A negative time is not a position in the file. It happens when an edit
|
|
1238
|
+
// starts later than a keyframe the table lists, and those frames are not
|
|
1239
|
+
// presented at all.
|
|
1240
|
+
return shifted.filter((time) => time >= 0);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|