@torrent-tv/proxy 2.80.18 → 2.81.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 +22 -0
- package/docs/encode-architecture.md +51 -2
- package/package.json +1 -1
- package/research/double-spawn-2026-09-10.md +171 -0
- package/services/disk/DiskSpace.js +146 -0
- package/services/disk/wire.js +60 -0
- package/services/encode/EncodeRun.js +37 -9
- package/services/encode/SegmentStore.js +284 -232
- package/services/encode/run-command.js +16 -1
- package/services/hls-session-manager.js +31 -128
- package/services/orchestrators/EncodeOrchestrator.js +52 -38
- package/services/piece-store/allowance.js +107 -0
- package/services/piece-store/piece-disk-store.js +365 -0
- package/services/piece-store/shared-piece-store.js +1549 -1535
- package/services/segment-formats/fmp4.js +54 -0
- package/services/segment-formats/mpegts.js +54 -0
- package/services/torrent-worker/client.js +32 -0
- package/services/torrent-worker/pool-adapter.js +15 -0
- package/services/torrent-worker/protocol.js +9 -0
- package/services/torrent-worker/worker.js +8 -1
- package/services/viewer/positions.js +48 -0
- package/test/audio-inventory.test.js +176 -176
- package/test/auto-quality-step.test.js +514 -514
- package/test/concurrent-cost.test.js +138 -138
- package/test/coverage-follows-the-disk.test.js +191 -187
- package/test/coverage-map.test.js +195 -195
- package/test/declared-tracks.test.js +35 -35
- package/test/disk-space.test.js +138 -0
- package/test/encode-orchestrator.test.js +0 -3
- package/test/encode-run.test.js +5 -12
- package/test/held-request-width.test.js +155 -155
- package/test/helpers/encode-run.js +2 -2
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +281 -220
- package/test/orchestrator-wired.test.js +199 -195
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +267 -0
- package/test/piece-reader.test.js +4 -4
- package/test/piece-store-eviction.test.js +17 -17
- package/test/piece-store-reservations.test.js +20 -1
- package/test/piece-store-slow-disk.test.js +16 -1
- package/test/produced-copy-choice.test.js +258 -358
- package/test/read-window.test.js +6 -6
- package/test/run-intervals.test.js +100 -100
- package/test/seek-landing.test.js +109 -109
- package/test/segment-serve-wiring.test.js +8 -9
- package/test/segment-store-eviction.test.js +232 -0
- package/test/segment-store.test.js +238 -216
- package/test/segments-are-shared.test.js +1 -1
- package/test/shared-piece-store.test.js +12 -12
- package/test/sidecar-naming.test.js +142 -142
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-walk.test.js +369 -369
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-track-numbering.test.js +370 -370
- package/test/tail-duplication.test.js +167 -167
- package/test/tracks-begin-together.test.js +195 -195
- package/test/two-viewers-one-picture.test.js +374 -374
- package/test/video-facts.test.js +102 -102
- package/test/wedge-certainty.test.js +131 -131
- package/services/encode/open-piece.js +0 -135
- package/services/piece-store/disk-tier.js +0 -151
- package/test/open-piece.test.js +0 -152
package/test/read-window.test.js
CHANGED
|
@@ -154,7 +154,7 @@ test("a read that is not stopped claims nothing", async () => {
|
|
|
154
154
|
await iterator.return();
|
|
155
155
|
} finally {
|
|
156
156
|
store.destroy(() => undefined);
|
|
157
|
-
await fs.rm(directory, { recursive: true, force: true });
|
|
157
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
158
158
|
}
|
|
159
159
|
});
|
|
160
160
|
|
|
@@ -165,7 +165,7 @@ test("a finished read leaves nothing selected", async () => {
|
|
|
165
165
|
assert.deepEqual(torrent.held, [], "the read kept its claim after finishing");
|
|
166
166
|
} finally {
|
|
167
167
|
store.destroy(() => undefined);
|
|
168
|
-
await fs.rm(directory, { recursive: true, force: true });
|
|
168
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
171
|
|
|
@@ -188,7 +188,7 @@ test("an abandoned read leaves nothing selected", async () => {
|
|
|
188
188
|
assert.deepEqual(torrent.held, [], "an abandoned read kept its claim forever");
|
|
189
189
|
} finally {
|
|
190
190
|
store.destroy(() => undefined);
|
|
191
|
-
await fs.rm(directory, { recursive: true, force: true });
|
|
191
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
192
192
|
}
|
|
193
193
|
});
|
|
194
194
|
|
|
@@ -238,7 +238,7 @@ test("two stopped readers add up, and one leaving takes only its own", async ()
|
|
|
238
238
|
assert.deepEqual(torrent.held, [], "the last reader left something behind");
|
|
239
239
|
} finally {
|
|
240
240
|
store.destroy(() => undefined);
|
|
241
|
-
await fs.rm(directory, { recursive: true, force: true });
|
|
241
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
242
242
|
}
|
|
243
243
|
});
|
|
244
244
|
|
|
@@ -275,7 +275,7 @@ test("criticality marks the window being waited for, not the whole range", async
|
|
|
275
275
|
await iterator.return();
|
|
276
276
|
} finally {
|
|
277
277
|
store.destroy(() => undefined);
|
|
278
|
-
await fs.rm(directory, { recursive: true, force: true });
|
|
278
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
279
279
|
}
|
|
280
280
|
});
|
|
281
281
|
|
|
@@ -306,7 +306,7 @@ test("a reader that is abandoned mid-fragment does not keep the piece pinned", a
|
|
|
306
306
|
);
|
|
307
307
|
} finally {
|
|
308
308
|
store.destroy(() => undefined);
|
|
309
|
-
await fs.rm(directory, { recursive: true, force: true });
|
|
309
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
310
310
|
}
|
|
311
311
|
});
|
|
312
312
|
|
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file How far a run may work, and who decides it.
|
|
3
|
-
*
|
|
4
|
-
* These cases used to be asked of `planRunInterval`, a second authority inside
|
|
5
|
-
* the session manager that answered by its own rules: it walked the whole track
|
|
6
|
-
* for the first free number, MOVED the start there, and counted every live run
|
|
7
|
-
* as claiming up to `head + look-ahead`. It contradicted the plan directly, and
|
|
8
|
-
* the two together produced the field oscillation of 2026-09-05 — the plan
|
|
9
|
-
* commanded a start at #46, this moved it to #78, the plan killed the run for
|
|
10
|
-
* standing outside the window it had asked for, and the same start was
|
|
11
|
-
* commanded again, 350-700ms per cycle, no segment ever produced, the viewer's
|
|
12
|
-
* picture stopped for 125 seconds.
|
|
13
|
-
*
|
|
14
|
-
* It is gone. WHERE a run starts is the plan's decision and nothing moves it;
|
|
15
|
-
* HOW FAR it may work is a fact of the one coverage map, and that is what these
|
|
16
|
-
* cases now ask. The map is the same object the plan reads, so there is no
|
|
17
|
-
* second set of rules for the two to disagree about.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import test from "node:test";
|
|
21
|
-
import assert from "node:assert/strict";
|
|
22
|
-
import { CoverageMap } from "../services/encode/CoverageMap.js";
|
|
23
|
-
|
|
24
|
-
/** A stand-in for a run: the map identifies one by being it. */
|
|
25
|
-
function run(name) {
|
|
26
|
-
return { name };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
test("with nothing made, a run gets everything from where it was asked", () => {
|
|
30
|
-
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
31
|
-
|
|
32
|
-
assert.equal(coverage.freeRunFrom(0), 100, "the whole track is free");
|
|
33
|
-
assert.equal(coverage.firstGapFrom(0), 0);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("a run stops before material that is already made", () => {
|
|
37
|
-
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
38
|
-
coverage.setReady([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]);
|
|
39
|
-
|
|
40
|
-
assert.equal(coverage.freeRunFrom(0), 10, "0..9, and it stops where 10 begins");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("a run stops before a stretch another live run was given", () => {
|
|
44
|
-
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
45
|
-
const other = run("other");
|
|
46
|
-
coverage.claim(other, 40, 60);
|
|
47
|
-
|
|
48
|
-
assert.equal(coverage.freeRunFrom(0), 40, "0..39, and the other run's claim begins at 40");
|
|
49
|
-
assert.equal(coverage.firstGapFrom(45), 61, "the first thing nobody holds after it");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("a run's own claim does not hold it back", () => {
|
|
53
|
-
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
54
|
-
const mine = run("mine");
|
|
55
|
-
coverage.claim(mine, 40, 60);
|
|
56
|
-
|
|
57
|
-
assert.equal(
|
|
58
|
-
coverage.freeRunFrom(40, mine),
|
|
59
|
-
60,
|
|
60
|
-
"asked by the run that holds it, the stretch is its own to work through"
|
|
61
|
-
);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("a run that has ended holds nothing back", () => {
|
|
65
|
-
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
66
|
-
const dead = run("dead");
|
|
67
|
-
coverage.claim(dead, 40, 60);
|
|
68
|
-
coverage.release(dead);
|
|
69
|
-
|
|
70
|
-
assert.equal(coverage.freeRunFrom(0), 100, "what it did not finish is free again");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("what a dead run DID finish stays made", () => {
|
|
74
|
-
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
75
|
-
const dead = run("dead");
|
|
76
|
-
coverage.claim(dead, 40, 60);
|
|
77
|
-
coverage.setReady([40, 41, 42]);
|
|
78
|
-
coverage.release(dead);
|
|
79
|
-
|
|
80
|
-
assert.equal(coverage.firstGapFrom(40), 43, "a closed file is closed whoever made it");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("nothing left to make is answered with nothing", () => {
|
|
84
|
-
const coverage = new CoverageMap({ segmentCount: 5 });
|
|
85
|
-
coverage.setReady([0, 1, 2, 3, 4]);
|
|
86
|
-
|
|
87
|
-
assert.equal(coverage.firstGapFrom(0), null, "and a run started here would only repeat somebody's work");
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("a run without an end does not take the film away from a viewer further in", () => {
|
|
91
|
-
// The case that used to need a second authority's `head + look-ahead` guess.
|
|
92
|
-
// A run's claim is now the stretch the plan GAVE it, so a viewer opening the
|
|
93
|
-
// same film in the middle finds their own position free.
|
|
94
|
-
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
95
|
-
const first = run("first");
|
|
96
|
-
coverage.claim(first, 0, 499);
|
|
97
|
-
|
|
98
|
-
assert.equal(coverage.firstGapFrom(500), 500, "the second viewer's own position is free");
|
|
99
|
-
assert.equal(coverage.freeRunFrom(500), 500, "and everything from there to the end");
|
|
100
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file How far a run may work, and who decides it.
|
|
3
|
+
*
|
|
4
|
+
* These cases used to be asked of `planRunInterval`, a second authority inside
|
|
5
|
+
* the session manager that answered by its own rules: it walked the whole track
|
|
6
|
+
* for the first free number, MOVED the start there, and counted every live run
|
|
7
|
+
* as claiming up to `head + look-ahead`. It contradicted the plan directly, and
|
|
8
|
+
* the two together produced the field oscillation of 2026-09-05 — the plan
|
|
9
|
+
* commanded a start at #46, this moved it to #78, the plan killed the run for
|
|
10
|
+
* standing outside the window it had asked for, and the same start was
|
|
11
|
+
* commanded again, 350-700ms per cycle, no segment ever produced, the viewer's
|
|
12
|
+
* picture stopped for 125 seconds.
|
|
13
|
+
*
|
|
14
|
+
* It is gone. WHERE a run starts is the plan's decision and nothing moves it;
|
|
15
|
+
* HOW FAR it may work is a fact of the one coverage map, and that is what these
|
|
16
|
+
* cases now ask. The map is the same object the plan reads, so there is no
|
|
17
|
+
* second set of rules for the two to disagree about.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { CoverageMap } from "../services/encode/CoverageMap.js";
|
|
23
|
+
|
|
24
|
+
/** A stand-in for a run: the map identifies one by being it. */
|
|
25
|
+
function run(name) {
|
|
26
|
+
return { name };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("with nothing made, a run gets everything from where it was asked", () => {
|
|
30
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
31
|
+
|
|
32
|
+
assert.equal(coverage.freeRunFrom(0), 100, "the whole track is free");
|
|
33
|
+
assert.equal(coverage.firstGapFrom(0), 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a run stops before material that is already made", () => {
|
|
37
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
38
|
+
coverage.setReady([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]);
|
|
39
|
+
|
|
40
|
+
assert.equal(coverage.freeRunFrom(0), 10, "0..9, and it stops where 10 begins");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a run stops before a stretch another live run was given", () => {
|
|
44
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
45
|
+
const other = run("other");
|
|
46
|
+
coverage.claim(other, 40, 60);
|
|
47
|
+
|
|
48
|
+
assert.equal(coverage.freeRunFrom(0), 40, "0..39, and the other run's claim begins at 40");
|
|
49
|
+
assert.equal(coverage.firstGapFrom(45), 61, "the first thing nobody holds after it");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a run's own claim does not hold it back", () => {
|
|
53
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
54
|
+
const mine = run("mine");
|
|
55
|
+
coverage.claim(mine, 40, 60);
|
|
56
|
+
|
|
57
|
+
assert.equal(
|
|
58
|
+
coverage.freeRunFrom(40, mine),
|
|
59
|
+
60,
|
|
60
|
+
"asked by the run that holds it, the stretch is its own to work through"
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a run that has ended holds nothing back", () => {
|
|
65
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
66
|
+
const dead = run("dead");
|
|
67
|
+
coverage.claim(dead, 40, 60);
|
|
68
|
+
coverage.release(dead);
|
|
69
|
+
|
|
70
|
+
assert.equal(coverage.freeRunFrom(0), 100, "what it did not finish is free again");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("what a dead run DID finish stays made", () => {
|
|
74
|
+
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
75
|
+
const dead = run("dead");
|
|
76
|
+
coverage.claim(dead, 40, 60);
|
|
77
|
+
coverage.setReady([40, 41, 42]);
|
|
78
|
+
coverage.release(dead);
|
|
79
|
+
|
|
80
|
+
assert.equal(coverage.firstGapFrom(40), 43, "a closed file is closed whoever made it");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("nothing left to make is answered with nothing", () => {
|
|
84
|
+
const coverage = new CoverageMap({ segmentCount: 5 });
|
|
85
|
+
coverage.setReady([0, 1, 2, 3, 4]);
|
|
86
|
+
|
|
87
|
+
assert.equal(coverage.firstGapFrom(0), null, "and a run started here would only repeat somebody's work");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a run without an end does not take the film away from a viewer further in", () => {
|
|
91
|
+
// The case that used to need a second authority's `head + look-ahead` guess.
|
|
92
|
+
// A run's claim is now the stretch the plan GAVE it, so a viewer opening the
|
|
93
|
+
// same film in the middle finds their own position free.
|
|
94
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
95
|
+
const first = run("first");
|
|
96
|
+
coverage.claim(first, 0, 499);
|
|
97
|
+
|
|
98
|
+
assert.equal(coverage.firstGapFrom(500), 500, "the second viewer's own position is free");
|
|
99
|
+
assert.equal(coverage.freeRunFrom(500), 500, "and everything from there to the end");
|
|
100
|
+
});
|
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Ask ffmpeg late enough that it lands where we meant.
|
|
3
|
-
*
|
|
4
|
-
* `fftools/ffmpeg_demux.c` moves an input seek back by `3*AV_TIME_BASE / 23` —
|
|
5
|
-
* 130.435 ms — whenever the container does not declare `AVFMT_SEEK_TO_PTS` and
|
|
6
|
-
* a stream carries B-frames. So asking for a keyframe lands on the one before
|
|
7
|
-
* it, and since `-segment_times` is measured from where the run really began,
|
|
8
|
-
* every cut of that run inherits the shift.
|
|
9
|
-
*
|
|
10
|
-
* Measured 2026-08-21 on Matroska with keyframes every 2 s: `-ss 10` produced a
|
|
11
|
-
* first segment starting at 8.000, `-ss 10.130435` one starting at 10.000. On
|
|
12
|
-
* MP4, where the heuristic does not fire, 10, 10.130435 and 10.2 all produced
|
|
13
|
-
* 10.000 — right in one case, harmless in the other.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
import { SourceFile } from "../services/source/SourceFile.js";
|
|
18
|
-
import test from "node:test";
|
|
19
|
-
|
|
20
|
-
import { seekLandingOffsetFor } from "../services/hls-session-manager.js";
|
|
21
|
-
|
|
22
|
-
const OFFSET = 3 / 23;
|
|
23
|
-
|
|
24
|
-
test("a copied picture is asked for one heuristic later than the keyframe", () => {
|
|
25
|
-
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004, 6.006] }) };
|
|
26
|
-
assert.equal(seekLandingOffsetFor(session, 2.002), OFFSET);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("a re-encode is asked for exactly what it should produce", () => {
|
|
30
|
-
// It decodes from the keyframe and discards frames up to the requested time,
|
|
31
|
-
// so pushing the request later would start its output late.
|
|
32
|
-
const session = { transcodeVideo: true, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
|
|
33
|
-
assert.equal(seekLandingOffsetFor(session, 2.002), 0);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("the offset never reaches the next keyframe", () => {
|
|
37
|
-
// Keyframes 0.1 s apart: half of that is the most that can be added without
|
|
38
|
-
// risking a landing on the NEXT one where the heuristic does not fire.
|
|
39
|
-
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2, 0.3] }) };
|
|
40
|
-
assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("the last keyframe has nothing after it to collide with", () => {
|
|
44
|
-
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
|
|
45
|
-
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("no keyframe list is still answered", () => {
|
|
49
|
-
assert.equal(seekLandingOffsetFor({ transcodeVideo: false }, 5), OFFSET);
|
|
50
|
-
assert.equal(seekLandingOffsetFor(null, 5), OFFSET);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("a grid whose times are approximate is asked for that much later again", () => {
|
|
54
|
-
// AVI names a keyframe by its frame NUMBER and the time is that number times
|
|
55
|
-
// the frame duration, so a name can sit just BELOW the keyframe it refers to
|
|
56
|
-
// — measured 2026-08-21, 10-44 ms out on two files, always under one frame.
|
|
57
|
-
// Asking at the name alone would seek to before the real keyframe and land on
|
|
58
|
-
// the one before that, which is the fault this offset exists for.
|
|
59
|
-
const session = {
|
|
60
|
-
transcodeVideo: false,
|
|
61
|
-
file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0.04 })
|
|
62
|
-
};
|
|
63
|
-
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET + 0.04);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("an exact grid claims no tolerance", () => {
|
|
67
|
-
// Matroska and MP4 state instants outright — measured the same day, nine
|
|
68
|
-
// files and 11 665 keyframes with not one disagreement.
|
|
69
|
-
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008], keyframeTolerance: 0 }) };
|
|
70
|
-
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("the bound still holds once a tolerance is added", () => {
|
|
74
|
-
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2], keyframeTolerance: 1 }) };
|
|
75
|
-
assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("an output carrying only sound is not pushed past what it asked for", () => {
|
|
79
|
-
// The field failure of 2026-09-06: the sound played 130 ms ahead of the
|
|
80
|
-
// picture from every restart onward, and the viewer saw lips out of step with
|
|
81
|
-
// the voice from two minutes in.
|
|
82
|
-
//
|
|
83
|
-
// The offset exists because ffmpeg's demuxer moves a seek target back for a
|
|
84
|
-
// container it reads in decode order, after which a COPY lands on the previous
|
|
85
|
-
// keyframe. A re-encode trims to the requested time itself and is excluded —
|
|
86
|
-
// and an output with no picture is exactly that, since its one track is
|
|
87
|
-
// `-c:a aac`. It was not excluded, because the test asked whether the PICTURE
|
|
88
|
-
// is re-encoded and an output with no picture answers no.
|
|
89
|
-
const soundtrack = {
|
|
90
|
-
audioOnly: true,
|
|
91
|
-
transcodeVideo: false,
|
|
92
|
-
file: new SourceFile({ sourceKey: "s", fileIndex: 0 })
|
|
93
|
-
.learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0 })
|
|
94
|
-
};
|
|
95
|
-
assert.equal(seekLandingOffsetFor(soundtrack, 4.004), 0);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("the picture of the same film still gets the offset", () => {
|
|
99
|
-
// The pair to the check above: the two outputs are repositioned to one
|
|
100
|
-
// boundary and must be given DIFFERENT requests, because one is copied and one
|
|
101
|
-
// is re-encoded. Given the same request they land 130 ms apart.
|
|
102
|
-
const picture = {
|
|
103
|
-
audioOnly: false,
|
|
104
|
-
transcodeVideo: false,
|
|
105
|
-
file: new SourceFile({ sourceKey: "s", fileIndex: 0 })
|
|
106
|
-
.learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0 })
|
|
107
|
-
};
|
|
108
|
-
assert.equal(seekLandingOffsetFor(picture, 4.004), OFFSET);
|
|
109
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file Ask ffmpeg late enough that it lands where we meant.
|
|
3
|
+
*
|
|
4
|
+
* `fftools/ffmpeg_demux.c` moves an input seek back by `3*AV_TIME_BASE / 23` —
|
|
5
|
+
* 130.435 ms — whenever the container does not declare `AVFMT_SEEK_TO_PTS` and
|
|
6
|
+
* a stream carries B-frames. So asking for a keyframe lands on the one before
|
|
7
|
+
* it, and since `-segment_times` is measured from where the run really began,
|
|
8
|
+
* every cut of that run inherits the shift.
|
|
9
|
+
*
|
|
10
|
+
* Measured 2026-08-21 on Matroska with keyframes every 2 s: `-ss 10` produced a
|
|
11
|
+
* first segment starting at 8.000, `-ss 10.130435` one starting at 10.000. On
|
|
12
|
+
* MP4, where the heuristic does not fire, 10, 10.130435 and 10.2 all produced
|
|
13
|
+
* 10.000 — right in one case, harmless in the other.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
|
|
20
|
+
import { seekLandingOffsetFor } from "../services/hls-session-manager.js";
|
|
21
|
+
|
|
22
|
+
const OFFSET = 3 / 23;
|
|
23
|
+
|
|
24
|
+
test("a copied picture is asked for one heuristic later than the keyframe", () => {
|
|
25
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004, 6.006] }) };
|
|
26
|
+
assert.equal(seekLandingOffsetFor(session, 2.002), OFFSET);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a re-encode is asked for exactly what it should produce", () => {
|
|
30
|
+
// It decodes from the keyframe and discards frames up to the requested time,
|
|
31
|
+
// so pushing the request later would start its output late.
|
|
32
|
+
const session = { transcodeVideo: true, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
|
|
33
|
+
assert.equal(seekLandingOffsetFor(session, 2.002), 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the offset never reaches the next keyframe", () => {
|
|
37
|
+
// Keyframes 0.1 s apart: half of that is the most that can be added without
|
|
38
|
+
// risking a landing on the NEXT one where the heuristic does not fire.
|
|
39
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2, 0.3] }) };
|
|
40
|
+
assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the last keyframe has nothing after it to collide with", () => {
|
|
44
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
|
|
45
|
+
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("no keyframe list is still answered", () => {
|
|
49
|
+
assert.equal(seekLandingOffsetFor({ transcodeVideo: false }, 5), OFFSET);
|
|
50
|
+
assert.equal(seekLandingOffsetFor(null, 5), OFFSET);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a grid whose times are approximate is asked for that much later again", () => {
|
|
54
|
+
// AVI names a keyframe by its frame NUMBER and the time is that number times
|
|
55
|
+
// the frame duration, so a name can sit just BELOW the keyframe it refers to
|
|
56
|
+
// — measured 2026-08-21, 10-44 ms out on two files, always under one frame.
|
|
57
|
+
// Asking at the name alone would seek to before the real keyframe and land on
|
|
58
|
+
// the one before that, which is the fault this offset exists for.
|
|
59
|
+
const session = {
|
|
60
|
+
transcodeVideo: false,
|
|
61
|
+
file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0.04 })
|
|
62
|
+
};
|
|
63
|
+
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET + 0.04);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an exact grid claims no tolerance", () => {
|
|
67
|
+
// Matroska and MP4 state instants outright — measured the same day, nine
|
|
68
|
+
// files and 11 665 keyframes with not one disagreement.
|
|
69
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008], keyframeTolerance: 0 }) };
|
|
70
|
+
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the bound still holds once a tolerance is added", () => {
|
|
74
|
+
const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2], keyframeTolerance: 1 }) };
|
|
75
|
+
assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("an output carrying only sound is not pushed past what it asked for", () => {
|
|
79
|
+
// The field failure of 2026-09-06: the sound played 130 ms ahead of the
|
|
80
|
+
// picture from every restart onward, and the viewer saw lips out of step with
|
|
81
|
+
// the voice from two minutes in.
|
|
82
|
+
//
|
|
83
|
+
// The offset exists because ffmpeg's demuxer moves a seek target back for a
|
|
84
|
+
// container it reads in decode order, after which a COPY lands on the previous
|
|
85
|
+
// keyframe. A re-encode trims to the requested time itself and is excluded —
|
|
86
|
+
// and an output with no picture is exactly that, since its one track is
|
|
87
|
+
// `-c:a aac`. It was not excluded, because the test asked whether the PICTURE
|
|
88
|
+
// is re-encoded and an output with no picture answers no.
|
|
89
|
+
const soundtrack = {
|
|
90
|
+
audioOnly: true,
|
|
91
|
+
transcodeVideo: false,
|
|
92
|
+
file: new SourceFile({ sourceKey: "s", fileIndex: 0 })
|
|
93
|
+
.learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0 })
|
|
94
|
+
};
|
|
95
|
+
assert.equal(seekLandingOffsetFor(soundtrack, 4.004), 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the picture of the same film still gets the offset", () => {
|
|
99
|
+
// The pair to the check above: the two outputs are repositioned to one
|
|
100
|
+
// boundary and must be given DIFFERENT requests, because one is copied and one
|
|
101
|
+
// is re-encoded. Given the same request they land 130 ms apart.
|
|
102
|
+
const picture = {
|
|
103
|
+
audioOnly: false,
|
|
104
|
+
transcodeVideo: false,
|
|
105
|
+
file: new SourceFile({ sourceKey: "s", fileIndex: 0 })
|
|
106
|
+
.learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0 })
|
|
107
|
+
};
|
|
108
|
+
assert.equal(seekLandingOffsetFor(picture, 4.004), OFFSET);
|
|
109
|
+
});
|
|
@@ -290,12 +290,12 @@ test("a run's FIRST segment is served once the encoder has passed it, without wa
|
|
|
290
290
|
// successor and nothing is producing one. Waiting for that successor is what
|
|
291
291
|
// held #317 for 46 s and then answered 404 to a browser that had given up.
|
|
292
292
|
await rm(path.join(dirPath, "segment-00001.mp4"));
|
|
293
|
-
// WHAT PROVES IT INSTEAD:
|
|
294
|
-
//
|
|
295
|
-
// proven the moment it is finished and the
|
|
296
|
-
// This used to be inferred from the
|
|
297
|
-
//
|
|
298
|
-
|
|
293
|
+
// WHAT PROVES IT INSTEAD: its own NAME. A piece is called something else while
|
|
294
|
+
// it is being written and takes its served name when its writer says it is
|
|
295
|
+
// closed, so a run's first piece is proven the moment it is finished and the
|
|
296
|
+
// absence of a next one says nothing. This used to be inferred from the
|
|
297
|
+
// encoder's reported position — a different question, where it has read to
|
|
298
|
+
// rather than what it has closed.
|
|
299
299
|
|
|
300
300
|
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
301
301
|
|
|
@@ -345,10 +345,9 @@ test("serving a run's own segment moves the run out of STARTING", async (t) => {
|
|
|
345
345
|
// ITS OWN PIECE, AND FILM STILL TO MAKE BEHIND IT. A run standing on material
|
|
346
346
|
// that already exists is moved forward — correctly — so a fixture that wants a
|
|
347
347
|
// live run has to put it where the work is. #1 is what it is making; #2..#4
|
|
348
|
-
// are unmade, so it is wanted; and
|
|
349
|
-
//
|
|
348
|
+
// are unmade, so it is wanted; and #1 stands under its served name, which is
|
|
349
|
+
// what makes the piece servable at all.
|
|
350
350
|
const run = startRunOn(session, { from: 1, producing: false, usesExplicitCuts: true, speedX: 2 });
|
|
351
|
-
manager.segmentStore.markClosed(OUTPUT_KEY, 1);
|
|
352
351
|
assert.equal(run.state, ENCODE_RUN_STATE.STARTING);
|
|
353
352
|
assert.deepEqual(run.produced, [], "it has made nothing yet");
|
|
354
353
|
|