@torrent-tv/proxy 2.87.4 → 2.87.6
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 +8 -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 +94 -94
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +129 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +361 -361
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2162 -2162
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1083 -1083
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +266 -266
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +212 -212
- 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/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1146 -1146
- package/services/server/ViewerRequests.js +522 -522
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +36 -36
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +983 -983
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2 -2
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +1 -1
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/data-channel-handler.js +1356 -1356
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +657 -657
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -73
- package/test/asset-paths.test.js +28 -28
- package/test/audio-inventory.test.js +176 -176
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +35 -35
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +161 -161
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/source-registry.test.js +18 -0
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/{services/torrent → utils}/torrent-source-key.js +38 -38
|
@@ -1,776 +1,776 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One running encoder: its process, the stretch it was given, where it
|
|
3
|
-
* has got to, and how it ended.
|
|
4
|
-
*
|
|
5
|
-
* Until now a run was ten fields on a session — the process, the run state, the
|
|
6
|
-
* directory, the start and end numbers, a generation counter, the superseded
|
|
7
|
-
* processes, the run number, its label, its argument list — and there could be
|
|
8
|
-
* exactly one of them, because there was nowhere to put a second. It is an
|
|
9
|
-
* object here so that an output can have as many as the machine affords, and so
|
|
10
|
-
* that a run belongs to a STRETCH of an output rather than to a viewer.
|
|
11
|
-
*
|
|
12
|
-
* **The identity check disappears with the fields.** Every handler used to open
|
|
13
|
-
* with "is this still the session's process", because a replaced run's exit
|
|
14
|
-
* would otherwise write the session's error, its state and its failure tally —
|
|
15
|
-
* one set of fields for however many processes had lived. A run writes its own
|
|
16
|
-
* state and nothing else's, so a predecessor dying after its replacement has
|
|
17
|
-
* spawned can no longer be mistaken for the current run failing. That mistake
|
|
18
|
-
* cost a hardware encoder: on any host with one, every seek downgraded the
|
|
19
|
-
* proxy to libx264 for good.
|
|
20
|
-
*
|
|
21
|
-
* **A run has an end.** Neither `-to` nor `-t` appeared anywhere in the
|
|
22
|
-
* arguments this proxy built, so every run went until something killed it from
|
|
23
|
-
* outside. Given an end it finishes by itself, and two runs on one output
|
|
24
|
-
* cannot write over each other because their stretches do not overlap.
|
|
25
|
-
*
|
|
26
|
-
* **Every start and every end is recorded, with its cause** (required by the
|
|
27
|
-
* user 2026-09-04, because abnormal endings are frequent here). Exactly one
|
|
28
|
-
* ending is normal: the run reached the end of the stretch it was given and
|
|
29
|
-
* exited by itself. Every other ending, our own kill included, is abnormal and
|
|
30
|
-
* says so — with the code, the signal, how far it got, how long it lived, and
|
|
31
|
-
* what was cleared up after it.
|
|
32
|
-
*
|
|
33
|
-
* **Nothing here decides anything.** Where a run belongs, whether it should be
|
|
34
|
-
* moved and whether it should exist at all are `EncodePlan`'s, from numbers.
|
|
35
|
-
* What to do about an ending — fall back to software, wait for the input to
|
|
36
|
-
* come back, stop retrying a position that keeps failing — belongs to whoever
|
|
37
|
-
* owns the output. This carries a decision out and reports what happened.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
|
|
41
|
-
import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
|
|
42
|
-
import { RunProgress } from "./RunProgress.js";
|
|
43
|
-
|
|
44
|
-
/** Microseconds in a second, as ffmpeg's `out_time_ms` counts them. */
|
|
45
|
-
const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* @typedef {object} RunEnded
|
|
50
|
-
* @property {EncodeRun} run - The run itself. It has no name: what identifies
|
|
51
|
-
* it is that it IS itself, and what identifies it in a line is its output and
|
|
52
|
-
* the stretch it was given, which no other live run of that output holds.
|
|
53
|
-
* @property {string} address
|
|
54
|
-
* @property {string} ending - One of {@link ENCODE_EXIT}.
|
|
55
|
-
* @property {string} because - Why, in words, including who asked when we did.
|
|
56
|
-
* @property {number | null} code
|
|
57
|
-
* @property {string | null} signal
|
|
58
|
-
* @property {number} from
|
|
59
|
-
* @property {number} to
|
|
60
|
-
* @property {number} reached - The last number it finished, or `from - 1` when
|
|
61
|
-
* it finished none.
|
|
62
|
-
* @property {number} livedMs
|
|
63
|
-
* @property {boolean} normal - Whether this ending is the expected one.
|
|
64
|
-
* @property {string} lastError - The last thing ffmpeg said on stderr.
|
|
65
|
-
* @property {string | null} provenName - The last piece this run named while it
|
|
66
|
-
* was still running normally, and therefore the last one it is known to have
|
|
67
|
-
* finished. Anything on disk beyond it was open when the run ended, whatever
|
|
68
|
-
* ended it. Null where the run named nothing.
|
|
69
|
-
*/
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The last number this run was given, or Infinity when it was given no end.
|
|
73
|
-
*
|
|
74
|
-
* ONE reading of one convention. "No end" is written as a `to` below `from` —
|
|
75
|
-
* the session layer returns -1 for it and the log prints `#-1` — and every place
|
|
76
|
-
* that has to compare against it read that for itself. One place did not: the
|
|
77
|
-
* coverage map was handed the raw -1, `Math.max(from, -1)` made the claim one
|
|
78
|
-
* segment long, and the plan then saw the rest of the film as free. Field,
|
|
79
|
-
* 2026-09-05: an encoder was started and killed every five seconds, each one
|
|
80
|
-
* producing 0-2 segments, for as long as anybody watched.
|
|
81
|
-
*
|
|
82
|
-
* @param {{ from: number, to: number }} run
|
|
83
|
-
* @returns {number}
|
|
84
|
-
*/
|
|
85
|
-
export function endOfRun(run) {
|
|
86
|
-
const from = Number(run?.from);
|
|
87
|
-
const to = Number(run?.to);
|
|
88
|
-
return Number.isInteger(to) && to >= from ? to : Number.POSITIVE_INFINITY;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export class EncodeRun {
|
|
92
|
-
/** @type {import("node:child_process").ChildProcess | null} */
|
|
93
|
-
#process = null;
|
|
94
|
-
|
|
95
|
-
/** @type {string} */
|
|
96
|
-
#state = INITIAL_RUN_STATE;
|
|
97
|
-
|
|
98
|
-
/** Numbers this run has finished. @type {Set<number>} */
|
|
99
|
-
#produced = new Set();
|
|
100
|
-
|
|
101
|
-
/** @type {number} */
|
|
102
|
-
#startedAt = 0;
|
|
103
|
-
|
|
104
|
-
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
105
|
-
#closedTail = "";
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The last piece named on the ready channel while the run was still running
|
|
109
|
-
* normally — the last one it is KNOWN to have finished.
|
|
110
|
-
*
|
|
111
|
-
* "The encoder named it" was taken to mean "it is whole". That is true of the
|
|
112
|
-
* file and false of the span. On SIGTERM ffmpeg writes out the piece it had
|
|
113
|
-
* open and names it like any other; killed harder, or dying on its own, it
|
|
114
|
-
* leaves that piece unnamed and half-written. Both are readable, and neither
|
|
115
|
-
* covers the span its number promises. Field 2026-09-06: a run stopped
|
|
116
|
-
* mid-piece left `segment-00010.mp4` holding 3.92 s of the 5.589 s the
|
|
117
|
-
* playlist gives #10, and the viewer's picture jumped 1.5 s at 1:02; the
|
|
118
|
-
* soundtrack did the same at 17.5 s, 2.8 s wide, in the same session.
|
|
119
|
-
*
|
|
120
|
-
* Recorded by WHEN the name arrives, so nothing is read and no span is
|
|
121
|
-
* measured: a name that arrives after the stop was ordered is the flush and
|
|
122
|
-
* does not count as proof. A piece genuinely closed in the moment between the
|
|
123
|
-
* last normal name and the stop is then made a second time — the cheaper of
|
|
124
|
-
* the two errors, since the other is a hole the viewer sees.
|
|
125
|
-
*
|
|
126
|
-
* @type {string | null}
|
|
127
|
-
*/
|
|
128
|
-
#provenName = null;
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* When this run was told to stop, so the death itself can be priced.
|
|
132
|
-
*
|
|
133
|
-
* The plan weighs moving an encoder against letting it drive on, and dying is
|
|
134
|
-
* one of the terms. It was measured in the field at 430-729 ms — larger than
|
|
135
|
-
* the start it is added to — by a log line that lived in the one place that
|
|
136
|
-
* killed a run. That place is gone, so the run times its own death.
|
|
137
|
-
*/
|
|
138
|
-
#stopOrderedAt = 0;
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* When the first thing this run ever produced appeared.
|
|
142
|
-
*
|
|
143
|
-
* The other term the plan needs: a run started where nothing is downloaded
|
|
144
|
-
* waits for the swarm before it can encode a frame, and that wait is the
|
|
145
|
-
* largest part of what a restart costs. Nothing measured it before.
|
|
146
|
-
*/
|
|
147
|
-
#firstOutputAt = 0;
|
|
148
|
-
|
|
149
|
-
/** @type {number} */
|
|
150
|
-
#speedX = 0;
|
|
151
|
-
|
|
152
|
-
/** @type {boolean} */
|
|
153
|
-
#stopping = false;
|
|
154
|
-
|
|
155
|
-
/** @type {string} */
|
|
156
|
-
#stopReason = "";
|
|
157
|
-
|
|
158
|
-
/** @type {boolean} */
|
|
159
|
-
#ended = false;
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Whether this platform refuses to suspend a process.
|
|
163
|
-
*
|
|
164
|
-
* Asked once and remembered: `SIGSTOP` does not exist on Windows, and asking
|
|
165
|
-
* again every look-ahead pass would log the same refusal for the life of the
|
|
166
|
-
* run.
|
|
167
|
-
* @type {boolean}
|
|
168
|
-
*/
|
|
169
|
-
#pauseUnsupported = false;
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* @param {object} params
|
|
173
|
-
* @param {string} params.address - The output this run makes segments of.
|
|
174
|
-
* @param {import("./Encoder.js").Encoder} params.encoder
|
|
175
|
-
* @param {number} params.from - First segment number it is to make.
|
|
176
|
-
* @param {number} params.to - Last segment number it is to make, inclusive;
|
|
177
|
-
* below `from` means it was given no end.
|
|
178
|
-
* @param {() => string[]} params.buildArgs - The full argument list for this
|
|
179
|
-
* run. Supplied rather than built here: what to map, where to read from and
|
|
180
|
-
* how to cut belong to whoever knows the source, and this owns the process.
|
|
181
|
-
* @param {(args: string[]) => import("node:child_process").ChildProcess} params.spawn
|
|
182
|
-
* @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
|
|
183
|
-
* @param {() => number} [params.now]
|
|
184
|
-
* @param {(ended: RunEnded) => void} [params.onEnded]
|
|
185
|
-
* @param {(name: string) => string | null} [params.onClosed] - Called with the
|
|
186
|
-
* WORKING name of every piece the encoder has finished writing, as the
|
|
187
|
-
* encoder itself names it on its own channel, and answers with the name that
|
|
188
|
-
* piece is served under — because making it servable is a rename, and only
|
|
189
|
-
* whoever owns the disk can perform one.
|
|
190
|
-
* @param {(progress: object) => void} [params.onProgress] - Called with this
|
|
191
|
-
* run's progress on the source timeline after every `-progress` report.
|
|
192
|
-
* @param {() => number | null} [params.lastSegmentIndex] - The film's last
|
|
193
|
-
* segment number, for telling "reached the end" from "the input dried up".
|
|
194
|
-
* ffmpeg exits zero for both, and over a torrent it cannot tell them apart.
|
|
195
|
-
* @param {(message: string) => boolean} [params.inputUnavailable] - Whether
|
|
196
|
-
* ffmpeg's own message names a missing input rather than a bad encode.
|
|
197
|
-
* @param {string} [params.argsDescribed] - The command as one readable line,
|
|
198
|
-
* kept so a failure can quote what produced it.
|
|
199
|
-
* @param {boolean} [params.usesExplicitCuts] - Whether this run cuts at times
|
|
200
|
-
* it was given, which decides how a segment is judged finished.
|
|
201
|
-
* @param {string} params.because - Why this encoder is being put on the
|
|
202
|
-
* machine, in words. Recorded with the argument list: a start whose cause is
|
|
203
|
-
* not written down cannot be told from any other when several runs exist.
|
|
204
|
-
* @param {(name: string) => number | null} [params.indexOfName] - The number
|
|
205
|
-
* a closed piece's name carries. How a piece is named belongs to the format
|
|
206
|
-
* that writes it, so it arrives as a plain function rather than this class
|
|
207
|
-
* knowing any naming.
|
|
208
|
-
* @param {number} [params.startSeconds] - Where this run begins on the source timeline.
|
|
209
|
-
* @param {number | null} [params.totalSeconds] - Source duration when known.
|
|
210
|
-
*/
|
|
211
|
-
constructor({
|
|
212
|
-
address,
|
|
213
|
-
encoder,
|
|
214
|
-
from,
|
|
215
|
-
to,
|
|
216
|
-
buildArgs,
|
|
217
|
-
spawn,
|
|
218
|
-
logger,
|
|
219
|
-
now,
|
|
220
|
-
onEnded,
|
|
221
|
-
onClosed,
|
|
222
|
-
onProgress,
|
|
223
|
-
lastSegmentIndex,
|
|
224
|
-
inputUnavailable,
|
|
225
|
-
argsDescribed = "",
|
|
226
|
-
usesExplicitCuts = false,
|
|
227
|
-
indexOfName,
|
|
228
|
-
startSeconds = 0,
|
|
229
|
-
totalSeconds = null,
|
|
230
|
-
because = "no reason was given"
|
|
231
|
-
}) {
|
|
232
|
-
this.address = address;
|
|
233
|
-
this.encoder = encoder;
|
|
234
|
-
this.from = from;
|
|
235
|
-
this.to = to;
|
|
236
|
-
this.buildArgs = buildArgs;
|
|
237
|
-
this.spawnProcess = spawn;
|
|
238
|
-
this.logger = logger;
|
|
239
|
-
this.now = typeof now === "function" ? now : Date.now;
|
|
240
|
-
this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now });
|
|
241
|
-
this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
|
|
242
|
-
this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
|
|
243
|
-
// Told the NAME of every piece the encoder has closed. The name is the
|
|
244
|
-
// proof it is whole; nothing else here can prove that.
|
|
245
|
-
this.onClosed = typeof onClosed === "function" ? onClosed : () => {};
|
|
246
|
-
this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
|
|
247
|
-
this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
|
|
248
|
-
this.argsDescribed = argsDescribed;
|
|
249
|
-
this.usesExplicitCuts = usesExplicitCuts === true;
|
|
250
|
-
this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
|
|
251
|
-
/** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
|
|
252
|
-
this.lastError = "";
|
|
253
|
-
// EXISTING IS RUNNING. There is no moment at which a built run is not yet a
|
|
254
|
-
// process, so there is no second act for two owners to perform.
|
|
255
|
-
this.#begin(because);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** @returns {string} */
|
|
259
|
-
get state() {
|
|
260
|
-
return this.#state;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** @returns {number} */
|
|
264
|
-
get speedX() {
|
|
265
|
-
return this.#speedX;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** @returns {number} When it was spawned, or 0 before that. */
|
|
269
|
-
get startedAt() {
|
|
270
|
-
return this.#startedAt;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* The process itself, for the two things only a handle can answer: whether it
|
|
275
|
-
* has exited, and its pid. Nothing outside may kill it — that is `stop`, which
|
|
276
|
-
* records the cause.
|
|
277
|
-
*
|
|
278
|
-
* @returns {import("node:child_process").ChildProcess | null}
|
|
279
|
-
*/
|
|
280
|
-
get process() {
|
|
281
|
-
return this.#process;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* The next number this run will produce.
|
|
286
|
-
*
|
|
287
|
-
* Its position, and the figure the plan compares against what is already
|
|
288
|
-
* covered. Before it has finished anything that is where it started.
|
|
289
|
-
*
|
|
290
|
-
* @returns {number}
|
|
291
|
-
*/
|
|
292
|
-
get head() {
|
|
293
|
-
let head = this.from;
|
|
294
|
-
while (this.#produced.has(head)) {
|
|
295
|
-
head += 1;
|
|
296
|
-
}
|
|
297
|
-
return head;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* The last number it finished, or one before its start when it finished none.
|
|
302
|
-
*
|
|
303
|
-
* @returns {number}
|
|
304
|
-
*/
|
|
305
|
-
get reached() {
|
|
306
|
-
return this.head - 1;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* The last piece this run named while it was running normally.
|
|
311
|
-
*
|
|
312
|
-
* Its own statement that a piece is whole, and the only thing that can say so:
|
|
313
|
-
* a piece cut short still decodes, so its contents cannot be asked. Whatever
|
|
314
|
-
* lies beyond this name was open when the run ended, and that is what the
|
|
315
|
-
* clearing-up after a run reads.
|
|
316
|
-
*
|
|
317
|
-
* @returns {string | null}
|
|
318
|
-
*/
|
|
319
|
-
get provenName() {
|
|
320
|
-
return this.#provenName;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** @returns {number[]} */
|
|
324
|
-
get produced() {
|
|
325
|
-
return [...this.#produced].sort((left, right) => left - right);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Whether this run can still produce.
|
|
330
|
-
*
|
|
331
|
-
* A run told to stop cannot, whatever its process is still doing about the
|
|
332
|
-
* signal: the exit arrives a turn or two later, and until then everything
|
|
333
|
-
* asking "is anything encoding" would be answered yes by a run that is on its
|
|
334
|
-
* way out — and a caller deciding whether to start one would decide not to.
|
|
335
|
-
*
|
|
336
|
-
* @returns {boolean}
|
|
337
|
-
*/
|
|
338
|
-
get isAlive() {
|
|
339
|
-
return this.#process !== null && !this.#ended && !this.#stopping;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Whether it has been told to stop and its exit has not arrived yet.
|
|
344
|
-
*
|
|
345
|
-
* Asked by whoever sweeps for runs that ended without saying so: this one is
|
|
346
|
-
* going to say so.
|
|
347
|
-
*
|
|
348
|
-
* @returns {boolean}
|
|
349
|
-
*/
|
|
350
|
-
get isStopping() {
|
|
351
|
-
return this.#stopping && !this.#ended;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** @returns {boolean} Whether it is stopped where it stands, producing nothing. */
|
|
355
|
-
get isSuspended() {
|
|
356
|
-
return this.#state === ENCODE_RUN_STATE.SUSPENDED;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Put the process on the machine, and say why.
|
|
361
|
-
*
|
|
362
|
-
* PRIVATE, AND CALLED ONCE, from the constructor. It was public until
|
|
363
|
-
* 2026-09-10, and two places called it: the session manager built a run and
|
|
364
|
-
* started it, then handed it back to the orchestrator, which started it
|
|
365
|
-
* again. Every run of every session therefore had TWO ffmpeg processes on one
|
|
366
|
-
* output writing one set of names — 207 runs against 414 spawns in the field
|
|
367
|
-
* logs of 08-10 September, without a single exception. Only the second was
|
|
368
|
-
* reachable afterwards, because this line overwrote the reference to the
|
|
369
|
-
* first, so `stop` killed one and the other ran on: measured 105 seconds past
|
|
370
|
-
* its own run's death, eleven processes writing at once on a four-core host
|
|
371
|
-
* whose budget said three.
|
|
372
|
-
*
|
|
373
|
-
* The guard against that is not a check but the absence of a second act: a
|
|
374
|
-
* run exists means its process is running, so there is nothing anybody can
|
|
375
|
-
* call twice.
|
|
376
|
-
*
|
|
377
|
-
* The reason is not decoration: a start whose cause is not recorded cannot be
|
|
378
|
-
* told from any other start when several runs exist at once, and the argument
|
|
379
|
-
* list alone does not say whether this was a first open, a viewer's seek, a
|
|
380
|
-
* quality step or a move off covered material.
|
|
381
|
-
*
|
|
382
|
-
* @param {string} because
|
|
383
|
-
*/
|
|
384
|
-
#begin(because) {
|
|
385
|
-
const args = this.buildArgs();
|
|
386
|
-
this.#startedAt = this.now();
|
|
387
|
-
this.logger.info(
|
|
388
|
-
`encode-run #${this.from}..#${this.to} of ${this.address} ` +
|
|
389
|
-
`by ${this.encoder?.name ?? "?"}: ${because} ` +
|
|
390
|
-
`:: ffmpeg ${this.argsDescribed || args.join(" ")}`
|
|
391
|
-
);
|
|
392
|
-
this.#process = this.spawnProcess(args);
|
|
393
|
-
this.#transition(ENCODE_RUN_EVENT.SPAWNED);
|
|
394
|
-
this.#wire(this.#process);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Everything the process says about itself: how far it has got, how fast, and
|
|
399
|
-
* what went wrong.
|
|
400
|
-
*
|
|
401
|
-
* No handler asks whether this process is still the current one. It writes
|
|
402
|
-
* this run's own fields and nothing shared, so a predecessor still dying
|
|
403
|
-
* after its replacement has spawned cannot be mistaken for the current run.
|
|
404
|
-
*
|
|
405
|
-
* @param {import("node:child_process").ChildProcess} process
|
|
406
|
-
*/
|
|
407
|
-
#wire(process) {
|
|
408
|
-
process.stdout?.on("data", (chunk) => this.#readProgress(String(chunk)));
|
|
409
|
-
// THE CHANNEL THE ENCODER NAMES ITS FINISHED PIECES ON.
|
|
410
|
-
//
|
|
411
|
-
// A name arrives here when ffmpeg CLOSES the file, so the name is proof the
|
|
412
|
-
// piece is whole — measured on the addon host 2026-09-05. Nothing else can
|
|
413
|
-
// prove it: a file on disk may still be being written, and the only other
|
|
414
|
-
// evidence available was the existence of the NEXT file, which never comes
|
|
415
|
-
// for the last piece of a run.
|
|
416
|
-
//
|
|
417
|
-
// Lines can arrive split, so what is left over is kept for the next chunk.
|
|
418
|
-
process.stdio?.[3]?.on("data", (chunk) => this.#readClosedPieces(String(chunk)));
|
|
419
|
-
process.stderr?.on("data", (chunk) => {
|
|
420
|
-
const line = String(chunk).trim();
|
|
421
|
-
if (line.length > 0) {
|
|
422
|
-
this.lastError = line;
|
|
423
|
-
this.logger.warn(`ffmpeg #${this.from}..#${this.to} of ${this.address}: ${line}`);
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
process.on("error", (error) => {
|
|
427
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
428
|
-
this.lastError = message;
|
|
429
|
-
this.#finish(ENCODE_EXIT.FAILED, `the process could not be started: ${message}`, null, null);
|
|
430
|
-
});
|
|
431
|
-
process.on("exit", (code, signal) => this.#onExit(code, signal));
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* ffmpeg's `-progress` stream: `key=value` lines, one block per report.
|
|
436
|
-
*
|
|
437
|
-
* @param {string} text
|
|
438
|
-
*/
|
|
439
|
-
#readProgress(text) {
|
|
440
|
-
for (const line of text.split(/\r?\n/)) {
|
|
441
|
-
const normalized = line.trim();
|
|
442
|
-
if (!normalized) {
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
const separator = normalized.indexOf("=");
|
|
446
|
-
if (separator <= 0) {
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
const key = normalized.slice(0, separator);
|
|
450
|
-
const value = normalized.slice(separator + 1);
|
|
451
|
-
if (key === "out_time_ms") {
|
|
452
|
-
const numeric = Number(value);
|
|
453
|
-
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
454
|
-
this.#reportProgress({ processedSeconds: numeric / MICROSECONDS_PER_SECOND, speed: null });
|
|
455
|
-
}
|
|
456
|
-
} else if (key === "out_time") {
|
|
457
|
-
this.#reportProgress({ processedSeconds: null, speed: null, outTime: value });
|
|
458
|
-
} else if (key === "speed") {
|
|
459
|
-
this.noteSpeed(Number.parseFloat(value));
|
|
460
|
-
this.#reportProgress({ processedSeconds: null, speed: value });
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
#reportProgress(report) {
|
|
466
|
-
this.progress.note(report);
|
|
467
|
-
this.onProgress(this.progress.snapshot());
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* A segment this run has finished.
|
|
472
|
-
*
|
|
473
|
-
* @param {number} index
|
|
474
|
-
*/
|
|
475
|
-
noteProduced(index) {
|
|
476
|
-
if (Number.isInteger(index) && index >= this.from) {
|
|
477
|
-
this.#produced.add(index);
|
|
478
|
-
if (this.#firstOutputAt === 0) {
|
|
479
|
-
this.#firstOutputAt = this.now();
|
|
480
|
-
}
|
|
481
|
-
if (this.#state === ENCODE_RUN_STATE.STARTING) {
|
|
482
|
-
this.#transition(ENCODE_RUN_EVENT.FIRST_SEGMENT);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* What ffmpeg says about its own speed.
|
|
489
|
-
*
|
|
490
|
-
* @param {number} speedX - Times realtime.
|
|
491
|
-
*/
|
|
492
|
-
/**
|
|
493
|
-
* Names of finished pieces, as the encoder writes them.
|
|
494
|
-
*
|
|
495
|
-
* @param {string} text
|
|
496
|
-
*/
|
|
497
|
-
#readClosedPieces(text) {
|
|
498
|
-
this.#closedTail += text;
|
|
499
|
-
const lines = this.#closedTail.split(/\r?\n/);
|
|
500
|
-
// The last piece of the chunk may be half a name; it waits for the rest.
|
|
501
|
-
this.#closedTail = lines.pop() ?? "";
|
|
502
|
-
for (const line of lines) {
|
|
503
|
-
const name = line.trim();
|
|
504
|
-
if (name.length === 0) {
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
// ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
|
|
508
|
-
// ffmpeg writes a piece under a working name and reports that; the piece
|
|
509
|
-
// becomes servable by being renamed, and everything below works in the
|
|
510
|
-
// name a request can actually ask for.
|
|
511
|
-
const served = this.onClosed(name) ?? name;
|
|
512
|
-
if (!this.#stopping) {
|
|
513
|
-
this.#provenName = served;
|
|
514
|
-
}
|
|
515
|
-
// WHAT THIS RUN HAS MADE IS THIS RUN'S OWN FACT, and this channel is where
|
|
516
|
-
// it learns it. It used to be told from outside, by whoever listed the
|
|
517
|
-
// output directory — which every run of an output shares — so a run
|
|
518
|
-
// inherited every number any other run had ever left there. Field
|
|
519
|
-
// 2026-09-06: a run beginning at the piece for 3:39 reported "reached #20
|
|
520
|
-
// (483 segment(s))", having produced none of them, and its head therefore
|
|
521
|
-
// described somebody else's work. Both the claim it holds and the cleanup
|
|
522
|
-
// after it read that head.
|
|
523
|
-
const index = this.indexOfName(served);
|
|
524
|
-
if (Number.isInteger(index)) {
|
|
525
|
-
this.noteProduced(index);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
noteSpeed(speedX) {
|
|
531
|
-
if (Number.isFinite(speedX) && speedX > 0) {
|
|
532
|
-
this.#speedX = speedX;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Stop it on purpose. Abnormal by the rule above, and recorded as such: our
|
|
538
|
-
* own kill is a thing that happened to a run before it finished, and hiding
|
|
539
|
-
* it among the normal endings would make the count of abnormal endings
|
|
540
|
-
* useless.
|
|
541
|
-
*
|
|
542
|
-
* @param {string} because
|
|
543
|
-
*/
|
|
544
|
-
stop(because) {
|
|
545
|
-
if (this.#process === null || this.#ended) {
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
this.#stopping = true;
|
|
549
|
-
this.#stopReason = because;
|
|
550
|
-
this.#stopOrderedAt = this.now();
|
|
551
|
-
this.#transition(ENCODE_RUN_EVENT.STOP_ORDERED);
|
|
552
|
-
// A suspended process does not act on SIGTERM until it is continued, so the
|
|
553
|
-
// wait for its exit would never end. Let it run before asking it to stop.
|
|
554
|
-
this.#continue();
|
|
555
|
-
try {
|
|
556
|
-
this.#process.kill("SIGTERM");
|
|
557
|
-
} catch {
|
|
558
|
-
// Best effort: it may already be gone, and its exit will say so.
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Stop it where it stands, producing nothing, without ending it.
|
|
564
|
-
*
|
|
565
|
-
* What the look-ahead does when a run is far enough in front of every viewer:
|
|
566
|
-
* the process keeps its decoder, its position and its open piece, and costs
|
|
567
|
-
* no processor at all until it is let go again.
|
|
568
|
-
*
|
|
569
|
-
* @param {string} reason
|
|
570
|
-
* @returns {boolean} Whether it was suspended by this call.
|
|
571
|
-
*/
|
|
572
|
-
pause(reason) {
|
|
573
|
-
if (this.#state === ENCODE_RUN_STATE.SUSPENDED || this.#pauseUnsupported || !this.#process?.pid) {
|
|
574
|
-
return false;
|
|
575
|
-
}
|
|
576
|
-
try {
|
|
577
|
-
globalThis.process.kill(this.#process.pid, "SIGSTOP");
|
|
578
|
-
} catch (error) {
|
|
579
|
-
this.#pauseUnsupported = true;
|
|
580
|
-
this.logger.info(
|
|
581
|
-
`encode-run #${this.from}..#${this.to} cannot suspend the encoder on this platform ` +
|
|
582
|
-
`(${error instanceof Error ? error.message : String(error)}); look-ahead stays unbounded`
|
|
583
|
-
);
|
|
584
|
-
return false;
|
|
585
|
-
}
|
|
586
|
-
this.#transition(ENCODE_RUN_EVENT.SUSPEND_ORDERED);
|
|
587
|
-
this.logger.info(`encode-run #${this.from}..#${this.to} suspended — ${reason}`);
|
|
588
|
-
return true;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Let it go again.
|
|
593
|
-
*
|
|
594
|
-
* @param {string} reason
|
|
595
|
-
* @returns {boolean} Whether anything was actually resumed. Two records of
|
|
596
|
-
* one moment must not contradict each other: a line saying the encoder
|
|
597
|
-
* resumed, beside a return value saying nothing was, is the sort of pair
|
|
598
|
-
* that costs an hour of reading a field log.
|
|
599
|
-
*/
|
|
600
|
-
resume(reason) {
|
|
601
|
-
if (this.#state !== ENCODE_RUN_STATE.SUSPENDED || !this.#process?.pid) {
|
|
602
|
-
return false;
|
|
603
|
-
}
|
|
604
|
-
const continued = this.#continue();
|
|
605
|
-
this.logger.info(
|
|
606
|
-
continued
|
|
607
|
-
? `encode-run #${this.from}..#${this.to} resumed — ${reason}`
|
|
608
|
-
: `encode-run #${this.from}..#${this.to} could not be resumed (the process is gone) — ${reason}`
|
|
609
|
-
);
|
|
610
|
-
return continued;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Send `SIGCONT` without deciding anything about it.
|
|
615
|
-
*
|
|
616
|
-
* @returns {boolean}
|
|
617
|
-
*/
|
|
618
|
-
#continue() {
|
|
619
|
-
if (this.#state !== ENCODE_RUN_STATE.SUSPENDED || !this.#process?.pid) {
|
|
620
|
-
return false;
|
|
621
|
-
}
|
|
622
|
-
let continued = true;
|
|
623
|
-
try {
|
|
624
|
-
globalThis.process.kill(this.#process.pid, "SIGCONT");
|
|
625
|
-
} catch {
|
|
626
|
-
// The process is gone; its exit handler will deal with it. The state is
|
|
627
|
-
// moved either way — but nothing was resumed, and saying so is what stops
|
|
628
|
-
// a dead run being reported as producing again.
|
|
629
|
-
continued = false;
|
|
630
|
-
}
|
|
631
|
-
this.#transition(ENCODE_RUN_EVENT.RESUMED);
|
|
632
|
-
return continued;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* @param {number | null} code
|
|
637
|
-
* @param {string | null} signal
|
|
638
|
-
*/
|
|
639
|
-
#onExit(code, signal) {
|
|
640
|
-
if (this.#stopping) {
|
|
641
|
-
this.#finish(ENCODE_EXIT.STOPPED, this.#stopReason, code, signal);
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
// What "it finished" means is the end of its own STRETCH where it was given
|
|
645
|
-
// one, and the end of the film where it was not. A run told to make #10..#14
|
|
646
|
-
// that exits cleanly at #11 has not finished, whatever the film's length;
|
|
647
|
-
// and a run with no end has nothing but the film to be measured against.
|
|
648
|
-
const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
|
|
649
|
-
const outcome = classifyEncodeExit({
|
|
650
|
-
code,
|
|
651
|
-
producedThrough: this.#produced.size > 0 ? this.reached : null,
|
|
652
|
-
producedCount: this.#produced.size,
|
|
653
|
-
lastSegmentIndex: endOfWork,
|
|
654
|
-
inputUnavailable: this.inputUnavailable(this.lastError)
|
|
655
|
-
});
|
|
656
|
-
if (outcome === ENCODE_EXIT.COMPLETE) {
|
|
657
|
-
this.#finish(ENCODE_EXIT.COMPLETE, "it reached the end of what it was given", code, signal);
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
if (outcome === ENCODE_EXIT.SHORT) {
|
|
661
|
-
// ffmpeg exits zero both at the end of a file and when its input simply
|
|
662
|
-
// stops producing bytes; over a torrent the two look identical to it. A
|
|
663
|
-
// run that stopped short of the film has not finished it.
|
|
664
|
-
this.#finish(
|
|
665
|
-
ENCODE_EXIT.SHORT,
|
|
666
|
-
`it exited cleanly at #${this.reached} of #${endOfWork ?? "?"} — its input stopped`,
|
|
667
|
-
code,
|
|
668
|
-
signal
|
|
669
|
-
);
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
if (outcome === ENCODE_EXIT.INPUT_LOST) {
|
|
673
|
-
this.#finish(ENCODE_EXIT.INPUT_LOST, `its input went away: ${this.lastError}`, code, signal);
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
this.#finish(ENCODE_EXIT.FAILED, `it exited with code ${code ?? "?"}: ${this.lastError}`, code, signal);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* @param {string} ending - One of {@link ENCODE_EXIT}.
|
|
681
|
-
* @param {string} because
|
|
682
|
-
* @param {number | null} code
|
|
683
|
-
* @param {string | null} signal
|
|
684
|
-
*/
|
|
685
|
-
#finish(ending, because, code, signal) {
|
|
686
|
-
if (this.#ended) {
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
this.#ended = true;
|
|
690
|
-
this.#process = null;
|
|
691
|
-
// A run we stopped is already in STOPPED, ordered before the signal was
|
|
692
|
-
// sent; its exit is the answer to that order and not a second event.
|
|
693
|
-
if (ending !== ENCODE_EXIT.STOPPED) {
|
|
694
|
-
this.#transition(
|
|
695
|
-
ending === ENCODE_EXIT.COMPLETE
|
|
696
|
-
? ENCODE_RUN_EVENT.EXITED_COMPLETE
|
|
697
|
-
: ending === ENCODE_EXIT.SHORT
|
|
698
|
-
? ENCODE_RUN_EVENT.EXITED_SHORT
|
|
699
|
-
: ending === ENCODE_EXIT.INPUT_LOST
|
|
700
|
-
? ENCODE_RUN_EVENT.EXITED_INPUT_LOST
|
|
701
|
-
: ENCODE_RUN_EVENT.EXITED_FAILED
|
|
702
|
-
);
|
|
703
|
-
}
|
|
704
|
-
const livedMs = this.#startedAt > 0 ? this.now() - this.#startedAt : 0;
|
|
705
|
-
/** @type {RunEnded} */
|
|
706
|
-
const ended = {
|
|
707
|
-
run: this,
|
|
708
|
-
address: this.address,
|
|
709
|
-
ending,
|
|
710
|
-
because,
|
|
711
|
-
code: code ?? null,
|
|
712
|
-
signal: signal ?? null,
|
|
713
|
-
from: this.from,
|
|
714
|
-
to: this.to,
|
|
715
|
-
reached: this.reached,
|
|
716
|
-
provenName: this.#provenName,
|
|
717
|
-
livedMs,
|
|
718
|
-
// How long dying took, and how long the first output took to appear.
|
|
719
|
-
// Null where the run was never told to stop, or never produced anything:
|
|
720
|
-
// an absent measurement says so rather than reading as zero.
|
|
721
|
-
dyingMs: this.#stopOrderedAt > 0 ? this.now() - this.#stopOrderedAt : null,
|
|
722
|
-
firstOutputMs:
|
|
723
|
-
this.#firstOutputAt > 0 && this.#startedAt > 0
|
|
724
|
-
? this.#firstOutputAt - this.#startedAt
|
|
725
|
-
: null,
|
|
726
|
-
normal: ending === ENCODE_EXIT.COMPLETE,
|
|
727
|
-
lastError: this.lastError
|
|
728
|
-
};
|
|
729
|
-
const line =
|
|
730
|
-
`encode-run ${ending} #${this.from}..#${this.to} of ${this.address}, ` +
|
|
731
|
-
`reached #${ended.reached} (${this.#produced.size} segment(s)) after ${livedMs}ms` +
|
|
732
|
-
`${code === null ? "" : `, code ${code}`}${signal ? `, signal ${signal}` : ""}: ${because}`;
|
|
733
|
-
if (ended.normal) {
|
|
734
|
-
this.logger.info(line);
|
|
735
|
-
} else {
|
|
736
|
-
this.logger.warn(line);
|
|
737
|
-
}
|
|
738
|
-
this.onEnded(ended);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/**
|
|
742
|
-
* The wait after a lost input is over, and something is about to start
|
|
743
|
-
* again.
|
|
744
|
-
*
|
|
745
|
-
* The run itself is finished; this moves it out of the waiting state so that
|
|
746
|
-
* a second timer firing on the same run cannot start a second attempt.
|
|
747
|
-
*/
|
|
748
|
-
retryDue() {
|
|
749
|
-
this.#transition(ENCODE_RUN_EVENT.RETRY_DUE);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* Report an ending nobody watched: the run was found to be over without
|
|
754
|
-
* having said so. Only for a run handed over from elsewhere.
|
|
755
|
-
*
|
|
756
|
-
* @param {string} because
|
|
757
|
-
*/
|
|
758
|
-
reportGone(because) {
|
|
759
|
-
this.#finish(ENCODE_EXIT.GONE, because, null, null);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
* @param {string} event
|
|
764
|
-
*/
|
|
765
|
-
#transition(event) {
|
|
766
|
-
const from = this.#state;
|
|
767
|
-
const to = nextState(from, event);
|
|
768
|
-
if (to === null) {
|
|
769
|
-
this.logger.warn(`run-state #${this.from}..#${this.to} ${from} + ${event} — no such edge; ignored`);
|
|
770
|
-
return from;
|
|
771
|
-
}
|
|
772
|
-
this.#state = to;
|
|
773
|
-
this.logger.info(`run-state #${this.from}..#${this.to} of ${this.address} ${from} --${event}--> ${to}`);
|
|
774
|
-
return to;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file One running encoder: its process, the stretch it was given, where it
|
|
3
|
+
* has got to, and how it ended.
|
|
4
|
+
*
|
|
5
|
+
* Until now a run was ten fields on a session — the process, the run state, the
|
|
6
|
+
* directory, the start and end numbers, a generation counter, the superseded
|
|
7
|
+
* processes, the run number, its label, its argument list — and there could be
|
|
8
|
+
* exactly one of them, because there was nowhere to put a second. It is an
|
|
9
|
+
* object here so that an output can have as many as the machine affords, and so
|
|
10
|
+
* that a run belongs to a STRETCH of an output rather than to a viewer.
|
|
11
|
+
*
|
|
12
|
+
* **The identity check disappears with the fields.** Every handler used to open
|
|
13
|
+
* with "is this still the session's process", because a replaced run's exit
|
|
14
|
+
* would otherwise write the session's error, its state and its failure tally —
|
|
15
|
+
* one set of fields for however many processes had lived. A run writes its own
|
|
16
|
+
* state and nothing else's, so a predecessor dying after its replacement has
|
|
17
|
+
* spawned can no longer be mistaken for the current run failing. That mistake
|
|
18
|
+
* cost a hardware encoder: on any host with one, every seek downgraded the
|
|
19
|
+
* proxy to libx264 for good.
|
|
20
|
+
*
|
|
21
|
+
* **A run has an end.** Neither `-to` nor `-t` appeared anywhere in the
|
|
22
|
+
* arguments this proxy built, so every run went until something killed it from
|
|
23
|
+
* outside. Given an end it finishes by itself, and two runs on one output
|
|
24
|
+
* cannot write over each other because their stretches do not overlap.
|
|
25
|
+
*
|
|
26
|
+
* **Every start and every end is recorded, with its cause** (required by the
|
|
27
|
+
* user 2026-09-04, because abnormal endings are frequent here). Exactly one
|
|
28
|
+
* ending is normal: the run reached the end of the stretch it was given and
|
|
29
|
+
* exited by itself. Every other ending, our own kill included, is abnormal and
|
|
30
|
+
* says so — with the code, the signal, how far it got, how long it lived, and
|
|
31
|
+
* what was cleared up after it.
|
|
32
|
+
*
|
|
33
|
+
* **Nothing here decides anything.** Where a run belongs, whether it should be
|
|
34
|
+
* moved and whether it should exist at all are `EncodePlan`'s, from numbers.
|
|
35
|
+
* What to do about an ending — fall back to software, wait for the input to
|
|
36
|
+
* come back, stop retrying a position that keeps failing — belongs to whoever
|
|
37
|
+
* owns the output. This carries a decision out and reports what happened.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
|
|
41
|
+
import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
|
|
42
|
+
import { RunProgress } from "./RunProgress.js";
|
|
43
|
+
|
|
44
|
+
/** Microseconds in a second, as ffmpeg's `out_time_ms` counts them. */
|
|
45
|
+
const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} RunEnded
|
|
50
|
+
* @property {EncodeRun} run - The run itself. It has no name: what identifies
|
|
51
|
+
* it is that it IS itself, and what identifies it in a line is its output and
|
|
52
|
+
* the stretch it was given, which no other live run of that output holds.
|
|
53
|
+
* @property {string} address
|
|
54
|
+
* @property {string} ending - One of {@link ENCODE_EXIT}.
|
|
55
|
+
* @property {string} because - Why, in words, including who asked when we did.
|
|
56
|
+
* @property {number | null} code
|
|
57
|
+
* @property {string | null} signal
|
|
58
|
+
* @property {number} from
|
|
59
|
+
* @property {number} to
|
|
60
|
+
* @property {number} reached - The last number it finished, or `from - 1` when
|
|
61
|
+
* it finished none.
|
|
62
|
+
* @property {number} livedMs
|
|
63
|
+
* @property {boolean} normal - Whether this ending is the expected one.
|
|
64
|
+
* @property {string} lastError - The last thing ffmpeg said on stderr.
|
|
65
|
+
* @property {string | null} provenName - The last piece this run named while it
|
|
66
|
+
* was still running normally, and therefore the last one it is known to have
|
|
67
|
+
* finished. Anything on disk beyond it was open when the run ended, whatever
|
|
68
|
+
* ended it. Null where the run named nothing.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The last number this run was given, or Infinity when it was given no end.
|
|
73
|
+
*
|
|
74
|
+
* ONE reading of one convention. "No end" is written as a `to` below `from` —
|
|
75
|
+
* the session layer returns -1 for it and the log prints `#-1` — and every place
|
|
76
|
+
* that has to compare against it read that for itself. One place did not: the
|
|
77
|
+
* coverage map was handed the raw -1, `Math.max(from, -1)` made the claim one
|
|
78
|
+
* segment long, and the plan then saw the rest of the film as free. Field,
|
|
79
|
+
* 2026-09-05: an encoder was started and killed every five seconds, each one
|
|
80
|
+
* producing 0-2 segments, for as long as anybody watched.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ from: number, to: number }} run
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
export function endOfRun(run) {
|
|
86
|
+
const from = Number(run?.from);
|
|
87
|
+
const to = Number(run?.to);
|
|
88
|
+
return Number.isInteger(to) && to >= from ? to : Number.POSITIVE_INFINITY;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class EncodeRun {
|
|
92
|
+
/** @type {import("node:child_process").ChildProcess | null} */
|
|
93
|
+
#process = null;
|
|
94
|
+
|
|
95
|
+
/** @type {string} */
|
|
96
|
+
#state = INITIAL_RUN_STATE;
|
|
97
|
+
|
|
98
|
+
/** Numbers this run has finished. @type {Set<number>} */
|
|
99
|
+
#produced = new Set();
|
|
100
|
+
|
|
101
|
+
/** @type {number} */
|
|
102
|
+
#startedAt = 0;
|
|
103
|
+
|
|
104
|
+
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
105
|
+
#closedTail = "";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The last piece named on the ready channel while the run was still running
|
|
109
|
+
* normally — the last one it is KNOWN to have finished.
|
|
110
|
+
*
|
|
111
|
+
* "The encoder named it" was taken to mean "it is whole". That is true of the
|
|
112
|
+
* file and false of the span. On SIGTERM ffmpeg writes out the piece it had
|
|
113
|
+
* open and names it like any other; killed harder, or dying on its own, it
|
|
114
|
+
* leaves that piece unnamed and half-written. Both are readable, and neither
|
|
115
|
+
* covers the span its number promises. Field 2026-09-06: a run stopped
|
|
116
|
+
* mid-piece left `segment-00010.mp4` holding 3.92 s of the 5.589 s the
|
|
117
|
+
* playlist gives #10, and the viewer's picture jumped 1.5 s at 1:02; the
|
|
118
|
+
* soundtrack did the same at 17.5 s, 2.8 s wide, in the same session.
|
|
119
|
+
*
|
|
120
|
+
* Recorded by WHEN the name arrives, so nothing is read and no span is
|
|
121
|
+
* measured: a name that arrives after the stop was ordered is the flush and
|
|
122
|
+
* does not count as proof. A piece genuinely closed in the moment between the
|
|
123
|
+
* last normal name and the stop is then made a second time — the cheaper of
|
|
124
|
+
* the two errors, since the other is a hole the viewer sees.
|
|
125
|
+
*
|
|
126
|
+
* @type {string | null}
|
|
127
|
+
*/
|
|
128
|
+
#provenName = null;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* When this run was told to stop, so the death itself can be priced.
|
|
132
|
+
*
|
|
133
|
+
* The plan weighs moving an encoder against letting it drive on, and dying is
|
|
134
|
+
* one of the terms. It was measured in the field at 430-729 ms — larger than
|
|
135
|
+
* the start it is added to — by a log line that lived in the one place that
|
|
136
|
+
* killed a run. That place is gone, so the run times its own death.
|
|
137
|
+
*/
|
|
138
|
+
#stopOrderedAt = 0;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* When the first thing this run ever produced appeared.
|
|
142
|
+
*
|
|
143
|
+
* The other term the plan needs: a run started where nothing is downloaded
|
|
144
|
+
* waits for the swarm before it can encode a frame, and that wait is the
|
|
145
|
+
* largest part of what a restart costs. Nothing measured it before.
|
|
146
|
+
*/
|
|
147
|
+
#firstOutputAt = 0;
|
|
148
|
+
|
|
149
|
+
/** @type {number} */
|
|
150
|
+
#speedX = 0;
|
|
151
|
+
|
|
152
|
+
/** @type {boolean} */
|
|
153
|
+
#stopping = false;
|
|
154
|
+
|
|
155
|
+
/** @type {string} */
|
|
156
|
+
#stopReason = "";
|
|
157
|
+
|
|
158
|
+
/** @type {boolean} */
|
|
159
|
+
#ended = false;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether this platform refuses to suspend a process.
|
|
163
|
+
*
|
|
164
|
+
* Asked once and remembered: `SIGSTOP` does not exist on Windows, and asking
|
|
165
|
+
* again every look-ahead pass would log the same refusal for the life of the
|
|
166
|
+
* run.
|
|
167
|
+
* @type {boolean}
|
|
168
|
+
*/
|
|
169
|
+
#pauseUnsupported = false;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {object} params
|
|
173
|
+
* @param {string} params.address - The output this run makes segments of.
|
|
174
|
+
* @param {import("./Encoder.js").Encoder} params.encoder
|
|
175
|
+
* @param {number} params.from - First segment number it is to make.
|
|
176
|
+
* @param {number} params.to - Last segment number it is to make, inclusive;
|
|
177
|
+
* below `from` means it was given no end.
|
|
178
|
+
* @param {() => string[]} params.buildArgs - The full argument list for this
|
|
179
|
+
* run. Supplied rather than built here: what to map, where to read from and
|
|
180
|
+
* how to cut belong to whoever knows the source, and this owns the process.
|
|
181
|
+
* @param {(args: string[]) => import("node:child_process").ChildProcess} params.spawn
|
|
182
|
+
* @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
|
|
183
|
+
* @param {() => number} [params.now]
|
|
184
|
+
* @param {(ended: RunEnded) => void} [params.onEnded]
|
|
185
|
+
* @param {(name: string) => string | null} [params.onClosed] - Called with the
|
|
186
|
+
* WORKING name of every piece the encoder has finished writing, as the
|
|
187
|
+
* encoder itself names it on its own channel, and answers with the name that
|
|
188
|
+
* piece is served under — because making it servable is a rename, and only
|
|
189
|
+
* whoever owns the disk can perform one.
|
|
190
|
+
* @param {(progress: object) => void} [params.onProgress] - Called with this
|
|
191
|
+
* run's progress on the source timeline after every `-progress` report.
|
|
192
|
+
* @param {() => number | null} [params.lastSegmentIndex] - The film's last
|
|
193
|
+
* segment number, for telling "reached the end" from "the input dried up".
|
|
194
|
+
* ffmpeg exits zero for both, and over a torrent it cannot tell them apart.
|
|
195
|
+
* @param {(message: string) => boolean} [params.inputUnavailable] - Whether
|
|
196
|
+
* ffmpeg's own message names a missing input rather than a bad encode.
|
|
197
|
+
* @param {string} [params.argsDescribed] - The command as one readable line,
|
|
198
|
+
* kept so a failure can quote what produced it.
|
|
199
|
+
* @param {boolean} [params.usesExplicitCuts] - Whether this run cuts at times
|
|
200
|
+
* it was given, which decides how a segment is judged finished.
|
|
201
|
+
* @param {string} params.because - Why this encoder is being put on the
|
|
202
|
+
* machine, in words. Recorded with the argument list: a start whose cause is
|
|
203
|
+
* not written down cannot be told from any other when several runs exist.
|
|
204
|
+
* @param {(name: string) => number | null} [params.indexOfName] - The number
|
|
205
|
+
* a closed piece's name carries. How a piece is named belongs to the format
|
|
206
|
+
* that writes it, so it arrives as a plain function rather than this class
|
|
207
|
+
* knowing any naming.
|
|
208
|
+
* @param {number} [params.startSeconds] - Where this run begins on the source timeline.
|
|
209
|
+
* @param {number | null} [params.totalSeconds] - Source duration when known.
|
|
210
|
+
*/
|
|
211
|
+
constructor({
|
|
212
|
+
address,
|
|
213
|
+
encoder,
|
|
214
|
+
from,
|
|
215
|
+
to,
|
|
216
|
+
buildArgs,
|
|
217
|
+
spawn,
|
|
218
|
+
logger,
|
|
219
|
+
now,
|
|
220
|
+
onEnded,
|
|
221
|
+
onClosed,
|
|
222
|
+
onProgress,
|
|
223
|
+
lastSegmentIndex,
|
|
224
|
+
inputUnavailable,
|
|
225
|
+
argsDescribed = "",
|
|
226
|
+
usesExplicitCuts = false,
|
|
227
|
+
indexOfName,
|
|
228
|
+
startSeconds = 0,
|
|
229
|
+
totalSeconds = null,
|
|
230
|
+
because = "no reason was given"
|
|
231
|
+
}) {
|
|
232
|
+
this.address = address;
|
|
233
|
+
this.encoder = encoder;
|
|
234
|
+
this.from = from;
|
|
235
|
+
this.to = to;
|
|
236
|
+
this.buildArgs = buildArgs;
|
|
237
|
+
this.spawnProcess = spawn;
|
|
238
|
+
this.logger = logger;
|
|
239
|
+
this.now = typeof now === "function" ? now : Date.now;
|
|
240
|
+
this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now });
|
|
241
|
+
this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
|
|
242
|
+
this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
|
|
243
|
+
// Told the NAME of every piece the encoder has closed. The name is the
|
|
244
|
+
// proof it is whole; nothing else here can prove that.
|
|
245
|
+
this.onClosed = typeof onClosed === "function" ? onClosed : () => {};
|
|
246
|
+
this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
|
|
247
|
+
this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
|
|
248
|
+
this.argsDescribed = argsDescribed;
|
|
249
|
+
this.usesExplicitCuts = usesExplicitCuts === true;
|
|
250
|
+
this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
|
|
251
|
+
/** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
|
|
252
|
+
this.lastError = "";
|
|
253
|
+
// EXISTING IS RUNNING. There is no moment at which a built run is not yet a
|
|
254
|
+
// process, so there is no second act for two owners to perform.
|
|
255
|
+
this.#begin(because);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @returns {string} */
|
|
259
|
+
get state() {
|
|
260
|
+
return this.#state;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @returns {number} */
|
|
264
|
+
get speedX() {
|
|
265
|
+
return this.#speedX;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @returns {number} When it was spawned, or 0 before that. */
|
|
269
|
+
get startedAt() {
|
|
270
|
+
return this.#startedAt;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The process itself, for the two things only a handle can answer: whether it
|
|
275
|
+
* has exited, and its pid. Nothing outside may kill it — that is `stop`, which
|
|
276
|
+
* records the cause.
|
|
277
|
+
*
|
|
278
|
+
* @returns {import("node:child_process").ChildProcess | null}
|
|
279
|
+
*/
|
|
280
|
+
get process() {
|
|
281
|
+
return this.#process;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The next number this run will produce.
|
|
286
|
+
*
|
|
287
|
+
* Its position, and the figure the plan compares against what is already
|
|
288
|
+
* covered. Before it has finished anything that is where it started.
|
|
289
|
+
*
|
|
290
|
+
* @returns {number}
|
|
291
|
+
*/
|
|
292
|
+
get head() {
|
|
293
|
+
let head = this.from;
|
|
294
|
+
while (this.#produced.has(head)) {
|
|
295
|
+
head += 1;
|
|
296
|
+
}
|
|
297
|
+
return head;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The last number it finished, or one before its start when it finished none.
|
|
302
|
+
*
|
|
303
|
+
* @returns {number}
|
|
304
|
+
*/
|
|
305
|
+
get reached() {
|
|
306
|
+
return this.head - 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The last piece this run named while it was running normally.
|
|
311
|
+
*
|
|
312
|
+
* Its own statement that a piece is whole, and the only thing that can say so:
|
|
313
|
+
* a piece cut short still decodes, so its contents cannot be asked. Whatever
|
|
314
|
+
* lies beyond this name was open when the run ended, and that is what the
|
|
315
|
+
* clearing-up after a run reads.
|
|
316
|
+
*
|
|
317
|
+
* @returns {string | null}
|
|
318
|
+
*/
|
|
319
|
+
get provenName() {
|
|
320
|
+
return this.#provenName;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @returns {number[]} */
|
|
324
|
+
get produced() {
|
|
325
|
+
return [...this.#produced].sort((left, right) => left - right);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Whether this run can still produce.
|
|
330
|
+
*
|
|
331
|
+
* A run told to stop cannot, whatever its process is still doing about the
|
|
332
|
+
* signal: the exit arrives a turn or two later, and until then everything
|
|
333
|
+
* asking "is anything encoding" would be answered yes by a run that is on its
|
|
334
|
+
* way out — and a caller deciding whether to start one would decide not to.
|
|
335
|
+
*
|
|
336
|
+
* @returns {boolean}
|
|
337
|
+
*/
|
|
338
|
+
get isAlive() {
|
|
339
|
+
return this.#process !== null && !this.#ended && !this.#stopping;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Whether it has been told to stop and its exit has not arrived yet.
|
|
344
|
+
*
|
|
345
|
+
* Asked by whoever sweeps for runs that ended without saying so: this one is
|
|
346
|
+
* going to say so.
|
|
347
|
+
*
|
|
348
|
+
* @returns {boolean}
|
|
349
|
+
*/
|
|
350
|
+
get isStopping() {
|
|
351
|
+
return this.#stopping && !this.#ended;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @returns {boolean} Whether it is stopped where it stands, producing nothing. */
|
|
355
|
+
get isSuspended() {
|
|
356
|
+
return this.#state === ENCODE_RUN_STATE.SUSPENDED;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Put the process on the machine, and say why.
|
|
361
|
+
*
|
|
362
|
+
* PRIVATE, AND CALLED ONCE, from the constructor. It was public until
|
|
363
|
+
* 2026-09-10, and two places called it: the session manager built a run and
|
|
364
|
+
* started it, then handed it back to the orchestrator, which started it
|
|
365
|
+
* again. Every run of every session therefore had TWO ffmpeg processes on one
|
|
366
|
+
* output writing one set of names — 207 runs against 414 spawns in the field
|
|
367
|
+
* logs of 08-10 September, without a single exception. Only the second was
|
|
368
|
+
* reachable afterwards, because this line overwrote the reference to the
|
|
369
|
+
* first, so `stop` killed one and the other ran on: measured 105 seconds past
|
|
370
|
+
* its own run's death, eleven processes writing at once on a four-core host
|
|
371
|
+
* whose budget said three.
|
|
372
|
+
*
|
|
373
|
+
* The guard against that is not a check but the absence of a second act: a
|
|
374
|
+
* run exists means its process is running, so there is nothing anybody can
|
|
375
|
+
* call twice.
|
|
376
|
+
*
|
|
377
|
+
* The reason is not decoration: a start whose cause is not recorded cannot be
|
|
378
|
+
* told from any other start when several runs exist at once, and the argument
|
|
379
|
+
* list alone does not say whether this was a first open, a viewer's seek, a
|
|
380
|
+
* quality step or a move off covered material.
|
|
381
|
+
*
|
|
382
|
+
* @param {string} because
|
|
383
|
+
*/
|
|
384
|
+
#begin(because) {
|
|
385
|
+
const args = this.buildArgs();
|
|
386
|
+
this.#startedAt = this.now();
|
|
387
|
+
this.logger.info(
|
|
388
|
+
`encode-run #${this.from}..#${this.to} of ${this.address} ` +
|
|
389
|
+
`by ${this.encoder?.name ?? "?"}: ${because} ` +
|
|
390
|
+
`:: ffmpeg ${this.argsDescribed || args.join(" ")}`
|
|
391
|
+
);
|
|
392
|
+
this.#process = this.spawnProcess(args);
|
|
393
|
+
this.#transition(ENCODE_RUN_EVENT.SPAWNED);
|
|
394
|
+
this.#wire(this.#process);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Everything the process says about itself: how far it has got, how fast, and
|
|
399
|
+
* what went wrong.
|
|
400
|
+
*
|
|
401
|
+
* No handler asks whether this process is still the current one. It writes
|
|
402
|
+
* this run's own fields and nothing shared, so a predecessor still dying
|
|
403
|
+
* after its replacement has spawned cannot be mistaken for the current run.
|
|
404
|
+
*
|
|
405
|
+
* @param {import("node:child_process").ChildProcess} process
|
|
406
|
+
*/
|
|
407
|
+
#wire(process) {
|
|
408
|
+
process.stdout?.on("data", (chunk) => this.#readProgress(String(chunk)));
|
|
409
|
+
// THE CHANNEL THE ENCODER NAMES ITS FINISHED PIECES ON.
|
|
410
|
+
//
|
|
411
|
+
// A name arrives here when ffmpeg CLOSES the file, so the name is proof the
|
|
412
|
+
// piece is whole — measured on the addon host 2026-09-05. Nothing else can
|
|
413
|
+
// prove it: a file on disk may still be being written, and the only other
|
|
414
|
+
// evidence available was the existence of the NEXT file, which never comes
|
|
415
|
+
// for the last piece of a run.
|
|
416
|
+
//
|
|
417
|
+
// Lines can arrive split, so what is left over is kept for the next chunk.
|
|
418
|
+
process.stdio?.[3]?.on("data", (chunk) => this.#readClosedPieces(String(chunk)));
|
|
419
|
+
process.stderr?.on("data", (chunk) => {
|
|
420
|
+
const line = String(chunk).trim();
|
|
421
|
+
if (line.length > 0) {
|
|
422
|
+
this.lastError = line;
|
|
423
|
+
this.logger.warn(`ffmpeg #${this.from}..#${this.to} of ${this.address}: ${line}`);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
process.on("error", (error) => {
|
|
427
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
428
|
+
this.lastError = message;
|
|
429
|
+
this.#finish(ENCODE_EXIT.FAILED, `the process could not be started: ${message}`, null, null);
|
|
430
|
+
});
|
|
431
|
+
process.on("exit", (code, signal) => this.#onExit(code, signal));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* ffmpeg's `-progress` stream: `key=value` lines, one block per report.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} text
|
|
438
|
+
*/
|
|
439
|
+
#readProgress(text) {
|
|
440
|
+
for (const line of text.split(/\r?\n/)) {
|
|
441
|
+
const normalized = line.trim();
|
|
442
|
+
if (!normalized) {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const separator = normalized.indexOf("=");
|
|
446
|
+
if (separator <= 0) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const key = normalized.slice(0, separator);
|
|
450
|
+
const value = normalized.slice(separator + 1);
|
|
451
|
+
if (key === "out_time_ms") {
|
|
452
|
+
const numeric = Number(value);
|
|
453
|
+
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
454
|
+
this.#reportProgress({ processedSeconds: numeric / MICROSECONDS_PER_SECOND, speed: null });
|
|
455
|
+
}
|
|
456
|
+
} else if (key === "out_time") {
|
|
457
|
+
this.#reportProgress({ processedSeconds: null, speed: null, outTime: value });
|
|
458
|
+
} else if (key === "speed") {
|
|
459
|
+
this.noteSpeed(Number.parseFloat(value));
|
|
460
|
+
this.#reportProgress({ processedSeconds: null, speed: value });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#reportProgress(report) {
|
|
466
|
+
this.progress.note(report);
|
|
467
|
+
this.onProgress(this.progress.snapshot());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A segment this run has finished.
|
|
472
|
+
*
|
|
473
|
+
* @param {number} index
|
|
474
|
+
*/
|
|
475
|
+
noteProduced(index) {
|
|
476
|
+
if (Number.isInteger(index) && index >= this.from) {
|
|
477
|
+
this.#produced.add(index);
|
|
478
|
+
if (this.#firstOutputAt === 0) {
|
|
479
|
+
this.#firstOutputAt = this.now();
|
|
480
|
+
}
|
|
481
|
+
if (this.#state === ENCODE_RUN_STATE.STARTING) {
|
|
482
|
+
this.#transition(ENCODE_RUN_EVENT.FIRST_SEGMENT);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* What ffmpeg says about its own speed.
|
|
489
|
+
*
|
|
490
|
+
* @param {number} speedX - Times realtime.
|
|
491
|
+
*/
|
|
492
|
+
/**
|
|
493
|
+
* Names of finished pieces, as the encoder writes them.
|
|
494
|
+
*
|
|
495
|
+
* @param {string} text
|
|
496
|
+
*/
|
|
497
|
+
#readClosedPieces(text) {
|
|
498
|
+
this.#closedTail += text;
|
|
499
|
+
const lines = this.#closedTail.split(/\r?\n/);
|
|
500
|
+
// The last piece of the chunk may be half a name; it waits for the rest.
|
|
501
|
+
this.#closedTail = lines.pop() ?? "";
|
|
502
|
+
for (const line of lines) {
|
|
503
|
+
const name = line.trim();
|
|
504
|
+
if (name.length === 0) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
|
|
508
|
+
// ffmpeg writes a piece under a working name and reports that; the piece
|
|
509
|
+
// becomes servable by being renamed, and everything below works in the
|
|
510
|
+
// name a request can actually ask for.
|
|
511
|
+
const served = this.onClosed(name) ?? name;
|
|
512
|
+
if (!this.#stopping) {
|
|
513
|
+
this.#provenName = served;
|
|
514
|
+
}
|
|
515
|
+
// WHAT THIS RUN HAS MADE IS THIS RUN'S OWN FACT, and this channel is where
|
|
516
|
+
// it learns it. It used to be told from outside, by whoever listed the
|
|
517
|
+
// output directory — which every run of an output shares — so a run
|
|
518
|
+
// inherited every number any other run had ever left there. Field
|
|
519
|
+
// 2026-09-06: a run beginning at the piece for 3:39 reported "reached #20
|
|
520
|
+
// (483 segment(s))", having produced none of them, and its head therefore
|
|
521
|
+
// described somebody else's work. Both the claim it holds and the cleanup
|
|
522
|
+
// after it read that head.
|
|
523
|
+
const index = this.indexOfName(served);
|
|
524
|
+
if (Number.isInteger(index)) {
|
|
525
|
+
this.noteProduced(index);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
noteSpeed(speedX) {
|
|
531
|
+
if (Number.isFinite(speedX) && speedX > 0) {
|
|
532
|
+
this.#speedX = speedX;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Stop it on purpose. Abnormal by the rule above, and recorded as such: our
|
|
538
|
+
* own kill is a thing that happened to a run before it finished, and hiding
|
|
539
|
+
* it among the normal endings would make the count of abnormal endings
|
|
540
|
+
* useless.
|
|
541
|
+
*
|
|
542
|
+
* @param {string} because
|
|
543
|
+
*/
|
|
544
|
+
stop(because) {
|
|
545
|
+
if (this.#process === null || this.#ended) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
this.#stopping = true;
|
|
549
|
+
this.#stopReason = because;
|
|
550
|
+
this.#stopOrderedAt = this.now();
|
|
551
|
+
this.#transition(ENCODE_RUN_EVENT.STOP_ORDERED);
|
|
552
|
+
// A suspended process does not act on SIGTERM until it is continued, so the
|
|
553
|
+
// wait for its exit would never end. Let it run before asking it to stop.
|
|
554
|
+
this.#continue();
|
|
555
|
+
try {
|
|
556
|
+
this.#process.kill("SIGTERM");
|
|
557
|
+
} catch {
|
|
558
|
+
// Best effort: it may already be gone, and its exit will say so.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Stop it where it stands, producing nothing, without ending it.
|
|
564
|
+
*
|
|
565
|
+
* What the look-ahead does when a run is far enough in front of every viewer:
|
|
566
|
+
* the process keeps its decoder, its position and its open piece, and costs
|
|
567
|
+
* no processor at all until it is let go again.
|
|
568
|
+
*
|
|
569
|
+
* @param {string} reason
|
|
570
|
+
* @returns {boolean} Whether it was suspended by this call.
|
|
571
|
+
*/
|
|
572
|
+
pause(reason) {
|
|
573
|
+
if (this.#state === ENCODE_RUN_STATE.SUSPENDED || this.#pauseUnsupported || !this.#process?.pid) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
globalThis.process.kill(this.#process.pid, "SIGSTOP");
|
|
578
|
+
} catch (error) {
|
|
579
|
+
this.#pauseUnsupported = true;
|
|
580
|
+
this.logger.info(
|
|
581
|
+
`encode-run #${this.from}..#${this.to} cannot suspend the encoder on this platform ` +
|
|
582
|
+
`(${error instanceof Error ? error.message : String(error)}); look-ahead stays unbounded`
|
|
583
|
+
);
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
this.#transition(ENCODE_RUN_EVENT.SUSPEND_ORDERED);
|
|
587
|
+
this.logger.info(`encode-run #${this.from}..#${this.to} suspended — ${reason}`);
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Let it go again.
|
|
593
|
+
*
|
|
594
|
+
* @param {string} reason
|
|
595
|
+
* @returns {boolean} Whether anything was actually resumed. Two records of
|
|
596
|
+
* one moment must not contradict each other: a line saying the encoder
|
|
597
|
+
* resumed, beside a return value saying nothing was, is the sort of pair
|
|
598
|
+
* that costs an hour of reading a field log.
|
|
599
|
+
*/
|
|
600
|
+
resume(reason) {
|
|
601
|
+
if (this.#state !== ENCODE_RUN_STATE.SUSPENDED || !this.#process?.pid) {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
const continued = this.#continue();
|
|
605
|
+
this.logger.info(
|
|
606
|
+
continued
|
|
607
|
+
? `encode-run #${this.from}..#${this.to} resumed — ${reason}`
|
|
608
|
+
: `encode-run #${this.from}..#${this.to} could not be resumed (the process is gone) — ${reason}`
|
|
609
|
+
);
|
|
610
|
+
return continued;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Send `SIGCONT` without deciding anything about it.
|
|
615
|
+
*
|
|
616
|
+
* @returns {boolean}
|
|
617
|
+
*/
|
|
618
|
+
#continue() {
|
|
619
|
+
if (this.#state !== ENCODE_RUN_STATE.SUSPENDED || !this.#process?.pid) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
let continued = true;
|
|
623
|
+
try {
|
|
624
|
+
globalThis.process.kill(this.#process.pid, "SIGCONT");
|
|
625
|
+
} catch {
|
|
626
|
+
// The process is gone; its exit handler will deal with it. The state is
|
|
627
|
+
// moved either way — but nothing was resumed, and saying so is what stops
|
|
628
|
+
// a dead run being reported as producing again.
|
|
629
|
+
continued = false;
|
|
630
|
+
}
|
|
631
|
+
this.#transition(ENCODE_RUN_EVENT.RESUMED);
|
|
632
|
+
return continued;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* @param {number | null} code
|
|
637
|
+
* @param {string | null} signal
|
|
638
|
+
*/
|
|
639
|
+
#onExit(code, signal) {
|
|
640
|
+
if (this.#stopping) {
|
|
641
|
+
this.#finish(ENCODE_EXIT.STOPPED, this.#stopReason, code, signal);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
// What "it finished" means is the end of its own STRETCH where it was given
|
|
645
|
+
// one, and the end of the film where it was not. A run told to make #10..#14
|
|
646
|
+
// that exits cleanly at #11 has not finished, whatever the film's length;
|
|
647
|
+
// and a run with no end has nothing but the film to be measured against.
|
|
648
|
+
const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
|
|
649
|
+
const outcome = classifyEncodeExit({
|
|
650
|
+
code,
|
|
651
|
+
producedThrough: this.#produced.size > 0 ? this.reached : null,
|
|
652
|
+
producedCount: this.#produced.size,
|
|
653
|
+
lastSegmentIndex: endOfWork,
|
|
654
|
+
inputUnavailable: this.inputUnavailable(this.lastError)
|
|
655
|
+
});
|
|
656
|
+
if (outcome === ENCODE_EXIT.COMPLETE) {
|
|
657
|
+
this.#finish(ENCODE_EXIT.COMPLETE, "it reached the end of what it was given", code, signal);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (outcome === ENCODE_EXIT.SHORT) {
|
|
661
|
+
// ffmpeg exits zero both at the end of a file and when its input simply
|
|
662
|
+
// stops producing bytes; over a torrent the two look identical to it. A
|
|
663
|
+
// run that stopped short of the film has not finished it.
|
|
664
|
+
this.#finish(
|
|
665
|
+
ENCODE_EXIT.SHORT,
|
|
666
|
+
`it exited cleanly at #${this.reached} of #${endOfWork ?? "?"} — its input stopped`,
|
|
667
|
+
code,
|
|
668
|
+
signal
|
|
669
|
+
);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (outcome === ENCODE_EXIT.INPUT_LOST) {
|
|
673
|
+
this.#finish(ENCODE_EXIT.INPUT_LOST, `its input went away: ${this.lastError}`, code, signal);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
this.#finish(ENCODE_EXIT.FAILED, `it exited with code ${code ?? "?"}: ${this.lastError}`, code, signal);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* @param {string} ending - One of {@link ENCODE_EXIT}.
|
|
681
|
+
* @param {string} because
|
|
682
|
+
* @param {number | null} code
|
|
683
|
+
* @param {string | null} signal
|
|
684
|
+
*/
|
|
685
|
+
#finish(ending, because, code, signal) {
|
|
686
|
+
if (this.#ended) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
this.#ended = true;
|
|
690
|
+
this.#process = null;
|
|
691
|
+
// A run we stopped is already in STOPPED, ordered before the signal was
|
|
692
|
+
// sent; its exit is the answer to that order and not a second event.
|
|
693
|
+
if (ending !== ENCODE_EXIT.STOPPED) {
|
|
694
|
+
this.#transition(
|
|
695
|
+
ending === ENCODE_EXIT.COMPLETE
|
|
696
|
+
? ENCODE_RUN_EVENT.EXITED_COMPLETE
|
|
697
|
+
: ending === ENCODE_EXIT.SHORT
|
|
698
|
+
? ENCODE_RUN_EVENT.EXITED_SHORT
|
|
699
|
+
: ending === ENCODE_EXIT.INPUT_LOST
|
|
700
|
+
? ENCODE_RUN_EVENT.EXITED_INPUT_LOST
|
|
701
|
+
: ENCODE_RUN_EVENT.EXITED_FAILED
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const livedMs = this.#startedAt > 0 ? this.now() - this.#startedAt : 0;
|
|
705
|
+
/** @type {RunEnded} */
|
|
706
|
+
const ended = {
|
|
707
|
+
run: this,
|
|
708
|
+
address: this.address,
|
|
709
|
+
ending,
|
|
710
|
+
because,
|
|
711
|
+
code: code ?? null,
|
|
712
|
+
signal: signal ?? null,
|
|
713
|
+
from: this.from,
|
|
714
|
+
to: this.to,
|
|
715
|
+
reached: this.reached,
|
|
716
|
+
provenName: this.#provenName,
|
|
717
|
+
livedMs,
|
|
718
|
+
// How long dying took, and how long the first output took to appear.
|
|
719
|
+
// Null where the run was never told to stop, or never produced anything:
|
|
720
|
+
// an absent measurement says so rather than reading as zero.
|
|
721
|
+
dyingMs: this.#stopOrderedAt > 0 ? this.now() - this.#stopOrderedAt : null,
|
|
722
|
+
firstOutputMs:
|
|
723
|
+
this.#firstOutputAt > 0 && this.#startedAt > 0
|
|
724
|
+
? this.#firstOutputAt - this.#startedAt
|
|
725
|
+
: null,
|
|
726
|
+
normal: ending === ENCODE_EXIT.COMPLETE,
|
|
727
|
+
lastError: this.lastError
|
|
728
|
+
};
|
|
729
|
+
const line =
|
|
730
|
+
`encode-run ${ending} #${this.from}..#${this.to} of ${this.address}, ` +
|
|
731
|
+
`reached #${ended.reached} (${this.#produced.size} segment(s)) after ${livedMs}ms` +
|
|
732
|
+
`${code === null ? "" : `, code ${code}`}${signal ? `, signal ${signal}` : ""}: ${because}`;
|
|
733
|
+
if (ended.normal) {
|
|
734
|
+
this.logger.info(line);
|
|
735
|
+
} else {
|
|
736
|
+
this.logger.warn(line);
|
|
737
|
+
}
|
|
738
|
+
this.onEnded(ended);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* The wait after a lost input is over, and something is about to start
|
|
743
|
+
* again.
|
|
744
|
+
*
|
|
745
|
+
* The run itself is finished; this moves it out of the waiting state so that
|
|
746
|
+
* a second timer firing on the same run cannot start a second attempt.
|
|
747
|
+
*/
|
|
748
|
+
retryDue() {
|
|
749
|
+
this.#transition(ENCODE_RUN_EVENT.RETRY_DUE);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Report an ending nobody watched: the run was found to be over without
|
|
754
|
+
* having said so. Only for a run handed over from elsewhere.
|
|
755
|
+
*
|
|
756
|
+
* @param {string} because
|
|
757
|
+
*/
|
|
758
|
+
reportGone(because) {
|
|
759
|
+
this.#finish(ENCODE_EXIT.GONE, because, null, null);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* @param {string} event
|
|
764
|
+
*/
|
|
765
|
+
#transition(event) {
|
|
766
|
+
const from = this.#state;
|
|
767
|
+
const to = nextState(from, event);
|
|
768
|
+
if (to === null) {
|
|
769
|
+
this.logger.warn(`run-state #${this.from}..#${this.to} ${from} + ${event} — no such edge; ignored`);
|
|
770
|
+
return from;
|
|
771
|
+
}
|
|
772
|
+
this.#state = to;
|
|
773
|
+
this.logger.info(`run-state #${this.from}..#${this.to} of ${this.address} ${from} --${event}--> ${to}`);
|
|
774
|
+
return to;
|
|
775
|
+
}
|
|
776
|
+
}
|