@torrent-tv/proxy 2.75.0 → 2.76.1

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +1509 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11100 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @file libx264 — the encoder every host has and the only one whose ladder of
3
+ * speed settings is measured at startup.
4
+ */
5
+
6
+ import { Encoder } from "./Encoder.js";
7
+ import {
8
+ bitrateCapArgs,
9
+ CPU_THREADS,
10
+ hasForcedTimes,
11
+ keyFrameArgs,
12
+ safeDimensions,
13
+ SOFTWARE_CRF,
14
+ SOFTWARE_PRESET,
15
+ TONEMAP_FILTER_CHAIN,
16
+ TRANSCODE_FPS
17
+ } from "./args.js";
18
+
19
+ /**
20
+ * The presets the startup benchmark walks, slowest first.
21
+ *
22
+ * The same list `benchmarkSoftwarePresets` measures and `pickSoftwarePreset`
23
+ * chooses from; it is stated here because it is a property of this kind, and
24
+ * the four hardware kinds beside it show what its absence looks like.
25
+ */
26
+ const PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
27
+
28
+ export class SoftwareEncoder extends Encoder {
29
+ constructor() {
30
+ super({ name: "libx264", kind: "software", device: null, inputArgs: [] });
31
+ }
32
+
33
+ /** @returns {import("./Encoder.js").SpeedLadder} */
34
+ get speedLadder() {
35
+ return {
36
+ flag: "-preset",
37
+ values: PRESETS,
38
+ measured: true,
39
+ note:
40
+ "Measured on every host at startup by `benchmarkSoftwarePresets`, in " +
41
+ "pixels per second per preset, and chosen from by `pickSoftwarePreset`."
42
+ };
43
+ }
44
+
45
+ buildVideoArgs({
46
+ targetWidth,
47
+ targetHeight,
48
+ segmentDurationSec,
49
+ preset,
50
+ fps,
51
+ tonemap,
52
+ forcedKeyframeTimes,
53
+ nominalKbps = null
54
+ }) {
55
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
56
+ const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
57
+ // Output frame rate: inherited from the source (rounded/capped) by the
58
+ // session manager, TRANSCODE_FPS by default. MUST be an integer and MUST
59
+ // equal the value used in the GOP below, or keyframes drift off the grid.
60
+ const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
61
+ // HDR→SDR tone-map, inserted AFTER the downscale so it runs on the smaller
62
+ // frame (cheaper on ARM); only when the source is HDR and the filters are
63
+ // present (session manager gates on both).
64
+ const tonemapPart = tonemap === true ? `,${TONEMAP_FILTER_CHAIN}` : "";
65
+ return [
66
+ // Never upscale: cap the target box to the source size (min with
67
+ // iw/ih), so a small source (e.g. 720x400) is encoded at its own
68
+ // resolution instead of being scaled up to the viewport — far fewer
69
+ // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
70
+ "-vf",
71
+ `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${tonemapPart},fps=${outFps}`,
72
+ "-c:v", "libx264",
73
+ // Preset is chosen per stream by the session manager from the startup
74
+ // benchmark (highest quality that still encodes the source resolution
75
+ // faster than realtime); falls back to the static default.
76
+ "-preset", chosenPreset,
77
+ "-crf", SOFTWARE_CRF,
78
+ // Constrained CRF: bound peak bitrate per rung so a complex scene
79
+ // cannot produce segments a thin viewer link (cellular) can't
80
+ // download in time. Sized by the TARGET box height (the rung the
81
+ // budget/manual selection chose).
82
+ ...bitrateCapArgs(h, nominalKbps),
83
+ "-threads", String(CPU_THREADS),
84
+ "-pix_fmt", "yuv420p",
85
+ // Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
86
+ // scene-cut keyframes disabled. This is frame-count based, so it is
87
+ // independent of the PTS offset used on seek-restart — every HLS segment
88
+ // is exactly segmentDurationSec long and starts on a keyframe, so segment
89
+ // boundaries line up with the synthetic playlist with no gaps. (The old
90
+ // the OLD `expr:` form of -force_key_frames broke after a seek, because
91
+ // the `t` it reads is shifted by `-output_ts_offset`.)
92
+ //
93
+ // An explicit cut LIST is a different thing and does work: verified by
94
+ // running it, its times are on the run's own timeline — the same one
95
+ // `-segment_times` is measured on — so both are given one list and
96
+ // cannot drift apart. It replaces the frame-count GOP, which cannot
97
+ // describe the source's keyframes because they are not evenly spaced.
98
+ // `-g` stays as an upper bound on the interval: an extra keyframe
99
+ // inside a segment costs a little bitrate and cuts nothing, while
100
+ // leaving the interval unbounded means a driver that ignores the list
101
+ // produces one enormous segment instead of a wrong but cut one.
102
+ // `-keyint_min` goes, since a MINIMUM interval is the one thing that
103
+ // could argue with a forced keyframe.
104
+ "-g", String(segmentDurationSec * outFps),
105
+ ...(hasForcedTimes(forcedKeyframeTimes)
106
+ ? keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
107
+ : ["-keyint_min", String(segmentDurationSec * outFps)]),
108
+ "-sc_threshold", "0"
109
+ ];
110
+ }
111
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file h264_v4l2m2m — the stateful memory-to-memory encoder on an ARM board
3
+ * (Raspberry Pi, HA Yellow).
4
+ */
5
+
6
+ import { Encoder } from "./Encoder.js";
7
+ import { keyFrameArgs, safeDimensions, TRANSCODE_FPS } from "./args.js";
8
+
9
+ export class V4l2m2mEncoder extends Encoder {
10
+ constructor() {
11
+ // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
12
+ // scaler — scale in software, hand YUV420 frames to the hardware encoder.
13
+ super({ name: "h264_v4l2m2m", kind: "v4l2m2m", device: null, inputArgs: [] });
14
+ }
15
+
16
+ /** @returns {import("./Encoder.js").SpeedLadder} */
17
+ get speedLadder() {
18
+ return {
19
+ flag: "",
20
+ values: [],
21
+ measured: false,
22
+ note:
23
+ "This kind has no speed setting at all — its levers are the bitrate " +
24
+ "and the number of capture buffers, which are not a trade of picture " +
25
+ "against speed. So there is nowhere to step, and a host that cannot " +
26
+ "keep up on it has to change something else."
27
+ };
28
+ }
29
+
30
+ // `-g` aligns the GOP to the segment length so an IDR lands on every segment
31
+ // boundary; this is verified by the keyframe-alignment test before use,
32
+ // because v4l2m2m does not always honour these hints.
33
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps, forcedKeyframeTimes }) {
34
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
35
+ const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
36
+ return [
37
+ "-vf",
38
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps},format=yuv420p`,
39
+ "-c:v", "h264_v4l2m2m",
40
+ // More capture buffers than the default 4 — the default deadlocks /
41
+ // drops frames on the CM4 encoder ("All capture buffers returned to
42
+ // userspace").
43
+ "-num_capture_buffers", "32",
44
+ "-b:v", "3M",
45
+ // Kept even with an explicit cut list, as an upper bound on the
46
+ // interval: this encoder is the one known not always to honour keyframe
47
+ // hints, and without any bound a list it ignores yields one segment for
48
+ // the whole file rather than a wrongly-cut one.
49
+ "-g", String(outFps * segmentDurationSec),
50
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
51
+ ];
52
+ }
53
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file h264_vaapi — encode on a VAAPI device, with the scale on the same
3
+ * device so frames never leave it.
4
+ */
5
+
6
+ import { Encoder } from "./Encoder.js";
7
+ import { keyFrameArgs, safeDimensions } from "./args.js";
8
+
9
+ export class VaapiEncoder extends Encoder {
10
+ /**
11
+ * @param {string} device - Render node, e.g. `/dev/dri/renderD128`.
12
+ */
13
+ constructor(device) {
14
+ super({
15
+ name: "h264_vaapi",
16
+ kind: "vaapi",
17
+ device,
18
+ // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
19
+ inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device]
20
+ });
21
+ }
22
+
23
+ /** @returns {import("./Encoder.js").SpeedLadder} */
24
+ get speedLadder() {
25
+ return {
26
+ flag: "-quality",
27
+ // Read from ffmpeg's own option list on the addon host 2026-09-04:
28
+ // "Set encode quality (trades off against speed, higher is faster)
29
+ // (from -1 to INT_MAX) (default -1)". The upper end is the driver's, not
30
+ // the encoder's, so there is no list to state — only the name of the
31
+ // setting and the direction.
32
+ values: [],
33
+ measured: false,
34
+ note:
35
+ "The setting is `-quality`, higher is faster, and its range belongs to " +
36
+ "the driver rather than to ffmpeg. Nothing here has benchmarked it, " +
37
+ "and nothing passes it: a VAAPI encode runs at the driver's default."
38
+ };
39
+ }
40
+
41
+ // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
42
+ // grid via time-based -force_key_frames, so it already honours source fps.
43
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
44
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
45
+ return [
46
+ "-vf",
47
+ `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
48
+ "-c:v", "h264_vaapi",
49
+ "-qp", "24",
50
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
51
+ ];
52
+ }
53
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @file The arithmetic every encoder kind is built from: the output frame rate,
3
+ * the bitrate ladder and its caps, the frame box, and the keyframe arguments.
4
+ *
5
+ * Taken out of `hwaccel.js` so the kind classes beside this file do not have to
6
+ * import the detection and benchmarking that happen to live there. The
7
+ * dependency runs one way — `hwaccel.js` imports this, never the reverse — and
8
+ * everything here is a calculation, with no process, no filesystem and no clock
9
+ * behind it.
10
+ *
11
+ * Moved verbatim on 2026-09-04. Every comment is the reasoning it was written
12
+ * with and every field case it cites is unchanged.
13
+ */
14
+
15
+ import os from "node:os";
16
+
17
+ export const SOFTWARE_PRESET = "ultrafast";
18
+ export const SOFTWARE_CRF = "24";
19
+ // HDR→SDR tone-map chain (software). Converts a BT.2020 PQ/HLG source to BT.709
20
+ // 8-bit SDR so the re-encode is not washed-out/desaturated. Requires the
21
+ // `zscale` (libzimg) and `tonemap` filters — gated by detectTonemapSupport;
22
+ // when unavailable the encode falls back to a plain 8-bit convert (no tonemap).
23
+ // npl=100 targets ~100-nit SDR; hable is a well-behaved tone-mapping operator.
24
+ export const TONEMAP_FILTER_CHAIN =
25
+ "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709," +
26
+ "tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
27
+ // Default output frame rate when the source rate is unknown, and the rate used
28
+ // by the synthetic startup test-encode / preset benchmark. The real encode
29
+ // inherits the source rate (rounded to an integer, capped) — see
30
+ // chooseOutputFps — so 25/30 fps content no longer plays resampled to 24.
31
+ export const TRANSCODE_FPS = 24;
32
+ // Upper bound on the output frame rate: 50/60 fps sources are halved-in-effort
33
+ // by capping to 30, protecting the realtime encode budget on weak hosts.
34
+ export const MAX_OUTPUT_FPS = 30;
35
+
36
+ /**
37
+ * Choose an INTEGER output frame rate from the (possibly fractional) source
38
+ * rate, for the frame-count-GOP encoders ONLY (software libx264, v4l2m2m).
39
+ * Those place keyframes with `-g = segmentDur × fps` (frame count), so the
40
+ * `fps=` filter value must be an integer that makes seg×fps an exact whole
41
+ * number of frames per segment — otherwise segments drift off the synthetic
42
+ * playlist's uniform grid and seek accuracy degrades over a long file. Film
43
+ * rates (23.976) round to 24, 25 stays 25, 29.97 rounds to 30; the cap clamps
44
+ * high rates (the cap is a SPEED guard for the weak software/v4l2m2m path).
45
+ *
46
+ * Time-based-keyframe encoders (nvenc, vaapi, qsv) do NOT use this — they
47
+ * inherit the exact source rate untouched (their keyframes are forced by
48
+ * output time, so any rate segments correctly).
49
+ *
50
+ * @param {number | null | undefined} sourceFps
51
+ * @param {number} [cap=MAX_OUTPUT_FPS]
52
+ * @returns {number}
53
+ */
54
+ export function chooseOutputFps(sourceFps, cap = MAX_OUTPUT_FPS) {
55
+ if (!Number.isFinite(sourceFps) || sourceFps <= 0) {
56
+ return TRANSCODE_FPS;
57
+ }
58
+ const rounded = Math.round(sourceFps);
59
+ if (rounded < 1) {
60
+ return TRANSCODE_FPS;
61
+ }
62
+ return Math.min(cap, rounded);
63
+ }
64
+ // Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
65
+ export const CPU_THREADS = Math.max(1, os.cpus().length);
66
+
67
+ // Bitrate caps (constrained CRF). CRF stays the quality driver; -maxrate/
68
+ // -bufsize only bound the peaks. Field evidence (iPhone on cellular,
69
+ // 2026-07-10): uncapped complex scenes produced 4 s segments of ~18 Mbit/s
70
+ // against a 1-6 Mbit/s viewer link — 45 s prebuffer, draining buffer.
71
+ // Nominal H.264 rates per rung height; multipliers from webtor's production
72
+ // ladder (content-transcoder): maxrate = 1.3x nominal, bufsize = 1.5x.
73
+ const RUNG_NOMINAL_KBPS = [
74
+ [1080, 5000],
75
+ [720, 2800],
76
+ [480, 1400],
77
+ [360, 800],
78
+ [240, 400]
79
+ ];
80
+ const CAP_MAXRATE_FACTOR = 1.3;
81
+ const CAP_BUFSIZE_FACTOR = 1.5;
82
+
83
+ /**
84
+ * Nominal kbps for an encode height: nearest rung wins (odd heights snap to
85
+ * the closest standard rung; anything above the top rung uses the top one).
86
+ *
87
+ * @param {number} height
88
+ * @returns {number}
89
+ */
90
+ export function nominalKbpsForHeight(height) {
91
+ const h = Number.isFinite(height) && height > 0 ? height : 720;
92
+ let best = RUNG_NOMINAL_KBPS[0];
93
+ for (const rung of RUNG_NOMINAL_KBPS) {
94
+ if (Math.abs(rung[0] - h) < Math.abs(best[0] - h)) {
95
+ best = rung;
96
+ }
97
+ }
98
+ return best[1];
99
+ }
100
+
101
+ /**
102
+ * The peak this encode may reach, in kbit/s, for a nominal rate.
103
+ *
104
+ * Exported because the same figure answers a second question: whether a rung
105
+ * fits the viewer's measured link. The budget compares the link against what
106
+ * the encode is ALLOWED to peak at rather than against what it happened to
107
+ * produce in the last few segments, so a rung is judged by the bound we impose
108
+ * on it and not by a quiet stretch of the film.
109
+ *
110
+ * @param {number} nominalKbps
111
+ * @returns {number}
112
+ */
113
+ export function maxrateKbpsFor(nominalKbps) {
114
+ return Math.round(nominalKbps * CAP_MAXRATE_FACTOR);
115
+ }
116
+
117
+ /**
118
+ * The nominal rate whose cap is a given peak — the inverse of
119
+ * {@link maxrateKbpsFor}.
120
+ *
121
+ * Used to turn a MEASURED limit into the figure the cap arithmetic takes. The
122
+ * viewer's usable link is a peak the stream must not exceed, and the encoder is
123
+ * configured from a nominal rate, so the two are converted through the one
124
+ * factor rather than through a second constant invented for the purpose.
125
+ *
126
+ * @param {number} maxrateKbps
127
+ * @returns {number}
128
+ */
129
+ export function nominalKbpsForMaxrate(maxrateKbps) {
130
+ return Math.round(maxrateKbps / CAP_MAXRATE_FACTOR);
131
+ }
132
+
133
+ /**
134
+ * `-maxrate`/`-bufsize` args for an encode height (constrained CRF).
135
+ *
136
+ * `nominalKbps` overrides the height's own nominal rate. It is how a measured
137
+ * limit — the viewer's link, the only figure that bounds an encode from
138
+ * outside this host — reaches the encoder without touching the picture's SIZE.
139
+ * That distinction is the whole point: `-maxrate`, `-bufsize` and CRF do not
140
+ * appear in the SPS (x264 writes no HRD parameters by default), so they can be
141
+ * moved in the middle of a session while one init segment goes on describing
142
+ * every fragment. The size cannot.
143
+ *
144
+ * @param {number} height
145
+ * @param {number | null} [nominalKbps=null]
146
+ * @returns {string[]}
147
+ */
148
+ export function bitrateCapArgs(height, nominalKbps = null) {
149
+ const nominal = Number.isFinite(nominalKbps) && nominalKbps > 0
150
+ ? nominalKbps
151
+ : nominalKbpsForHeight(height);
152
+ return [
153
+ "-maxrate", `${maxrateKbpsFor(nominal)}k`,
154
+ "-bufsize", `${Math.round(nominal * CAP_BUFSIZE_FACTOR)}k`
155
+ ];
156
+ }
157
+
158
+ /**
159
+ * @param {number} targetWidth
160
+ * @param {number} targetHeight
161
+ * @returns {{ w: number, h: number }}
162
+ */
163
+ export function safeDimensions(targetWidth, targetHeight) {
164
+ const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
165
+ const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
166
+ return { w, h };
167
+ }
168
+
169
+ /**
170
+ * Force a keyframe on every segment boundary so each HLS segment is
171
+ * independently decodable.
172
+ *
173
+ * Two grids exist. The usual one is even — a keyframe every
174
+ * `segmentDurationSec` — and the encoder is free to place them because it is
175
+ * producing every frame anyway. The other is the SOURCE's own keyframe times,
176
+ * used when this encode has to be interchangeable with a stream that is
177
+ * COPIED: a copy can only be cut where the source already has a keyframe, so a
178
+ * rung meant to splice into it must be cut at exactly those times and nowhere
179
+ * else. Then the times are given outright.
180
+ *
181
+ * @param {number} segmentDurationSec
182
+ * @param {number[] | null} [forcedTimes] - Run-relative seconds, ascending.
183
+ * @returns {string[]}
184
+ */
185
+ export function keyFrameArgs(segmentDurationSec, forcedTimes = null) {
186
+ if (Array.isArray(forcedTimes) && forcedTimes.length > 0) {
187
+ return ["-force_key_frames", forcedTimes.join(",")];
188
+ }
189
+ return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
190
+ }
191
+
192
+ /**
193
+ * Whether an explicit cut list was supplied.
194
+ *
195
+ * @param {number[] | null | undefined} forcedTimes
196
+ * @returns {boolean}
197
+ */
198
+ export function hasForcedTimes(forcedTimes) {
199
+ return Array.isArray(forcedTimes) && forcedTimes.length > 0;
200
+ }
@@ -29,6 +29,23 @@
29
29
  export const ENCODE_EXIT = Object.freeze({
30
30
  /** The process was already replaced or the session is gone: it says nothing. */
31
31
  IGNORED: "ignored",
32
+ /**
33
+ * We stopped it on purpose — moved off covered material, no longer wanted,
34
+ * the output was dropped.
35
+ *
36
+ * Abnormal by the rule this vocabulary exists to serve: exactly one ending is
37
+ * normal, and our own kill is not it. Hiding it among the normal endings is
38
+ * what would make the count of abnormal endings useless.
39
+ */
40
+ STOPPED: "stopped",
41
+ /**
42
+ * It was found to be over without having said so.
43
+ *
44
+ * Only a run handed over from elsewhere can end this way; one that owns its
45
+ * own process reports its own ending. Counted apart precisely because it
46
+ * means nobody watched it end.
47
+ */
48
+ GONE: "gone",
32
49
  /** Reached the end of the file. */
33
50
  COMPLETE: "complete",
34
51
  /** Claimed success, stopped short of the last segment — the input dried up. */
@@ -0,0 +1,9 @@
1
+ // Only what is imported through this file. Everything else in `encode/` is
2
+ // imported from the module that declares it, which is how the rest of the
3
+ // package already reads — four other folders had a file like this one that
4
+ // nothing imported at all, and they are gone.
5
+ export { NvencEncoder } from "./NvencEncoder.js";
6
+ export { QsvEncoder } from "./QsvEncoder.js";
7
+ export { SoftwareEncoder } from "./SoftwareEncoder.js";
8
+ export { V4l2m2mEncoder } from "./V4l2m2mEncoder.js";
9
+ export { VaapiEncoder } from "./VaapiEncoder.js";