@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file The drawing cannot disagree with the table.
|
|
3
3
|
*
|
|
4
|
-
* `docs/encode-run-state.md` is generated from `services/encode-run-state.js`.
|
|
4
|
+
* `docs/encode-run-state.md` is generated from `services/encode/encode-run-state.js`.
|
|
5
5
|
* A picture kept beside the code drifts — this repository has the receipts —
|
|
6
6
|
* so the check is mechanical: regenerate it and compare. A behavioural change
|
|
7
7
|
* to the table that forgets `npm run graph` fails here, in the same commit.
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A run is given a stretch nobody else holds, and stops at the end of it.
|
|
3
|
+
*
|
|
4
|
+
* This is what removes the per-run directories. Runs used to be kept apart by a
|
|
5
|
+
* directory each, because two of them writing one segment name at the same time
|
|
6
|
+
* produce a file belonging to neither — which was also the only reason a restart
|
|
7
|
+
* ever had to wait for its predecessor to die. Intervals remove that by
|
|
8
|
+
* construction, so one flat directory per output is correct.
|
|
9
|
+
*
|
|
10
|
+
* The other half is the argument that states the end, and WHICH argument it is
|
|
11
|
+
* belongs to the branch — measured, not reasoned:
|
|
12
|
+
* `research/encoder-layer-2026-09-04.md` §11.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
18
|
+
import { startRunOn } from "./helpers/encode-run.js";
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
23
|
+
import { SegmentStore } from "../services/encode/SegmentStore.js";
|
|
24
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
25
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
26
|
+
import { viewerOf } from "../services/viewer/Viewer.js";
|
|
27
|
+
|
|
28
|
+
const KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @returns {{ manager: HlsSessionManager, store: SegmentStore, root: string, dirPath: string }}
|
|
32
|
+
*/
|
|
33
|
+
function managerWithAnEmptyOutput() {
|
|
34
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "run-intervals-"));
|
|
35
|
+
const store = new SegmentStore({ root });
|
|
36
|
+
const manager = new HlsSessionManager({
|
|
37
|
+
enabled: true,
|
|
38
|
+
ffmpegBin: "ffmpeg",
|
|
39
|
+
localBindHost: "127.0.0.1",
|
|
40
|
+
localPort: 9090,
|
|
41
|
+
segmentStore: store
|
|
42
|
+
});
|
|
43
|
+
const dirPath = store.directoryFor(KEY);
|
|
44
|
+
store.useFormat(KEY, fmp4Format);
|
|
45
|
+
return { manager, store, root, dirPath };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} params
|
|
50
|
+
* @returns {object}
|
|
51
|
+
*/
|
|
52
|
+
function sessionOn({ id, dirPath, segmentCount = 100, runState = null, encodeStartIndex = 0, runEndIndex = -1 }) {
|
|
53
|
+
const session = {
|
|
54
|
+
id,
|
|
55
|
+
outputKey: KEY,
|
|
56
|
+
dirPath,
|
|
57
|
+
state: "ready",
|
|
58
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
|
|
59
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
60
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
61
|
+
get inputFile() { return this.file; },
|
|
62
|
+
get audioFile() { return this.file; },
|
|
63
|
+
segmentFormat: fmp4Format,
|
|
64
|
+
segmentCount,
|
|
65
|
+
runs: new Set(),
|
|
66
|
+
consumers: new Set(),
|
|
67
|
+
lastAccessedAt: Date.now()
|
|
68
|
+
};
|
|
69
|
+
if (runState !== null) {
|
|
70
|
+
const run = startRunOn(session, { from: encodeStartIndex, to: runEndIndex });
|
|
71
|
+
if (runState === "ENDED_FAILED") {
|
|
72
|
+
// A run that failed, said so, and left what it had made behind.
|
|
73
|
+
run.process?.exit(255, null);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} dirPath
|
|
81
|
+
* @param {number[]} indexes
|
|
82
|
+
*/
|
|
83
|
+
function alreadyMade(dirPath, indexes) {
|
|
84
|
+
for (const index of indexes) {
|
|
85
|
+
writeFileSync(path.join(dirPath, fmp4Format.segmentFileName(index)), Buffer.alloc(16, 1));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test("with nothing made, a run gets everything from where it was asked", (t) => {
|
|
90
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
91
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
92
|
+
|
|
93
|
+
const session = sessionOn({ id: "s", dirPath });
|
|
94
|
+
// -1 for the end means "to the end of the film", which is what every run had
|
|
95
|
+
// before ends existed — and the only case where that is still right.
|
|
96
|
+
assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: -1 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a run stops before material that is already made", (t) => {
|
|
100
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
101
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
102
|
+
|
|
103
|
+
// 10..20 on the disk. Only 10..19 are PROVEN — the highest has no successor,
|
|
104
|
+
// so nothing shows whether it was closed or was being written when its run
|
|
105
|
+
// died.
|
|
106
|
+
alreadyMade(dirPath, [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
|
|
107
|
+
const session = sessionOn({ id: "s", dirPath });
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: 9 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a run asked to start inside made material is moved forward to the gap", (t) => {
|
|
113
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
114
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
115
|
+
|
|
116
|
+
alreadyMade(dirPath, [10, 11, 12, 13, 14, 15]);
|
|
117
|
+
const session = sessionOn({ id: "s", dirPath });
|
|
118
|
+
|
|
119
|
+
// Asked for 11, which is made, and so is everything to 14. The first thing
|
|
120
|
+
// worth encoding is 15 — unproven, because 16 does not exist.
|
|
121
|
+
assert.deepEqual(manager.planRunInterval(session, 11), { from: 15, to: -1 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a run stops before a stretch another live run was given", (t) => {
|
|
125
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
126
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
127
|
+
|
|
128
|
+
const other = sessionOn({
|
|
129
|
+
id: "other",
|
|
130
|
+
dirPath,
|
|
131
|
+
runState: "PRODUCING",
|
|
132
|
+
encodeStartIndex: 40,
|
|
133
|
+
runEndIndex: 60
|
|
134
|
+
});
|
|
135
|
+
manager.sessionsById.set(other.id, other);
|
|
136
|
+
const session = sessionOn({ id: "s", dirPath });
|
|
137
|
+
|
|
138
|
+
// Two viewers of one film, one already encoding 40..60. The second run takes
|
|
139
|
+
// what is free in front of it and stops where the other one begins, so
|
|
140
|
+
// neither ever writes a name the other wants.
|
|
141
|
+
assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: 39 });
|
|
142
|
+
assert.deepEqual(manager.planRunInterval(session, 45), { from: 61, to: -1 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a run that has died holds nothing back", (t) => {
|
|
146
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
147
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
148
|
+
|
|
149
|
+
// The same stretch, but the process cannot be signalled — it has ended. What
|
|
150
|
+
// it did not finish is free again, without anything having to release it.
|
|
151
|
+
const dead = sessionOn({
|
|
152
|
+
id: "dead",
|
|
153
|
+
dirPath,
|
|
154
|
+
runState: "ENDED_FAILED",
|
|
155
|
+
encodeStartIndex: 40,
|
|
156
|
+
runEndIndex: 60
|
|
157
|
+
});
|
|
158
|
+
manager.sessionsById.set(dead.id, dead);
|
|
159
|
+
const session = sessionOn({ id: "s", dirPath });
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: -1 });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("nothing left to make is answered with nothing, not with a run", (t) => {
|
|
165
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
166
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
167
|
+
|
|
168
|
+
const session = sessionOn({ id: "s", dirPath, segmentCount: 5 });
|
|
169
|
+
alreadyMade(dirPath, [0, 1, 2, 3, 4]);
|
|
170
|
+
const other = sessionOn({
|
|
171
|
+
id: "other",
|
|
172
|
+
dirPath,
|
|
173
|
+
segmentCount: 5,
|
|
174
|
+
runState: "PRODUCING",
|
|
175
|
+
encodeStartIndex: 4,
|
|
176
|
+
runEndIndex: 4
|
|
177
|
+
});
|
|
178
|
+
manager.sessionsById.set(other.id, other);
|
|
179
|
+
|
|
180
|
+
// Starting an encoder here would only repeat somebody else's work, which is
|
|
181
|
+
// what three ffmpeg processes making one identical picture cost on a CM4.
|
|
182
|
+
assert.equal(manager.planRunInterval(session, 0), null);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a session with no address keeps the old shape: start here, no end", (t) => {
|
|
186
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
187
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
188
|
+
|
|
189
|
+
const session = sessionOn({ id: "s", dirPath });
|
|
190
|
+
session.outputKey = "";
|
|
191
|
+
|
|
192
|
+
assert.deepEqual(manager.planRunInterval(session, 7), { from: 7, to: -1 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a run without an end does not claim the whole film away from a later viewer", (t) => {
|
|
196
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
197
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
198
|
+
|
|
199
|
+
// The first viewer's run, at the beginning, with no end — which is every run
|
|
200
|
+
// whose output was empty when it started.
|
|
201
|
+
const first = sessionOn({
|
|
202
|
+
id: "first",
|
|
203
|
+
dirPath,
|
|
204
|
+
segmentCount: 1000,
|
|
205
|
+
runState: "PRODUCING",
|
|
206
|
+
encodeStartIndex: 0,
|
|
207
|
+
runEndIndex: -1
|
|
208
|
+
});
|
|
209
|
+
manager.sessionsById.set(first.id, first);
|
|
210
|
+
const second = sessionOn({ id: "second", dirPath, segmentCount: 1000 });
|
|
211
|
+
|
|
212
|
+
// A second viewer opens the same film in the middle. Counting the first run's
|
|
213
|
+
// claim as the whole film would leave them with no encoder at all, waiting for
|
|
214
|
+
// it to encode its way there — an hour on a long film. It only claims as far
|
|
215
|
+
// as it will actually get, which is its head plus the look-ahead.
|
|
216
|
+
const planned = manager.planRunInterval(second, 500);
|
|
217
|
+
assert.notEqual(planned, null);
|
|
218
|
+
assert.equal(planned.from, 500);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a run stops when it reaches a stretch another run was given", async (t) => {
|
|
222
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
223
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
224
|
+
|
|
225
|
+
const stopped = [];
|
|
226
|
+
const ahead = sessionOn({
|
|
227
|
+
id: "ahead",
|
|
228
|
+
dirPath,
|
|
229
|
+
runState: "PRODUCING",
|
|
230
|
+
encodeStartIndex: 500,
|
|
231
|
+
runEndIndex: 600
|
|
232
|
+
});
|
|
233
|
+
manager.sessionsById.set(ahead.id, ahead);
|
|
234
|
+
const behind = sessionOn({
|
|
235
|
+
id: "behind",
|
|
236
|
+
dirPath,
|
|
237
|
+
runState: "PRODUCING",
|
|
238
|
+
encodeStartIndex: 0,
|
|
239
|
+
runEndIndex: -1
|
|
240
|
+
});
|
|
241
|
+
manager.sessionsById.set(behind.id, behind);
|
|
242
|
+
|
|
243
|
+
// Its own end was set from the gaps of the moment it began, and the viewer who
|
|
244
|
+
// opened the film further on was not in that picture. Walking into their
|
|
245
|
+
// stretch means writing names they are writing.
|
|
246
|
+
const behindRun = [...behind.runs][0];
|
|
247
|
+
const aheadRun = [...ahead.runs][0];
|
|
248
|
+
assert.equal(manager.runMakingSegment(behind, 550, behindRun), aheadRun);
|
|
249
|
+
assert.equal(manager.runMakingSegment(behind, 499, behindRun), null);
|
|
250
|
+
assert.equal(
|
|
251
|
+
manager.runMakingSegment(ahead, 550, aheadRun),
|
|
252
|
+
null,
|
|
253
|
+
"a run without an end owns only as far as it will get, not the rest of the film"
|
|
254
|
+
);
|
|
255
|
+
void stopped;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a seek backwards does not drag the picture away from a viewer watching ahead", (t) => {
|
|
259
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
260
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
261
|
+
|
|
262
|
+
const shared = sessionOn({
|
|
263
|
+
id: "shared",
|
|
264
|
+
dirPath,
|
|
265
|
+
segmentCount: 1000,
|
|
266
|
+
runState: "PRODUCING",
|
|
267
|
+
encodeStartIndex: 200,
|
|
268
|
+
runEndIndex: -1
|
|
269
|
+
});
|
|
270
|
+
shared.sourceKey = "torrent:abc";
|
|
271
|
+
shared.fileIndex = 0;
|
|
272
|
+
shared.timeline = new Timeline({ boundaries: Array.from({ length: 1001 }, (_, index) => index * 4), cutGrid: "uniform" });
|
|
273
|
+
shared.acquireSource = null;
|
|
274
|
+
shared.progress = { processedSeconds: 900, startPositionSeconds: 800 };
|
|
275
|
+
manager.sessionsById.set(shared.id, shared);
|
|
276
|
+
|
|
277
|
+
// One viewer is at segment 250 and being served. The other jumps back an hour.
|
|
278
|
+
viewerOf(shared, "ahead").head = { segment: 250, seconds: 1000, at: Date.now() };
|
|
279
|
+
viewerOf(shared, "back").head = { segment: 250, seconds: 1000, at: Date.now() };
|
|
280
|
+
|
|
281
|
+
const servingAhead = [...shared.runs][0];
|
|
282
|
+
const startedAt = servingAhead.from;
|
|
283
|
+
manager.requestSeek("shared", 40, "back");
|
|
284
|
+
|
|
285
|
+
assert.equal(
|
|
286
|
+
servingAhead.from,
|
|
287
|
+
startedAt,
|
|
288
|
+
"the run serving the viewer in front is left exactly where it was"
|
|
289
|
+
);
|
|
290
|
+
assert.ok(shared.runs.has(servingAhead), "and it is not stopped");
|
|
291
|
+
// A run start claims its attempt before it awaits anything, so the assertion
|
|
292
|
+
// holds without the test needing an ffmpeg. 40 s is segment #10 on this grid.
|
|
293
|
+
assert.equal(
|
|
294
|
+
shared.pendingRun?.startIndex,
|
|
295
|
+
10,
|
|
296
|
+
"and the one who jumped is given a run of their own, there"
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("a seek backwards with nobody else watching still moves the run", (t) => {
|
|
301
|
+
const { manager, root, dirPath } = managerWithAnEmptyOutput();
|
|
302
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
303
|
+
|
|
304
|
+
let created = 0;
|
|
305
|
+
manager.createOrGetSession = async () => {
|
|
306
|
+
created += 1;
|
|
307
|
+
return null;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const alone = sessionOn({
|
|
311
|
+
id: "alone",
|
|
312
|
+
dirPath,
|
|
313
|
+
segmentCount: 1000,
|
|
314
|
+
runState: "PRODUCING",
|
|
315
|
+
encodeStartIndex: 200,
|
|
316
|
+
runEndIndex: -1
|
|
317
|
+
});
|
|
318
|
+
alone.timeline = new Timeline({ boundaries: Array.from({ length: 1001 }, (_, index) => index * 4), cutGrid: "uniform" });
|
|
319
|
+
alone.progress = { processedSeconds: 900, startPositionSeconds: 800 };
|
|
320
|
+
manager.sessionsById.set(alone.id, alone);
|
|
321
|
+
viewerOf(alone, "only").head = { segment: 250, seconds: 1000, at: Date.now() };
|
|
322
|
+
|
|
323
|
+
manager.requestSeek("alone", 40, "only");
|
|
324
|
+
|
|
325
|
+
// Nobody is left behind, so a second run would be an encoder for one viewer
|
|
326
|
+
// who is not there any more.
|
|
327
|
+
assert.equal(created, 0);
|
|
328
|
+
assert.equal(alone.seekTarget !== undefined, true, "the run itself is repositioned, as before");
|
|
329
|
+
});
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import assert from "node:assert/strict";
|
|
22
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
22
23
|
import test from "node:test";
|
|
23
24
|
|
|
24
25
|
import { HlsSessionManager, describeGridDrift, segmentCutTimesFrom } from "../services/hls-session-manager.js";
|
|
@@ -40,8 +41,7 @@ function sessionWithDriftedGrid() {
|
|
|
40
41
|
});
|
|
41
42
|
const session = {
|
|
42
43
|
id: "picture",
|
|
43
|
-
|
|
44
|
-
publishedBoundaries: [...PUBLISHED]
|
|
44
|
+
timeline: new Timeline({ boundaries: [...CORRECTED], published: [...PUBLISHED], cutGrid: "uniform" })
|
|
45
45
|
};
|
|
46
46
|
return { manager, session };
|
|
47
47
|
}
|
|
@@ -49,7 +49,7 @@ function sessionWithDriftedGrid() {
|
|
|
49
49
|
test("a run starts at the time the player was told, not at the corrected one", () => {
|
|
50
50
|
const { manager, session } = sessionWithDriftedGrid();
|
|
51
51
|
assert.equal(manager.runStartTimeFor(session, 2), 16.684);
|
|
52
|
-
assert.notEqual(manager.runStartTimeFor(session, 2), session.
|
|
52
|
+
assert.notEqual(manager.runStartTimeFor(session, 2), session.timeline.boundaries[2]);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test("position and cut list come from the same table", () => {
|
|
@@ -65,7 +65,7 @@ test("position and cut list come from the same table", () => {
|
|
|
65
65
|
|
|
66
66
|
test("a session that published no grid positions on the live one", () => {
|
|
67
67
|
const { manager } = sessionWithDriftedGrid();
|
|
68
|
-
const session = { id: "no-playlist",
|
|
68
|
+
const session = { id: "no-playlist", timeline: new Timeline({ boundaries: [...CORRECTED], published: [], cutGrid: "uniform" }) };
|
|
69
69
|
assert.equal(manager.runStartTimeFor(session, 2), CORRECTED[2]);
|
|
70
70
|
});
|
|
71
71
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
17
18
|
import test from "node:test";
|
|
18
19
|
|
|
19
20
|
import { seekLandingOffsetFor } from "../services/hls-session-manager.js";
|
|
@@ -21,26 +22,26 @@ import { seekLandingOffsetFor } from "../services/hls-session-manager.js";
|
|
|
21
22
|
const OFFSET = 3 / 23;
|
|
22
23
|
|
|
23
24
|
test("a copied picture is asked for one heuristic later than the keyframe", () => {
|
|
24
|
-
const session = { transcodeVideo: false, keyframeTimes: [0, 2.002, 4.004, 6.006] };
|
|
25
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004, 6.006] }) };
|
|
25
26
|
assert.equal(seekLandingOffsetFor(session, 2.002), OFFSET);
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
test("a re-encode is asked for exactly what it should produce", () => {
|
|
29
30
|
// It decodes from the keyframe and discards frames up to the requested time,
|
|
30
31
|
// so pushing the request later would start its output late.
|
|
31
|
-
const session = { transcodeVideo: true, keyframeTimes: [0, 2.002, 4.004] };
|
|
32
|
+
const session = { transcodeVideo: true, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
|
|
32
33
|
assert.equal(seekLandingOffsetFor(session, 2.002), 0);
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
test("the offset never reaches the next keyframe", () => {
|
|
36
37
|
// Keyframes 0.1 s apart: half of that is the most that can be added without
|
|
37
38
|
// risking a landing on the NEXT one where the heuristic does not fire.
|
|
38
|
-
const session = { transcodeVideo: false, keyframeTimes: [0, 0.1, 0.2, 0.3] };
|
|
39
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2, 0.3] }) };
|
|
39
40
|
assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
test("the last keyframe has nothing after it to collide with", () => {
|
|
43
|
-
const session = { transcodeVideo: false, keyframeTimes: [0, 2.002, 4.004] };
|
|
44
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
|
|
44
45
|
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
|
|
45
46
|
});
|
|
46
47
|
|
|
@@ -57,8 +58,7 @@ test("a grid whose times are approximate is asked for that much later again", ()
|
|
|
57
58
|
// the one before that, which is the fault this offset exists for.
|
|
58
59
|
const session = {
|
|
59
60
|
transcodeVideo: false,
|
|
60
|
-
keyframeTolerance: 0.04
|
|
61
|
-
keyframeTimes: [0, 4.004, 8.008, 12.012]
|
|
61
|
+
file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0.04 })
|
|
62
62
|
};
|
|
63
63
|
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET + 0.04);
|
|
64
64
|
});
|
|
@@ -66,11 +66,11 @@ test("a grid whose times are approximate is asked for that much later again", ()
|
|
|
66
66
|
test("an exact grid claims no tolerance", () => {
|
|
67
67
|
// Matroska and MP4 state instants outright — measured the same day, nine
|
|
68
68
|
// files and 11 665 keyframes with not one disagreement.
|
|
69
|
-
const session = { transcodeVideo: false,
|
|
69
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008], keyframeTolerance: 0 }) };
|
|
70
70
|
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
test("the bound still holds once a tolerance is added", () => {
|
|
74
|
-
const session = { transcodeVideo: false,
|
|
74
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2], keyframeTolerance: 1 }) };
|
|
75
75
|
assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
|
|
76
76
|
});
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import test from "node:test";
|
|
17
17
|
import assert from "node:assert/strict";
|
|
18
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
19
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
18
20
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
19
21
|
import os from "node:os";
|
|
20
22
|
import path from "node:path";
|
|
21
23
|
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
24
|
+
import { viewerOf } from "../services/viewer/Viewer.js";
|
|
22
25
|
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
23
26
|
|
|
24
27
|
const SESSION_ID = "aaaaaaaa-1111-2222-3333-444444444444";
|
|
@@ -42,19 +45,28 @@ async function managerAfterSeek() {
|
|
|
42
45
|
manager.sessionsById.set(SESSION_ID, {
|
|
43
46
|
id: SESSION_ID,
|
|
44
47
|
dirPath,
|
|
48
|
+
// Where this file is cut, held by the file. A fixture that stated it
|
|
49
|
+
// on the session was describing what production no longer does.
|
|
50
|
+
timeline: new Timeline({
|
|
51
|
+
boundaries: Array.from({ length: 400 }, (_, index) => index * SEGMENT_SECONDS),
|
|
52
|
+
cutGrid: "uniform"
|
|
53
|
+
}),
|
|
45
54
|
state: "ready",
|
|
46
|
-
|
|
55
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
|
|
56
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
57
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
58
|
+
get inputFile() { return this.file; },
|
|
59
|
+
get audioFile() { return this.file; },
|
|
47
60
|
startedAt: Date.now(),
|
|
48
61
|
lastAccessedAt: Date.now(),
|
|
49
62
|
consumers: new Set(),
|
|
50
63
|
segmentFormat: fmp4Format,
|
|
51
64
|
transcodeVideo: false,
|
|
52
65
|
useSyntheticPlaylist: true,
|
|
53
|
-
segmentBoundaries: Array.from({ length: 400 }, (_, index) => index * SEGMENT_SECONDS),
|
|
54
66
|
segmentCount: 399,
|
|
55
67
|
encodeStartIndex: SEGMENT_AT_SEEK,
|
|
56
68
|
waitEpoch: 1,
|
|
57
|
-
|
|
69
|
+
furthestViewerSeconds: SEEK_TO_SECONDS,
|
|
58
70
|
runState: "PRODUCING"
|
|
59
71
|
});
|
|
60
72
|
return { manager, dirPath };
|
|
@@ -128,16 +140,16 @@ test("a request as deep as the cushion the browser is told to hold is still want
|
|
|
128
140
|
test("the viewer who made the request is the one it is judged against", async () => {
|
|
129
141
|
const { manager, dirPath } = await managerAfterSeek();
|
|
130
142
|
const session = manager.sessionsById.get(SESSION_ID);
|
|
131
|
-
session.
|
|
143
|
+
session.viewers = new Map();
|
|
132
144
|
// Two people watching one copied picture. The shared position belongs to the
|
|
133
145
|
// one in front — it is the furthest segment anybody asked for — and the one
|
|
134
146
|
// behind is a hundred segments back, waiting for a segment there.
|
|
135
147
|
const behind = SEGMENT_AT_SEEK - 100;
|
|
136
|
-
session
|
|
148
|
+
viewerOf(session, "behind").head = {
|
|
137
149
|
segment: behind,
|
|
138
150
|
seconds: behind * SEGMENT_SECONDS,
|
|
139
151
|
at: Date.now()
|
|
140
|
-
}
|
|
152
|
+
};
|
|
141
153
|
|
|
142
154
|
assert.equal(
|
|
143
155
|
manager.requestStillWanted(SESSION_ID, `segment-${String(behind).padStart(5, "0")}.mp4`, "behind"),
|
|
@@ -156,13 +168,13 @@ test("the viewer who made the request is the one it is judged against", async ()
|
|
|
156
168
|
test("a seek moves the seeking viewer's own head, and nobody else's", async () => {
|
|
157
169
|
const { manager, dirPath } = await managerAfterSeek();
|
|
158
170
|
const session = manager.sessionsById.get(SESSION_ID);
|
|
159
|
-
session.
|
|
171
|
+
session.viewers = new Map();
|
|
160
172
|
const staying = SEGMENT_AT_SEEK - 40;
|
|
161
|
-
session
|
|
173
|
+
viewerOf(session, "staying").head = {
|
|
162
174
|
segment: staying,
|
|
163
175
|
seconds: staying * SEGMENT_SECONDS,
|
|
164
176
|
at: Date.now()
|
|
165
|
-
}
|
|
177
|
+
};
|
|
166
178
|
|
|
167
179
|
const jumpTo = 120;
|
|
168
180
|
manager.requestSeek(SESSION_ID, jumpTo, "jumping");
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What viewers want of an output, stated once each and read as a union.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { SegmentDemand } from "../services/encode/SegmentDemand.js";
|
|
8
|
+
|
|
9
|
+
const PICTURE = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
|
|
10
|
+
const SOUND = "torrent:abc:fmt=fmp4:grid=kf@0:audio-only:a=0/1/copy";
|
|
11
|
+
|
|
12
|
+
test("a viewer restating a window replaces it rather than adding to it", () => {
|
|
13
|
+
// A player restates its window every few seconds. Accumulated, the demand
|
|
14
|
+
// would grow to the whole film within a minute.
|
|
15
|
+
const demand = new SegmentDemand();
|
|
16
|
+
demand.state({ claimant: "one", address: PICTURE, from: 0, to: 10, statedAt: 1 });
|
|
17
|
+
demand.state({ claimant: "one", address: PICTURE, from: 30, to: 40, statedAt: 2 });
|
|
18
|
+
assert.deepEqual(demand.spanOn(PICTURE), { from: 30, to: 40 });
|
|
19
|
+
assert.equal(demand.stats().windows, 1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("one viewer states a window per output, and both stand", () => {
|
|
23
|
+
const demand = new SegmentDemand();
|
|
24
|
+
demand.state({ claimant: "one", address: PICTURE, from: 0, to: 10, statedAt: 1 });
|
|
25
|
+
demand.state({ claimant: "one", address: SOUND, from: 0, to: 10, statedAt: 1 });
|
|
26
|
+
assert.equal(demand.stats().windows, 2);
|
|
27
|
+
assert.deepEqual(demand.addresses().sort(), [SOUND, PICTURE].sort());
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("two viewers of one output are a union, not a sum", () => {
|
|
31
|
+
// Two viewers seconds apart want mostly the same segments; counted twice, a
|
|
32
|
+
// stretch would look twice as wanted as it is.
|
|
33
|
+
const demand = new SegmentDemand();
|
|
34
|
+
demand.state({ claimant: "one", address: PICTURE, from: 10, to: 14, statedAt: 1 });
|
|
35
|
+
demand.state({ claimant: "two", address: PICTURE, from: 12, to: 16, statedAt: 1 });
|
|
36
|
+
assert.deepEqual(demand.wantedOn(PICTURE), [10, 11, 12, 13, 14, 15, 16]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("two viewers far apart make a span that covers the ground between them", () => {
|
|
40
|
+
// Not so it is all made — so that a search for a gap considers all of it.
|
|
41
|
+
const demand = new SegmentDemand();
|
|
42
|
+
demand.state({ claimant: "one", address: PICTURE, from: 0, to: 5, statedAt: 1 });
|
|
43
|
+
demand.state({ claimant: "two", address: PICTURE, from: 900, to: 905, statedAt: 1 });
|
|
44
|
+
assert.deepEqual(demand.spanOn(PICTURE), { from: 0, to: 905 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a viewer who leaves takes every window they stated", () => {
|
|
48
|
+
const demand = new SegmentDemand();
|
|
49
|
+
demand.state({ claimant: "one", address: PICTURE, from: 0, to: 5, statedAt: 1 });
|
|
50
|
+
demand.state({ claimant: "one", address: SOUND, from: 0, to: 5, statedAt: 1 });
|
|
51
|
+
demand.state({ claimant: "two", address: PICTURE, from: 0, to: 5, statedAt: 1 });
|
|
52
|
+
assert.equal(demand.forget("one"), 2);
|
|
53
|
+
assert.equal(demand.stats().claimants, 1);
|
|
54
|
+
assert.equal(demand.addresses().length, 1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("expiry is carried out here and decided elsewhere", () => {
|
|
58
|
+
// The register has no clock on purpose: the rule that says how stale is too
|
|
59
|
+
// stale lives with whoever measures it, and this stays exercisable with
|
|
60
|
+
// numbers alone.
|
|
61
|
+
const demand = new SegmentDemand();
|
|
62
|
+
demand.state({ claimant: "one", address: PICTURE, from: 0, to: 5, statedAt: 1000 });
|
|
63
|
+
demand.state({ claimant: "two", address: PICTURE, from: 0, to: 5, statedAt: 9000 });
|
|
64
|
+
const dropped = demand.forgetStatedBefore(5000);
|
|
65
|
+
assert.equal(dropped.length, 1);
|
|
66
|
+
assert.equal(dropped[0].claimant, "one");
|
|
67
|
+
assert.equal(demand.stats().windows, 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a window that is not a window is refused rather than stored", () => {
|
|
71
|
+
const demand = new SegmentDemand();
|
|
72
|
+
assert.equal(demand.state({ claimant: "one", address: PICTURE, from: 5, to: 1, statedAt: 1 }), null);
|
|
73
|
+
assert.equal(demand.state({ claimant: "", address: PICTURE, from: 0, to: 1, statedAt: 1 }), null);
|
|
74
|
+
assert.equal(demand.state({ claimant: "one", address: "", from: 0, to: 1, statedAt: 1 }), null);
|
|
75
|
+
assert.equal(demand.stats().windows, 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("nothing wanted on an output answers null rather than an empty span", () => {
|
|
79
|
+
const demand = new SegmentDemand();
|
|
80
|
+
assert.equal(demand.spanOn(PICTURE), null);
|
|
81
|
+
assert.deepEqual(demand.wantedOn(PICTURE), []);
|
|
82
|
+
});
|