@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,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How many encoders there should be, and where.
|
|
3
|
+
*
|
|
4
|
+
* Every case here is one the code used to get wrong for a reason recorded in
|
|
5
|
+
* `research/encoder-layer-2026-09-04.md`: a run placed at a viewer's position
|
|
6
|
+
* rather than at the first thing missing, a run with no end, and nothing that
|
|
7
|
+
* stops a run which has caught up with material already made.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { CoverageMap } from "../services/encode/CoverageMap.js";
|
|
13
|
+
import { firstUnmetWant, planEncoders } from "../services/encode/EncodePlan.js";
|
|
14
|
+
|
|
15
|
+
/** A host that can afford two encoders, four-second segments, a cheap restart. */
|
|
16
|
+
const HOST = { maxRuns: 2, segmentSeconds: 4, restartCostSec: 0.12 };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Partial<import("../services/encode/EncodePlan.js").LiveRun>} run
|
|
20
|
+
* @returns {import("../services/encode/EncodePlan.js").LiveRun}
|
|
21
|
+
*/
|
|
22
|
+
function run(run_) {
|
|
23
|
+
// A run has no name: the plan hands back the run itself, so a test compares
|
|
24
|
+
// the thing rather than a token standing for it.
|
|
25
|
+
return { from: 0, to: 100, head: 0, speedX: 2, ...run_ };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("a viewer waiting on nothing made starts one encoder, at what they are waiting for", () => {
|
|
29
|
+
// It starts where the viewer is stopped and runs to the end of the film,
|
|
30
|
+
// because nothing else is in the way. The window says where to start and
|
|
31
|
+
// whether to start; it does not say where to stop — a window travels forward
|
|
32
|
+
// as the viewer plays, and a run bounded by one has to be replaced every few
|
|
33
|
+
// seconds.
|
|
34
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
35
|
+
const actions = planEncoders({
|
|
36
|
+
coverage,
|
|
37
|
+
windows: [{ from: 40, to: 70 }],
|
|
38
|
+
runs: [],
|
|
39
|
+
...HOST
|
|
40
|
+
});
|
|
41
|
+
assert.deepEqual(
|
|
42
|
+
actions.map((action) => ({ type: action.type, from: action.from, to: action.to })),
|
|
43
|
+
[{ type: "start", from: 40, to: 99 }]
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a viewer whose cushion reaches past a running encoder starts nothing", () => {
|
|
48
|
+
// Found by the orchestrator's own checks: with the end taken from the window,
|
|
49
|
+
// a second viewer two segments further on was given an encoder of their own
|
|
50
|
+
// to make those two, while the run already there had nowhere left to go.
|
|
51
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
52
|
+
const runA = run({ from: 100, to: 999, head: 100 });
|
|
53
|
+
coverage.claim(runA, 100, 999);
|
|
54
|
+
const actions = planEncoders({
|
|
55
|
+
coverage,
|
|
56
|
+
windows: [{ from: 100, to: 130 }, { from: 102, to: 132 }],
|
|
57
|
+
runs: [runA],
|
|
58
|
+
...HOST
|
|
59
|
+
});
|
|
60
|
+
assert.equal(actions.some((action) => action.type === "start"), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a viewer whose whole window is already made starts nothing", () => {
|
|
64
|
+
// The case that used to restart an encoder to make a second copy of material
|
|
65
|
+
// sitting on the disk.
|
|
66
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
67
|
+
for (let index = 40; index <= 70; index += 1) {
|
|
68
|
+
coverage.markReady(index);
|
|
69
|
+
}
|
|
70
|
+
const actions = planEncoders({ coverage, windows: [{ from: 40, to: 70 }], runs: [], ...HOST });
|
|
71
|
+
assert.deepEqual(actions, []);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a run is given an end at the edge of what is free", () => {
|
|
75
|
+
// Handed the rest of the film it would drive straight into another run's
|
|
76
|
+
// ground; handed the free stretch it stops where the covered material starts.
|
|
77
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
78
|
+
const runB = run({ from: 50, to: 80 });
|
|
79
|
+
coverage.claim(runB, 50, 80);
|
|
80
|
+
const actions = planEncoders({ coverage, windows: [{ from: 40, to: 90 }], runs: [], ...HOST });
|
|
81
|
+
assert.equal(actions.length, 1);
|
|
82
|
+
assert.deepEqual({ from: actions[0].from, to: actions[0].to }, { from: 40, to: 49 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a run that has caught up with made material is moved forward, not killed", () => {
|
|
86
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
87
|
+
const runA = run({ head: 10, from: 0, to: 100 });
|
|
88
|
+
coverage.claim(runA, 0, 100);
|
|
89
|
+
for (let index = 10; index <= 30; index += 1) {
|
|
90
|
+
coverage.markReady(index);
|
|
91
|
+
}
|
|
92
|
+
const actions = planEncoders({
|
|
93
|
+
coverage,
|
|
94
|
+
windows: [{ from: 0, to: 90 }],
|
|
95
|
+
runs: [runA],
|
|
96
|
+
...HOST
|
|
97
|
+
});
|
|
98
|
+
const move = actions.find((action) => action.type === "move");
|
|
99
|
+
assert.ok(move, "it should have been moved");
|
|
100
|
+
assert.equal(move.run, runA);
|
|
101
|
+
assert.equal(move.from, 31, "to the first thing nobody has");
|
|
102
|
+
assert.equal(actions.some((action) => action.type === "stop"), false, "and not stopped");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a covered stretch shorter than a restart is driven through instead", () => {
|
|
106
|
+
// The comparison is of two measured quantities: the covered stretch divided
|
|
107
|
+
// by this run's own speed, against what a restart costs. One segment at 50x
|
|
108
|
+
// is 0.08 s of encoding against 0.12 s to move.
|
|
109
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
110
|
+
const runA = run({ head: 10, speedX: 50 });
|
|
111
|
+
coverage.claim(runA, 0, 100);
|
|
112
|
+
coverage.markReady(10);
|
|
113
|
+
const actions = planEncoders({
|
|
114
|
+
coverage,
|
|
115
|
+
windows: [{ from: 0, to: 90 }],
|
|
116
|
+
runs: [runA],
|
|
117
|
+
...HOST
|
|
118
|
+
});
|
|
119
|
+
assert.equal(actions.some((action) => action.type === "move"), false);
|
|
120
|
+
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a run whose speed nothing has measured is moved rather than left driving through", () => {
|
|
124
|
+
// Not a default: driving through is work that is certainly wasted, and the
|
|
125
|
+
// restart is a known and small cost. What cannot be done is the comparison.
|
|
126
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
127
|
+
const runA = run({ head: 10, speedX: 0 });
|
|
128
|
+
coverage.claim(runA, 0, 100);
|
|
129
|
+
coverage.markReady(10);
|
|
130
|
+
const actions = planEncoders({
|
|
131
|
+
coverage,
|
|
132
|
+
windows: [{ from: 0, to: 90 }],
|
|
133
|
+
runs: [runA],
|
|
134
|
+
...HOST
|
|
135
|
+
});
|
|
136
|
+
const move = actions.find((action) => action.type === "move");
|
|
137
|
+
assert.ok(move);
|
|
138
|
+
assert.match(move.because, /speed is not measured/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a run with nothing left to make ahead of it is stopped", () => {
|
|
142
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
143
|
+
const runA = run({ head: 10 });
|
|
144
|
+
coverage.claim(runA, 0, 100);
|
|
145
|
+
for (let index = 10; index <= 90; index += 1) {
|
|
146
|
+
coverage.markReady(index);
|
|
147
|
+
}
|
|
148
|
+
const actions = planEncoders({
|
|
149
|
+
coverage,
|
|
150
|
+
windows: [{ from: 0, to: 90 }],
|
|
151
|
+
runs: [runA],
|
|
152
|
+
...HOST
|
|
153
|
+
});
|
|
154
|
+
assert.deepEqual(
|
|
155
|
+
actions.map((action) => action.type),
|
|
156
|
+
["stop"]
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("every encoder stops when nobody is watching the output", () => {
|
|
161
|
+
// A look-ahead cannot answer this: it asks how far AHEAD of a viewer a run
|
|
162
|
+
// is, and there is no viewer.
|
|
163
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
164
|
+
const runA = run();
|
|
165
|
+
const runB = run();
|
|
166
|
+
const actions = planEncoders({
|
|
167
|
+
coverage,
|
|
168
|
+
windows: [],
|
|
169
|
+
runs: [runA, runB],
|
|
170
|
+
...HOST
|
|
171
|
+
});
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
actions.map((action) => [action.type, action.run]),
|
|
174
|
+
[["stop", runA], ["stop", runB]]
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a run making material nobody asked for is stopped", () => {
|
|
179
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
180
|
+
const runA = run({ from: 500, to: 600, head: 520 });
|
|
181
|
+
coverage.claim(runA, 500, 600);
|
|
182
|
+
const actions = planEncoders({
|
|
183
|
+
coverage,
|
|
184
|
+
windows: [{ from: 0, to: 40 }],
|
|
185
|
+
runs: [runA],
|
|
186
|
+
...HOST
|
|
187
|
+
});
|
|
188
|
+
assert.ok(actions.some((action) => action.type === "stop" && action.run === runA));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("two viewers far apart get an encoder each, when the machine can hold two", () => {
|
|
192
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
193
|
+
const actions = planEncoders({
|
|
194
|
+
coverage,
|
|
195
|
+
windows: [{ from: 0, to: 30 }, { from: 800, to: 830 }],
|
|
196
|
+
runs: [],
|
|
197
|
+
...HOST
|
|
198
|
+
});
|
|
199
|
+
const started = actions.filter((action) => action.type === "start").map((action) => action.from);
|
|
200
|
+
assert.deepEqual(started, [0, 800]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("a machine that can hold one gives it to the viewer who is stopped soonest", () => {
|
|
204
|
+
// The bound is the host's own budget, the same arithmetic that decides the
|
|
205
|
+
// quality offer. Measured on the addon host 2026-09-03: at 1080p one encode
|
|
206
|
+
// saturates the machine and two land on realtime.
|
|
207
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
208
|
+
const actions = planEncoders({
|
|
209
|
+
coverage,
|
|
210
|
+
windows: [{ from: 800, to: 830 }, { from: 0, to: 30 }],
|
|
211
|
+
runs: [],
|
|
212
|
+
...HOST,
|
|
213
|
+
maxRuns: 1
|
|
214
|
+
});
|
|
215
|
+
const started = actions.filter((action) => action.type === "start").map((action) => action.from);
|
|
216
|
+
assert.deepEqual(started, [0]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a viewer joining behind a running encoder gets their own, not a dragged one", () => {
|
|
220
|
+
// This is what the whole layer is for. The run in front is untouched; the
|
|
221
|
+
// viewer behind is not made to wait for it to be pulled back.
|
|
222
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
223
|
+
const runA = run({ from: 500, to: 600, head: 510 });
|
|
224
|
+
coverage.claim(runA, 500, 600);
|
|
225
|
+
const actions = planEncoders({
|
|
226
|
+
coverage,
|
|
227
|
+
windows: [{ from: 500, to: 530 }, { from: 100, to: 130 }],
|
|
228
|
+
runs: [runA],
|
|
229
|
+
...HOST
|
|
230
|
+
});
|
|
231
|
+
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
232
|
+
assert.equal(actions.some((action) => action.type === "move"), false);
|
|
233
|
+
const started = actions.filter((action) => action.type === "start");
|
|
234
|
+
assert.equal(started.length, 1);
|
|
235
|
+
assert.equal(started[0].from, 100);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("the lowest thing a viewer is waiting for is reported, so a stalled plan is visible", () => {
|
|
239
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
240
|
+
coverage.markReady(40);
|
|
241
|
+
coverage.markReady(41);
|
|
242
|
+
assert.equal(firstUnmetWant(coverage, [{ from: 40, to: 70 }]), 42);
|
|
243
|
+
coverage.markReadyAll([42, 43, 44]);
|
|
244
|
+
assert.equal(firstUnmetWant(coverage, [{ from: 40, to: 44 }]), null);
|
|
245
|
+
});
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
nextState,
|
|
26
26
|
processCanBeSignalled,
|
|
27
27
|
wireState
|
|
28
|
-
} from "../services/encode-run-state.js";
|
|
28
|
+
} from "../services/encode/encode-run-state.js";
|
|
29
29
|
|
|
30
30
|
const ALL_STATES = Object.values(ENCODE_RUN_STATE);
|
|
31
31
|
const ALL_EVENTS = Object.values(ENCODE_RUN_EVENT);
|
|
@@ -271,7 +271,7 @@ test("the sawtooth of 2.9.93 is not expressible", () => {
|
|
|
271
271
|
});
|
|
272
272
|
|
|
273
273
|
test("a dead run cannot be mistaken for one that is covering the seek", () => {
|
|
274
|
-
//
|
|
274
|
+
// the process handle pointed at a corpse, so every later seek was waved through
|
|
275
275
|
// as "already covered by the running encode" and the session answered 500 for
|
|
276
276
|
// as long as the viewer kept trying.
|
|
277
277
|
assert.equal(processCanBeSignalled(ENCODE_RUN_STATE.ENDED_FAILED), false);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One encoder's lifetime: the stretch it was given, where it got to, and
|
|
3
|
+
* whether its ending was the normal one.
|
|
4
|
+
*
|
|
5
|
+
* The process is injected, so none of this needs ffmpeg. What is checked is the
|
|
6
|
+
* accounting the user asked for on 2026-09-04: exactly one ending is normal,
|
|
7
|
+
* every other one says why, and none of them goes unrecorded.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { EncodeRun } from "../services/encode/EncodeRun.js";
|
|
14
|
+
import { ENCODE_EXIT } from "../services/encode/encode-exit.js";
|
|
15
|
+
import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A process that does nothing until the test says what became of it.
|
|
19
|
+
*/
|
|
20
|
+
class FakeProcess extends EventEmitter {
|
|
21
|
+
constructor() {
|
|
22
|
+
super();
|
|
23
|
+
this.pid = 4242;
|
|
24
|
+
this.signals = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
kill(signal) {
|
|
28
|
+
this.signals.push(signal);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
exitWith(code, signal = null) {
|
|
32
|
+
this.emit("exit", code, signal);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Partial<{from:number,to:number}>} [span]
|
|
38
|
+
*/
|
|
39
|
+
function makeRun(span = {}) {
|
|
40
|
+
const lines = [];
|
|
41
|
+
const ends = [];
|
|
42
|
+
const process_ = new FakeProcess();
|
|
43
|
+
const run = new EncodeRun({
|
|
44
|
+
address: "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/enc:854x480:auto",
|
|
45
|
+
encoder: new SoftwareEncoder(),
|
|
46
|
+
from: span.from ?? 10,
|
|
47
|
+
to: span.to ?? 14,
|
|
48
|
+
buildArgs: () => ["-i", "in", "out"],
|
|
49
|
+
spawn: () => process_,
|
|
50
|
+
logger: {
|
|
51
|
+
info: (line) => lines.push(["info", line]),
|
|
52
|
+
warn: (line) => lines.push(["warn", line])
|
|
53
|
+
},
|
|
54
|
+
now: () => 1000,
|
|
55
|
+
onEnded: (ended) => ends.push(ended)
|
|
56
|
+
});
|
|
57
|
+
return { run, process: process_, lines, ends };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("a start says why it is starting and with what", () => {
|
|
61
|
+
// The argument list alone cannot say whether this was a first open, a seek, a
|
|
62
|
+
// quality step or a move off covered material — and with several runs at once
|
|
63
|
+
// that is the only way to tell one start from another.
|
|
64
|
+
const { run, lines } = makeRun();
|
|
65
|
+
run.start("nobody is making #10 and a viewer is waiting for it");
|
|
66
|
+
const [level, line] = lines[0];
|
|
67
|
+
assert.equal(level, "info");
|
|
68
|
+
// A run is named by what it produces: the stretch it was given, on the output
|
|
69
|
+
// it makes segments of. No other live run of that output can hold it.
|
|
70
|
+
assert.match(line, /encode-run #10\.\.#14 of torrent:abc:/);
|
|
71
|
+
assert.match(line, /nobody is making #10/);
|
|
72
|
+
assert.match(line, /ffmpeg -i in out/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("its head is the next number it will make, and moves as it makes them", () => {
|
|
76
|
+
const { run } = makeRun();
|
|
77
|
+
run.start("first");
|
|
78
|
+
assert.equal(run.head, 10);
|
|
79
|
+
run.noteProduced(10);
|
|
80
|
+
run.noteProduced(11);
|
|
81
|
+
assert.equal(run.head, 12);
|
|
82
|
+
assert.equal(run.reached, 11);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("reaching the end of its stretch and exiting is the one normal ending", () => {
|
|
86
|
+
const { run, process, ends, lines } = makeRun({ from: 10, to: 12 });
|
|
87
|
+
run.start("first");
|
|
88
|
+
for (const index of [10, 11, 12]) {
|
|
89
|
+
run.noteProduced(index);
|
|
90
|
+
}
|
|
91
|
+
process.exitWith(0, null);
|
|
92
|
+
assert.equal(ends.length, 1);
|
|
93
|
+
assert.equal(ends[0].ending, ENCODE_EXIT.COMPLETE);
|
|
94
|
+
assert.equal(ends[0].normal, true);
|
|
95
|
+
assert.equal(lines.at(-1)[0], "info", "a normal ending is not a warning");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("exiting cleanly short of its stretch is abnormal and says where it stopped", () => {
|
|
99
|
+
// ffmpeg exits zero both at the end of a file and when its input stops
|
|
100
|
+
// producing bytes; over a torrent the two look identical to it.
|
|
101
|
+
const { run, process, ends } = makeRun({ from: 10, to: 14 });
|
|
102
|
+
run.start("first");
|
|
103
|
+
run.noteProduced(10);
|
|
104
|
+
run.noteProduced(11);
|
|
105
|
+
process.exitWith(0, null);
|
|
106
|
+
assert.equal(ends[0].ending, ENCODE_EXIT.SHORT);
|
|
107
|
+
assert.equal(ends[0].normal, false);
|
|
108
|
+
assert.equal(ends[0].reached, 11);
|
|
109
|
+
assert.match(ends[0].because, /#11 of #14/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a non-zero exit is a failure carrying its code", () => {
|
|
113
|
+
const { run, process, ends, lines } = makeRun();
|
|
114
|
+
run.start("first");
|
|
115
|
+
process.exitWith(255, null);
|
|
116
|
+
assert.equal(ends[0].ending, ENCODE_EXIT.FAILED);
|
|
117
|
+
assert.equal(ends[0].code, 255);
|
|
118
|
+
assert.equal(lines.at(-1)[0], "warn");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("our own kill is abnormal too, and carries the reason we gave", () => {
|
|
122
|
+
// Hiding it among the normal endings would make a count of abnormal endings
|
|
123
|
+
// useless, which is the whole point of counting them.
|
|
124
|
+
const { run, process, ends } = makeRun();
|
|
125
|
+
run.start("first");
|
|
126
|
+
run.stop("moved past 20 segments that are already made");
|
|
127
|
+
assert.deepEqual(process.signals, ["SIGTERM"]);
|
|
128
|
+
process.exitWith(null, "SIGTERM");
|
|
129
|
+
assert.equal(ends[0].ending, ENCODE_EXIT.STOPPED);
|
|
130
|
+
assert.equal(ends[0].normal, false);
|
|
131
|
+
assert.match(ends[0].because, /already made/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("an ending reports the stretch, how far it got and how long it lived", () => {
|
|
135
|
+
const { run, process, ends } = makeRun({ from: 10, to: 14 });
|
|
136
|
+
run.start("first");
|
|
137
|
+
run.noteProduced(10);
|
|
138
|
+
process.exitWith(1, null);
|
|
139
|
+
const ended = ends[0];
|
|
140
|
+
assert.equal(ended.from, 10);
|
|
141
|
+
assert.equal(ended.to, 14);
|
|
142
|
+
assert.equal(ended.reached, 10);
|
|
143
|
+
assert.equal(ended.livedMs, 0, "the clock is injected, so this is exact");
|
|
144
|
+
assert.equal(ended.address.includes("enc:854x480"), true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a process that could not be started ends like any other failure", () => {
|
|
148
|
+
const { run, process, ends } = makeRun();
|
|
149
|
+
run.start("first");
|
|
150
|
+
process.emit("error", new Error("ENOENT"));
|
|
151
|
+
assert.equal(ends[0].ending, ENCODE_EXIT.FAILED);
|
|
152
|
+
assert.match(ends[0].because, /ENOENT/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("only a measured speed is kept", () => {
|
|
156
|
+
const { run } = makeRun();
|
|
157
|
+
assert.equal(run.speedX, 0, "nothing measured yet");
|
|
158
|
+
run.noteSpeed(0);
|
|
159
|
+
assert.equal(run.speedX, 0);
|
|
160
|
+
run.noteSpeed(2.5);
|
|
161
|
+
assert.equal(run.speedX, 2.5);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a segment number behind its own start is not its to claim", () => {
|
|
165
|
+
const { run } = makeRun({ from: 10, to: 14 });
|
|
166
|
+
run.noteProduced(4);
|
|
167
|
+
assert.deepEqual(run.produced, []);
|
|
168
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What every encoder kind must state about itself, and the properties of
|
|
3
|
+
* its arguments that a field failure paid for.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately NOT a transcription of the argument lists: a test written by
|
|
6
|
+
* copying the code cannot fail when the code is wrong. What is stated here is
|
|
7
|
+
* what must hold for ANY kind, including one added later, plus the two
|
|
8
|
+
* kind-specific facts that were learned by running the thing on real hardware.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { Encoder } from "../services/encode/Encoder.js";
|
|
14
|
+
import { NvencEncoder } from "../services/encode/NvencEncoder.js";
|
|
15
|
+
import { QsvEncoder } from "../services/encode/QsvEncoder.js";
|
|
16
|
+
import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
|
|
17
|
+
import { V4l2m2mEncoder } from "../services/encode/V4l2m2mEncoder.js";
|
|
18
|
+
import { VaapiEncoder } from "../services/encode/VaapiEncoder.js";
|
|
19
|
+
|
|
20
|
+
const KINDS = [
|
|
21
|
+
new SoftwareEncoder(),
|
|
22
|
+
new VaapiEncoder("/dev/dri/renderD128"),
|
|
23
|
+
new QsvEncoder("/dev/dri/renderD128"),
|
|
24
|
+
new NvencEncoder(),
|
|
25
|
+
new V4l2m2mEncoder()
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const BASE = { targetWidth: 1280, targetHeight: 720, segmentDurationSec: 4, fps: 24 };
|
|
29
|
+
|
|
30
|
+
test("a kind's arguments name the encoder the kind claims to be", () => {
|
|
31
|
+
// The one thing that must never drift: a class called `h264_nvenc` that puts
|
|
32
|
+
// `libx264` on the command line would be filed under an address promising
|
|
33
|
+
// the other one's header.
|
|
34
|
+
for (const encoder of KINDS) {
|
|
35
|
+
const args = encoder.buildVideoArgs(BASE);
|
|
36
|
+
const at = args.indexOf("-c:v");
|
|
37
|
+
assert.notEqual(at, -1, `${encoder.name} names no video encoder`);
|
|
38
|
+
assert.equal(args[at + 1], encoder.name, `${encoder.name} builds ${args[at + 1]}`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an explicit cut list is passed through exactly, by every kind", () => {
|
|
43
|
+
// This is what makes a re-encoded step splice into a copied picture: it has
|
|
44
|
+
// to be cut at the source's own keyframe times and nowhere else. A kind that
|
|
45
|
+
// rounded them, or dropped them for its own even grid, would produce segments
|
|
46
|
+
// that cannot stand where the copy's would.
|
|
47
|
+
const times = [0, 4.271, 8.9, 13.04];
|
|
48
|
+
for (const encoder of KINDS) {
|
|
49
|
+
const args = encoder.buildVideoArgs({ ...BASE, forcedKeyframeTimes: times });
|
|
50
|
+
const at = args.indexOf("-force_key_frames");
|
|
51
|
+
assert.notEqual(at, -1, `${encoder.name} forces no keyframes`);
|
|
52
|
+
assert.equal(args[at + 1], times.join(","), `${encoder.name} altered the cut list`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("with no cut list, keyframes are still placed on the segment grid", () => {
|
|
57
|
+
for (const encoder of KINDS) {
|
|
58
|
+
const args = encoder.buildVideoArgs({ ...BASE, forcedKeyframeTimes: null });
|
|
59
|
+
const joined = args.join(" ");
|
|
60
|
+
// Either forced by time, or by a frame-count GOP — both put a keyframe on
|
|
61
|
+
// every boundary, which is what makes a segment independently decodable.
|
|
62
|
+
const byTime = joined.includes("-force_key_frames expr:gte(t,n_forced*4)");
|
|
63
|
+
const byFrameCount = joined.includes(`-g ${4 * 24}`);
|
|
64
|
+
assert.ok(byTime || byFrameCount, `${encoder.name} places keyframes by neither rule`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a target box of nothing still produces a usable size", () => {
|
|
69
|
+
// The session manager passes 0 for "keep the source size"; a kind that let
|
|
70
|
+
// that through would build `scale=0:0`.
|
|
71
|
+
for (const encoder of KINDS) {
|
|
72
|
+
const args = encoder.buildVideoArgs({ ...BASE, targetWidth: 0, targetHeight: 0 });
|
|
73
|
+
assert.ok(!args.join(" ").includes("=0:"), `${encoder.name} scaled to zero`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("every kind says what its speed setting is, or that it has none", () => {
|
|
78
|
+
for (const encoder of KINDS) {
|
|
79
|
+
const ladder = encoder.speedLadder;
|
|
80
|
+
assert.equal(typeof ladder.note, "string");
|
|
81
|
+
assert.ok(ladder.note.length > 0, `${encoder.name} explains nothing about its ladder`);
|
|
82
|
+
assert.ok(Array.isArray(ladder.values));
|
|
83
|
+
if (ladder.values.length > 0) {
|
|
84
|
+
assert.ok(ladder.flag.length > 0, `${encoder.name} lists settings but names no option`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("only libx264 claims a measured ladder", () => {
|
|
90
|
+
// The four hardware kinds have never been benchmarked across their settings,
|
|
91
|
+
// and a ladder claiming to be measured is what would let something start
|
|
92
|
+
// choosing from it.
|
|
93
|
+
for (const encoder of KINDS) {
|
|
94
|
+
assert.equal(
|
|
95
|
+
encoder.speedLadder.measured,
|
|
96
|
+
encoder.kind === "software",
|
|
97
|
+
`${encoder.name} misreports whether its ladder was measured`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a kind that is not software is hardware, and says so", () => {
|
|
103
|
+
for (const encoder of KINDS) {
|
|
104
|
+
assert.equal(encoder.isHardware, encoder.kind !== "software");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the encoder on an ARM board always asks for more capture buffers", () => {
|
|
109
|
+
// The default of four deadlocks on the CM4 — "All capture buffers returned to
|
|
110
|
+
// userspace" — so this is not a tuning choice but the condition under which
|
|
111
|
+
// that encoder runs at all.
|
|
112
|
+
const args = new V4l2m2mEncoder().buildVideoArgs(BASE);
|
|
113
|
+
const at = args.indexOf("-num_capture_buffers");
|
|
114
|
+
assert.notEqual(at, -1, "v4l2m2m was given the default buffer count");
|
|
115
|
+
assert.ok(Number(args[at + 1]) > 4);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a kind that does not say how to build its arguments refuses rather than producing none", () => {
|
|
119
|
+
class Unstated extends Encoder {}
|
|
120
|
+
const encoder = new Unstated({ name: "h264_unstated", kind: "nvenc" });
|
|
121
|
+
assert.throws(() => encoder.buildVideoArgs(BASE), /does not say how/);
|
|
122
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import test from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
14
15
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
15
16
|
import os from "node:os";
|
|
16
17
|
import path from "node:path";
|
|
@@ -34,13 +35,18 @@ async function managerWithSession() {
|
|
|
34
35
|
manager.sessionsById.set(SESSION_ID, {
|
|
35
36
|
id: SESSION_ID,
|
|
36
37
|
dirPath,
|
|
38
|
+
// Where this file is cut, held by the file. A fixture that stated it
|
|
39
|
+
// on the session was describing what production no longer does.
|
|
40
|
+
timeline: new Timeline({
|
|
41
|
+
boundaries: Array.from({ length: 201 }, (_, index) => index * SEGMENT_SECONDS),
|
|
42
|
+
cutGrid: "uniform"
|
|
43
|
+
}),
|
|
37
44
|
state: "ready",
|
|
38
45
|
segmentFormat: fmp4Format,
|
|
39
|
-
segmentBoundaries: Array.from({ length: 201 }, (_, index) => index * SEGMENT_SECONDS),
|
|
40
46
|
segmentCount: 200,
|
|
41
47
|
useSyntheticPlaylist: true,
|
|
42
48
|
// The viewer is at segment #25.
|
|
43
|
-
|
|
49
|
+
furthestViewerSeconds: 100
|
|
44
50
|
});
|
|
45
51
|
return { manager, dirPath };
|
|
46
52
|
}
|
|
@@ -107,7 +113,7 @@ test("a seek by one viewer does not release the request held for another", async
|
|
|
107
113
|
// The shared field now holds the leader's position, which is what the
|
|
108
114
|
// encoder is steered by and what every earlier release judged BOTH of them
|
|
109
115
|
// against.
|
|
110
|
-
assert.equal(session.
|
|
116
|
+
assert.equal(session.furthestViewerSeconds, 600);
|
|
111
117
|
|
|
112
118
|
assert.equal(
|
|
113
119
|
manager.requestStillWanted(SESSION_ID, segment(26), "behind"),
|