@torrent-tv/proxy 2.75.0 → 2.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1504 -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 +11073 -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
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import test from "node:test";
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
18
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
17
19
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
18
20
|
import os from "node:os";
|
|
19
21
|
import path from "node:path";
|
|
20
22
|
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
23
|
+
import { startRunOn } from "./helpers/encode-run.js";
|
|
21
24
|
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
22
25
|
|
|
23
26
|
const SESSION_ID = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff";
|
|
@@ -41,8 +44,18 @@ async function managerWithRunAhead() {
|
|
|
41
44
|
const session = {
|
|
42
45
|
id: SESSION_ID,
|
|
43
46
|
dirPath,
|
|
47
|
+
// Where this file is cut, held by the file. A fixture that stated it
|
|
48
|
+
// on the session was describing what production no longer does.
|
|
49
|
+
timeline: new Timeline({
|
|
50
|
+
boundaries: Array.from({ length: 1937 }, (_, index) => index * SEGMENT_SECONDS),
|
|
51
|
+
cutGrid: "uniform"
|
|
52
|
+
}),
|
|
44
53
|
state: "ready",
|
|
45
|
-
|
|
54
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.avi" }),
|
|
55
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
56
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
57
|
+
get inputFile() { return this.file; },
|
|
58
|
+
get audioFile() { return this.file; },
|
|
46
59
|
startedAt: Date.now(),
|
|
47
60
|
createEntryMs: Date.now(),
|
|
48
61
|
lastAccessedAt: Date.now(),
|
|
@@ -52,10 +65,7 @@ async function managerWithRunAhead() {
|
|
|
52
65
|
transcodeVideo: true,
|
|
53
66
|
useSyntheticPlaylist: true,
|
|
54
67
|
playlistText: "#EXTM3U\n",
|
|
55
|
-
segmentBoundaries: Array.from({ length: 1937 }, (_, index) => index * SEGMENT_SECONDS),
|
|
56
68
|
segmentCount: 1936,
|
|
57
|
-
encodeStartIndex: RUN_STARTS_AT,
|
|
58
|
-
encodeRunGeneration: 0,
|
|
59
69
|
lastRestartAt: 0,
|
|
60
70
|
seekFailureTarget: -1,
|
|
61
71
|
seekFailureCount: 0,
|
|
@@ -63,11 +73,11 @@ async function managerWithRunAhead() {
|
|
|
63
73
|
seekTarget: null,
|
|
64
74
|
waitEpoch: 0,
|
|
65
75
|
firstWantedAt: new Map(),
|
|
66
|
-
|
|
67
|
-
ffmpeg: { pid: 4321, exitCode: null, signalCode: null, kill() {}, once(event, handler) { if (event === "exit") handler(); } },
|
|
76
|
+
runs: new Set(),
|
|
68
77
|
progress: { state: "running", processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS + 400, startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
|
|
69
78
|
};
|
|
70
79
|
manager.sessionsById.set(SESSION_ID, session);
|
|
80
|
+
startRunOn(session, { from: RUN_STARTS_AT });
|
|
71
81
|
const restarts = [];
|
|
72
82
|
return { manager, session, dirPath, restarts };
|
|
73
83
|
}
|
|
@@ -190,7 +200,7 @@ test("a rung whose encoder was stopped is not brought back by a held request", a
|
|
|
190
200
|
await rm(dirPath, { recursive: true, force: true });
|
|
191
201
|
});
|
|
192
202
|
// What a quality switch leaves behind: the rung nobody is watching, parked.
|
|
193
|
-
session.
|
|
203
|
+
session.runs = new Set();
|
|
194
204
|
session.firstWantedAt.set(WANTED, Date.now() - 4000);
|
|
195
205
|
|
|
196
206
|
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The three states of a segment number, and the two questions that decide
|
|
3
|
+
* where an encoder goes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { CoverageMap } from "../services/encode/CoverageMap.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A stand-in for a run.
|
|
12
|
+
*
|
|
13
|
+
* The map files a claim under the run ITSELF: a run has no name, and needs
|
|
14
|
+
* none — what identifies it is that it is itself, and what identifies it in a
|
|
15
|
+
* line is the stretch it was given, which no other live run of one output can
|
|
16
|
+
* hold.
|
|
17
|
+
*
|
|
18
|
+
* @returns {object}
|
|
19
|
+
*/
|
|
20
|
+
function aRun() {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("a number nobody has made and nobody is making is free", () => {
|
|
25
|
+
const map = new CoverageMap({ segmentCount: 10 });
|
|
26
|
+
assert.equal(map.stateOf(3), "free");
|
|
27
|
+
assert.equal(map.firstGapFrom(0), 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a closed file is ready whoever made it", () => {
|
|
31
|
+
const map = new CoverageMap({ segmentCount: 10 });
|
|
32
|
+
map.markReady(0);
|
|
33
|
+
map.markReady(1);
|
|
34
|
+
assert.equal(map.stateOf(1), "ready");
|
|
35
|
+
assert.equal(map.firstGapFrom(0), 2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a run claims a stretch, and the gap search skips it", () => {
|
|
39
|
+
// The claim is an interval and not a head, which is the whole reason two runs
|
|
40
|
+
// can work on one output: the second is sent past what the first will reach.
|
|
41
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
42
|
+
const runA = aRun();
|
|
43
|
+
map.claim(runA, 0, 40);
|
|
44
|
+
assert.equal(map.stateOf(20), "making");
|
|
45
|
+
assert.equal(map.makerOf(20), runA);
|
|
46
|
+
assert.equal(map.firstGapFrom(0), 41);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a run that ends gives back what it did not finish, and keeps what it did", () => {
|
|
50
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
51
|
+
const runA = aRun();
|
|
52
|
+
map.claim(runA, 0, 40);
|
|
53
|
+
map.markReady(0);
|
|
54
|
+
map.markReady(1);
|
|
55
|
+
map.release(runA);
|
|
56
|
+
assert.equal(map.stateOf(0), "ready", "a closed file survives its maker");
|
|
57
|
+
assert.equal(map.stateOf(2), "free", "the rest of the stretch is free again");
|
|
58
|
+
assert.equal(map.firstGapFrom(0), 2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a run with no end claims everything ahead of it", () => {
|
|
62
|
+
// Which is what every run was before ends existed, and is why a second run
|
|
63
|
+
// could never be placed.
|
|
64
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
65
|
+
const runA = aRun();
|
|
66
|
+
map.claim(runA, 10, Number.POSITIVE_INFINITY);
|
|
67
|
+
assert.equal(map.firstGapFrom(10), null);
|
|
68
|
+
assert.equal(map.firstGapFrom(0), 0, "behind it is still free");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("claiming again replaces the earlier claim rather than adding to it", () => {
|
|
72
|
+
// A run moved forward past ready material states its new stretch; if the two
|
|
73
|
+
// accumulated, the ground it has left would stay unavailable for ever.
|
|
74
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
75
|
+
const runA = aRun();
|
|
76
|
+
map.claim(runA, 0, 20);
|
|
77
|
+
map.claim(runA, 50, 70);
|
|
78
|
+
assert.equal(map.stateOf(10), "free");
|
|
79
|
+
assert.equal(map.stateOf(60), "making");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the covered stretch ahead is measured, which is what prices a move", () => {
|
|
83
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
84
|
+
const runA = aRun();
|
|
85
|
+
const runB = aRun();
|
|
86
|
+
for (let index = 10; index <= 24; index += 1) {
|
|
87
|
+
map.markReady(index);
|
|
88
|
+
}
|
|
89
|
+
map.claim(runB, 25, 30);
|
|
90
|
+
// Ready 10..24 then another run's 25..30, so fifteen plus six.
|
|
91
|
+
assert.equal(map.coveredRunFrom(10, runA), 21);
|
|
92
|
+
assert.equal(map.firstGapFrom(10), 31);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a run's own claim is not counted as somebody else's coverage", () => {
|
|
96
|
+
// Otherwise a run would read its own stretch as a reason to move off it.
|
|
97
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
98
|
+
const runA = aRun();
|
|
99
|
+
map.claim(runA, 10, 30);
|
|
100
|
+
assert.equal(map.coveredRunFrom(10, runA), 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("nothing free ahead answers null rather than a number past the end", () => {
|
|
104
|
+
const map = new CoverageMap({ segmentCount: 5 });
|
|
105
|
+
map.markReadyAll([0, 1, 2, 3, 4]);
|
|
106
|
+
assert.equal(map.firstGapFrom(0), null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a file that has gone stops being ready", () => {
|
|
110
|
+
const map = new CoverageMap({ segmentCount: 10 });
|
|
111
|
+
map.markReady(4);
|
|
112
|
+
map.markGone(4);
|
|
113
|
+
assert.equal(map.stateOf(4), "free");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a run's own claim does not hide the gap it is trying to move into", () => {
|
|
117
|
+
// Found by the plan's own checks: a run that had claimed the rest of the film
|
|
118
|
+
// — which is what every run did before ends existed — could find no gap
|
|
119
|
+
// anywhere, so it was stopped instead of moved.
|
|
120
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
121
|
+
const runA = aRun();
|
|
122
|
+
map.claim(runA, 0, 99);
|
|
123
|
+
map.markReadyAll([10, 11, 12]);
|
|
124
|
+
assert.equal(map.firstGapFrom(10, 90), null, "to anybody else its ground is taken");
|
|
125
|
+
assert.equal(map.firstGapFrom(10, 90, runA), 13, "to itself the ground beyond is a gap");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("the free stretch ahead is measured, which is what gives a run its end", () => {
|
|
129
|
+
// A run handed the whole rest of the film would drive through the stretch
|
|
130
|
+
// another run is making. Handed the free stretch, it stops where the covered
|
|
131
|
+
// material begins.
|
|
132
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
133
|
+
const runB = aRun();
|
|
134
|
+
map.claim(runB, 30, 60);
|
|
135
|
+
assert.equal(map.freeRunFrom(10), 20, "free from 10 up to 29");
|
|
136
|
+
map.markReady(15);
|
|
137
|
+
assert.equal(map.freeRunFrom(10), 5, "a ready segment ends the free stretch too");
|
|
138
|
+
assert.equal(map.freeRunFrom(15), 0, "a number that is not free has no free stretch");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a run's own claim does not end its own free stretch", () => {
|
|
142
|
+
const map = new CoverageMap({ segmentCount: 100 });
|
|
143
|
+
const runA = aRun();
|
|
144
|
+
map.claim(runA, 10, 40);
|
|
145
|
+
assert.equal(map.freeRunFrom(10, runA), 90, "its own ground is still its to fill");
|
|
146
|
+
assert.equal(map.freeRunFrom(10), 0, "to anybody else it is taken");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("with the length unknown, a gap search needs its own bound", () => {
|
|
150
|
+
const map = new CoverageMap();
|
|
151
|
+
assert.equal(map.firstGapFrom(0), null, "no length and no bound answers nothing");
|
|
152
|
+
assert.equal(map.firstGapFrom(0, 3), 0);
|
|
153
|
+
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import assert from "node:assert/strict";
|
|
16
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
16
17
|
import test from "node:test";
|
|
17
18
|
|
|
18
19
|
import { onKeyframeGridFor } from "../services/hls-session-manager.js";
|
|
@@ -33,10 +34,10 @@ test("a soundtrack follows the grid it was cut on, not its own encoding", () =>
|
|
|
33
34
|
// A rendition is re-encoded by definition, so asking `transcodeVideo` about
|
|
34
35
|
// it answers nothing. What decides its timeline is the grid it shares with
|
|
35
36
|
// the picture it plays with.
|
|
36
|
-
assert.equal(onKeyframeGridFor({ audioOnly: true, cutGrid: "keyframe" }), true);
|
|
37
|
-
assert.equal(onKeyframeGridFor({ audioOnly: true, cutGrid: "uniform" }), false);
|
|
37
|
+
assert.equal(onKeyframeGridFor({ audioOnly: true, timeline: new Timeline({ boundaries: [], cutGrid: "keyframe" }) }), true);
|
|
38
|
+
assert.equal(onKeyframeGridFor({ audioOnly: true, timeline: new Timeline({ boundaries: [], cutGrid: "uniform" }) }), false);
|
|
38
39
|
assert.equal(
|
|
39
|
-
onKeyframeGridFor({ audioOnly: true, cutGrid: "keyframe"
|
|
40
|
+
onKeyframeGridFor({ audioOnly: true, transcodeVideo: true, timeline: new Timeline({ boundaries: [], cutGrid: "keyframe" }) }),
|
|
40
41
|
true,
|
|
41
42
|
"its own encoding must not decide this — that disagreement is the defect"
|
|
42
43
|
);
|
|
@@ -48,8 +49,8 @@ test("one predicate answers for every caller", () => {
|
|
|
48
49
|
for (const session of [
|
|
49
50
|
{ transcodeVideo: false },
|
|
50
51
|
{ transcodeVideo: true },
|
|
51
|
-
{ audioOnly: true, cutGrid: "keyframe" },
|
|
52
|
-
{ audioOnly: true, cutGrid: "uniform" },
|
|
52
|
+
{ audioOnly: true, timeline: new Timeline({ boundaries: [], cutGrid: "keyframe" }) },
|
|
53
|
+
{ audioOnly: true, timeline: new Timeline({ boundaries: [], cutGrid: "uniform" }) },
|
|
53
54
|
{}
|
|
54
55
|
]) {
|
|
55
56
|
assert.equal(onKeyframeGridFor(session), onKeyframeGridFor({ ...session }));
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import assert from "node:assert/strict";
|
|
18
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
18
19
|
import test from "node:test";
|
|
19
20
|
|
|
20
21
|
import { HlsSessionManager, segmentCutTimesFrom } from "../services/hls-session-manager.js";
|
|
@@ -38,8 +39,7 @@ function sessionWithDriftedGrid() {
|
|
|
38
39
|
});
|
|
39
40
|
const session = {
|
|
40
41
|
id: "picture",
|
|
41
|
-
|
|
42
|
-
publishedBoundaries: [...PUBLISHED]
|
|
42
|
+
timeline: new Timeline({ boundaries: [...CORRECTED], published: [...PUBLISHED], cutGrid: "uniform" })
|
|
43
43
|
};
|
|
44
44
|
return { manager, session };
|
|
45
45
|
}
|
|
@@ -58,7 +58,7 @@ test("the cut list is the one the playlist was written from", () => {
|
|
|
58
58
|
|
|
59
59
|
test("a corrected grid does not move the cuts of a session already being read", () => {
|
|
60
60
|
const { manager, session } = sessionWithDriftedGrid();
|
|
61
|
-
const fromCorrected = segmentCutTimesFrom(session.
|
|
61
|
+
const fromCorrected = segmentCutTimesFrom(session.timeline.boundaries, 1);
|
|
62
62
|
const fromPublished = segmentCutTimesFrom(manager.publishedGridFor(session), 1);
|
|
63
63
|
assert.notDeepEqual(fromCorrected, fromPublished);
|
|
64
64
|
// The gap the field measured: the corrected table would have cut #2 two
|
|
@@ -68,6 +68,6 @@ test("a corrected grid does not move the cuts of a session already being read",
|
|
|
68
68
|
|
|
69
69
|
test("a session that published no grid falls back to the live one", () => {
|
|
70
70
|
const { manager } = sessionWithDriftedGrid();
|
|
71
|
-
const session = { id: "no-playlist",
|
|
71
|
+
const session = { id: "no-playlist", timeline: new Timeline({ boundaries: [...CORRECTED], published: [], cutGrid: "uniform" }) };
|
|
72
72
|
assert.deepEqual(manager.publishedGridFor(session), CORRECTED);
|
|
73
73
|
});
|
package/test/decode-cost.test.js
CHANGED
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import test from "node:test";
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
17
18
|
import { createRequire } from "node:module";
|
|
18
19
|
import { spawn } from "node:child_process";
|
|
19
20
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
20
21
|
import { decodeCostOf, decodeFamilyOf } from "../services/decode-cost-fit.js";
|
|
22
|
+
import { Output } from "../services/output/Output.js";
|
|
21
23
|
import os from "node:os";
|
|
22
24
|
import path from "node:path";
|
|
23
25
|
import {
|
|
@@ -27,7 +29,8 @@ import {
|
|
|
27
29
|
predictedRealtimeSpeed,
|
|
28
30
|
speedBar
|
|
29
31
|
} from "../services/hwaccel.js";
|
|
30
|
-
import { HlsSessionManager
|
|
32
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
33
|
+
import { SourceFile, sourceDecodeCharacteristics } from "../services/source/SourceFile.js";
|
|
31
34
|
import { parseFfmpegBitrateKbps, parseFfmpegVideoDimensions, parseFfmpegVideoFps } from "../services/ffmpeg-banner.js";
|
|
32
35
|
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
33
36
|
|
|
@@ -323,8 +326,26 @@ test("the OFFER drops the rungs the host cannot hold, and the master keeps addre
|
|
|
323
326
|
const session = {
|
|
324
327
|
id: "cccccccc-dddd-eeee-ffff-000000000000",
|
|
325
328
|
dirPath,
|
|
329
|
+
// Where this file is cut, held by the file. A fixture that stated it
|
|
330
|
+
// on the session was describing what production no longer does.
|
|
331
|
+
timeline: new Timeline({
|
|
332
|
+
boundaries: Array.from({ length: 101 }, (_, index) => index * 4),
|
|
333
|
+
cutGrid: "keyframe"
|
|
334
|
+
}),
|
|
326
335
|
state: "ready",
|
|
327
|
-
|
|
336
|
+
// The file's own facts: the picture's size, and what decoding it costs,
|
|
337
|
+
// which is derived from them rather than stated beside them. These are the
|
|
338
|
+
// measured film of MEASURED_FILM above — 1080p24 at 8 Mbit/s.
|
|
339
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }).learn({
|
|
340
|
+
width: 1920,
|
|
341
|
+
height: 1080,
|
|
342
|
+
fps: 24,
|
|
343
|
+
bitrateKbps: 8000
|
|
344
|
+
}),
|
|
345
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
346
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
347
|
+
get inputFile() { return this.file; },
|
|
348
|
+
get audioFile() { return this.file; },
|
|
328
349
|
startedAt: Date.now(),
|
|
329
350
|
lastAccessedAt: Date.now(),
|
|
330
351
|
ffmpeg: null,
|
|
@@ -334,21 +355,19 @@ test("the OFFER drops the rungs the host cannot hold, and the master keeps addre
|
|
|
334
355
|
transcodeVideo: false,
|
|
335
356
|
transcodeAudio: true,
|
|
336
357
|
audioTrackIndex: 0,
|
|
337
|
-
sourceKey: "source-1",
|
|
338
|
-
fileIndex: 0,
|
|
339
|
-
sourceWidth: 1920,
|
|
340
|
-
sourceHeight: 1080,
|
|
341
|
-
sourceDecode: MEASURED_FILM,
|
|
342
|
-
outputFps: 24,
|
|
343
358
|
// A copy can only be cut where the source already has a keyframe, and a
|
|
344
359
|
// master is offered only when that grid is real.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
360
|
+
// The shape this output is encoded AS, decided once for the output.
|
|
361
|
+
output: new Output({
|
|
362
|
+
encodeWidth: 0,
|
|
363
|
+
encodeHeight: 0,
|
|
364
|
+
outputFps: 24,
|
|
365
|
+
softwarePreset: null,
|
|
366
|
+
applyTonemap: false
|
|
367
|
+
}),
|
|
348
368
|
usesExplicitCuts: true,
|
|
349
369
|
useSyntheticPlaylist: true,
|
|
350
370
|
playlistText: "#EXTM3U\n",
|
|
351
|
-
segmentBoundaries: Array.from({ length: 101 }, (_, index) => index * 4),
|
|
352
371
|
segmentCount: 100,
|
|
353
372
|
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
354
373
|
};
|
package/test/encode-exit.test.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import test from "node:test";
|
|
11
11
|
|
|
12
|
-
import { ENCODE_EXIT, classifyEncodeExit } from "../services/encode-exit.js";
|
|
12
|
+
import { ENCODE_EXIT, classifyEncodeExit } from "../services/encode/encode-exit.js";
|
|
13
13
|
|
|
14
14
|
test("the predecessor a seek kills says nothing about the session", () => {
|
|
15
15
|
// The handler decides "is this exit mine" by comparing against the session's
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Encoders managed to suit whoever is watching, with no viewer reaching
|
|
3
|
+
* the decision.
|
|
4
|
+
*
|
|
5
|
+
* Runs are built by the test, so nothing here spawns ffmpeg. What is exercised
|
|
6
|
+
* is the whole path a request takes: a viewer states a window, a plan is made
|
|
7
|
+
* from the union, runs are started, moved and stopped, and every ending is
|
|
8
|
+
* counted.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import { EncodeRun } from "../services/encode/EncodeRun.js";
|
|
15
|
+
import { ENCODE_EXIT } from "../services/encode/encode-exit.js";
|
|
16
|
+
import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
|
|
17
|
+
import { EncodeOrchestrator } from "../services/orchestrators/EncodeOrchestrator.js";
|
|
18
|
+
|
|
19
|
+
const PICTURE = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
|
|
20
|
+
|
|
21
|
+
class FakeProcess extends EventEmitter {
|
|
22
|
+
constructor() {
|
|
23
|
+
super();
|
|
24
|
+
this.pid = 1;
|
|
25
|
+
this.signals = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
kill(signal) {
|
|
29
|
+
this.signals.push(signal);
|
|
30
|
+
// A real ffmpeg dies a moment later; the test wants the bookkeeping to
|
|
31
|
+
// happen where it can be seen, so the exit is immediate and synchronous.
|
|
32
|
+
this.emit("exit", null, signal);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {{ maxRuns?: number }} [options]
|
|
38
|
+
*/
|
|
39
|
+
function orchestrator({ maxRuns = 2 } = {}) {
|
|
40
|
+
const lines = [];
|
|
41
|
+
const processes = new Map();
|
|
42
|
+
/** @type {EncodeOrchestrator} */
|
|
43
|
+
let made;
|
|
44
|
+
made = new EncodeOrchestrator({
|
|
45
|
+
maxRunsFor: () => maxRuns,
|
|
46
|
+
segmentSeconds: 4,
|
|
47
|
+
restartCostSec: 0.12,
|
|
48
|
+
now: () => 1000,
|
|
49
|
+
logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
|
|
50
|
+
makeRun: ({ address, from, to }) => {
|
|
51
|
+
const process_ = new FakeProcess();
|
|
52
|
+
const run = new EncodeRun({
|
|
53
|
+
address,
|
|
54
|
+
encoder: new SoftwareEncoder(),
|
|
55
|
+
from,
|
|
56
|
+
to,
|
|
57
|
+
buildArgs: () => ["-i", "in", "out"],
|
|
58
|
+
spawn: () => process_,
|
|
59
|
+
logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
|
|
60
|
+
now: () => 1000,
|
|
61
|
+
onEnded: (ended) => made.noteEnded(ended)
|
|
62
|
+
});
|
|
63
|
+
// Filed under the run itself: a run has no name, so a test that has to
|
|
64
|
+
// reach its process asks with the run in hand.
|
|
65
|
+
processes.set(run, process_);
|
|
66
|
+
return run;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
made.setSegmentCount(PICTURE, 1000);
|
|
70
|
+
return { made, lines, processes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("a viewer waiting gets an encoder at what they are waiting for", () => {
|
|
74
|
+
const { made } = orchestrator();
|
|
75
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
|
|
76
|
+
made.reconcile();
|
|
77
|
+
const runs = made.runsOn(PICTURE);
|
|
78
|
+
assert.equal(runs.length, 1);
|
|
79
|
+
assert.equal(runs[0].from, 100, "where the viewer is stopped");
|
|
80
|
+
assert.equal(runs[0].to, 999, "and on to the end of the film, nothing being in the way");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a second viewer at the same place starts nothing more", () => {
|
|
84
|
+
// One encode serves everyone standing in front of it, which is the whole
|
|
85
|
+
// reason the decision is made from a union and not per viewer.
|
|
86
|
+
const { made } = orchestrator();
|
|
87
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
|
|
88
|
+
made.reconcile();
|
|
89
|
+
made.want({ claimant: "two", address: PICTURE, from: 102, to: 132 });
|
|
90
|
+
made.reconcile();
|
|
91
|
+
assert.equal(made.runsOn(PICTURE).length, 1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a second viewer far behind gets an encoder of their own", () => {
|
|
95
|
+
// Nobody is dragged: the run in front keeps its stretch and goes on making it.
|
|
96
|
+
const { made } = orchestrator();
|
|
97
|
+
made.want({ claimant: "one", address: PICTURE, from: 500, to: 530 });
|
|
98
|
+
made.reconcile();
|
|
99
|
+
made.want({ claimant: "two", address: PICTURE, from: 100, to: 130 });
|
|
100
|
+
made.reconcile();
|
|
101
|
+
const spans = made.runsOn(PICTURE).map((run) => [run.from, run.to]);
|
|
102
|
+
assert.equal(spans.length, 2);
|
|
103
|
+
assert.ok(spans.some(([from]) => from === 500), "the one in front is untouched");
|
|
104
|
+
assert.ok(spans.some(([from]) => from === 100), "the one behind got its own");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a machine that can afford one encoder does not start a second", () => {
|
|
108
|
+
const { made } = orchestrator({ maxRuns: 1 });
|
|
109
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
|
|
110
|
+
made.reconcile();
|
|
111
|
+
made.want({ claimant: "two", address: PICTURE, from: 500, to: 530 });
|
|
112
|
+
made.reconcile();
|
|
113
|
+
assert.equal(made.runsOn(PICTURE).length, 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a viewer asking for what is already made starts nothing", () => {
|
|
117
|
+
const { made } = orchestrator();
|
|
118
|
+
made.noteAlreadyMade(PICTURE, [100, 101, 102, 103, 104]);
|
|
119
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 104 });
|
|
120
|
+
made.reconcile();
|
|
121
|
+
assert.equal(made.runsOn(PICTURE).length, 0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("segments left by a previous life of this process are used, not remade", () => {
|
|
125
|
+
// The startup sweep tells the map what survived; from there it is material
|
|
126
|
+
// like any other, whoever made it and whatever became of them.
|
|
127
|
+
const { made } = orchestrator();
|
|
128
|
+
made.noteAlreadyMade(PICTURE, [100, 101, 102]);
|
|
129
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 110 });
|
|
130
|
+
made.reconcile();
|
|
131
|
+
const runs = made.runsOn(PICTURE);
|
|
132
|
+
assert.equal(runs.length, 1);
|
|
133
|
+
assert.equal(runs[0].from, 103, "it starts at the first thing missing");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a viewer who leaves takes the encoder with them", () => {
|
|
137
|
+
const { made } = orchestrator();
|
|
138
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
|
|
139
|
+
made.reconcile();
|
|
140
|
+
assert.equal(made.runsOn(PICTURE).length, 1);
|
|
141
|
+
made.release("one");
|
|
142
|
+
made.reconcile();
|
|
143
|
+
assert.equal(made.runsOn(PICTURE).length, 0);
|
|
144
|
+
assert.equal(made.endings()[ENCODE_EXIT.STOPPED], 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a run that meets material made elsewhere is moved past it", () => {
|
|
148
|
+
const { made } = orchestrator();
|
|
149
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 200 });
|
|
150
|
+
made.reconcile();
|
|
151
|
+
const first = made.runsOn(PICTURE)[0];
|
|
152
|
+
// It has made a few, and meanwhile 105..150 arrived from somewhere else.
|
|
153
|
+
made.noteProduced(PICTURE, 100);
|
|
154
|
+
made.noteProduced(PICTURE, 101);
|
|
155
|
+
for (let index = 102; index <= 150; index += 1) {
|
|
156
|
+
made.coverageOf(PICTURE).markReady(index);
|
|
157
|
+
}
|
|
158
|
+
first.noteSpeed(1);
|
|
159
|
+
made.reconcile();
|
|
160
|
+
const runs = made.runsOn(PICTURE);
|
|
161
|
+
assert.equal(runs.length, 1, "one encoder, moved rather than joined by another");
|
|
162
|
+
assert.equal(runs[0].from, 151);
|
|
163
|
+
assert.notEqual(runs[0], first, "a move is this one ending and another beginning");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("every ending is counted, and our own kill is not counted as normal", () => {
|
|
167
|
+
const { made, processes } = orchestrator();
|
|
168
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
|
|
169
|
+
made.reconcile();
|
|
170
|
+
const run = made.runsOn(PICTURE)[0];
|
|
171
|
+
processes.get(run).emit("exit", 255, null);
|
|
172
|
+
made.release("one");
|
|
173
|
+
made.reconcile();
|
|
174
|
+
const tally = made.endings();
|
|
175
|
+
assert.equal(tally[ENCODE_EXIT.FAILED], 1);
|
|
176
|
+
assert.equal(tally[ENCODE_EXIT.COMPLETE], 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("the line says whether anybody is still waiting", () => {
|
|
180
|
+
// A proxy with encoders running and a viewer stopped at a segment nobody is
|
|
181
|
+
// making is the failure this layer removes; it has to be readable, not
|
|
182
|
+
// inferred.
|
|
183
|
+
const { made } = orchestrator();
|
|
184
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
|
|
185
|
+
made.reconcile();
|
|
186
|
+
assert.match(made.describe(), /waiting=#100/);
|
|
187
|
+
for (let index = 100; index <= 130; index += 1) {
|
|
188
|
+
made.noteProduced(PICTURE, index);
|
|
189
|
+
}
|
|
190
|
+
assert.match(made.describe(), /waiting=nobody/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("nothing wanted anywhere is said plainly", () => {
|
|
194
|
+
const { made } = orchestrator();
|
|
195
|
+
assert.match(made.describe(), /nothing wanted/);
|
|
196
|
+
});
|