@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.
- package/CHANGELOG.md +1509 -1461
- package/CLAUDE.md +11 -1
- package/biome.json +182 -1
- package/docs/container-architecture.md +2 -1
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +206 -201
- package/services/container/AviContainer.js +1 -1
- package/services/container/Container.js +33 -1
- package/services/container/MatroskaContainer.js +1 -1
- package/services/container/Mp4Container.js +1 -1
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11100 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -775
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /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";
|