@torrent-tv/proxy 2.80.4 → 2.80.6
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 +36 -0
- package/package.json +1 -1
- package/routes/api/delivery-sink/get.js +8 -4
- package/server.js +415 -403
- package/services/data-channel-handler.js +87 -25
- package/services/delivery-probe.js +38 -5
- package/services/encode/CoverageMap.js +77 -4
- package/services/encode/EncodePlan.js +1025 -358
- package/services/encode/EncodeRun.js +42 -22
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +55 -3
- package/services/encode/open-piece.js +47 -24
- package/services/encode/run-command.js +12 -2
- package/services/hls-session-manager.js +38 -158
- package/services/hwaccel.js +182 -54
- package/services/orchestrators/EncodeOrchestrator.js +123 -94
- package/services/output/LiveOutputs.js +233 -213
- package/services/output/Timeline.js +333 -256
- package/services/priority/PriorityMap.js +262 -108
- package/services/priority/PriorityOrchestrator.js +31 -6
- package/services/quality/EncodeCost.js +555 -500
- package/services/torrent-pool.js +9 -4
- package/test/encode-orchestrator.test.js +195 -65
- package/test/encode-plan-viewers.test.js +719 -0
- package/test/encode-plan.test.js +174 -81
- package/test/open-piece.test.js +152 -0
- package/test/output-speed.test.js +86 -0
- package/test/priority-map-download.test.js +25 -7
- package/test/priority-map.test.js +134 -83
- package/test/seek-landing.test.js +109 -76
- package/test/segment-demand.test.js +54 -56
- package/test/wedge-certainty.test.js +3 -3
- package/test/flushed-piece.test.js +0 -108
|
@@ -15,7 +15,7 @@ import assert from "node:assert/strict";
|
|
|
15
15
|
import { TorrentPool } from "../services/torrent-pool.js";
|
|
16
16
|
import { demandFor, forgetTorrent } from "../services/download/registry.js";
|
|
17
17
|
import { Urgency } from "../services/demand/index.js";
|
|
18
|
-
import { mapForViewer } from "../services/priority/PriorityMap.js";
|
|
18
|
+
import { mapForViewer, runsOf } from "../services/priority/PriorityMap.js";
|
|
19
19
|
|
|
20
20
|
/** A torrent that is nothing but one file of a known length. */
|
|
21
21
|
function torrentOf({ length = 1_000_000, offset = 0 } = {}) {
|
|
@@ -58,11 +58,11 @@ test("a zone is stated at the level it means, not at the level of its position",
|
|
|
58
58
|
try {
|
|
59
59
|
// A viewer 300 s into a 3000 s film: one band behind them that nobody is
|
|
60
60
|
// approaching, then bands of decreasing urgency ahead.
|
|
61
|
-
const zones = mapForViewer({
|
|
61
|
+
const zones = runsOf(mapForViewer({
|
|
62
62
|
atSeconds: 300,
|
|
63
63
|
durationSeconds: 3000,
|
|
64
64
|
allowanceSeconds: 10
|
|
65
|
-
});
|
|
65
|
+
}));
|
|
66
66
|
assert.ok(zones.length > 5, "the map should be finer than the register's levels");
|
|
67
67
|
|
|
68
68
|
applyPriorityMap.call(null, torrent, 0, zones, 3000);
|
|
@@ -93,23 +93,41 @@ test("a zone is stated at the level it means, not at the level of its position",
|
|
|
93
93
|
test("a viewer who has stopped the picture makes the whole film wanted last", () => {
|
|
94
94
|
const torrent = torrentOf();
|
|
95
95
|
try {
|
|
96
|
-
const zones = mapForViewer({
|
|
96
|
+
const zones = runsOf(mapForViewer({
|
|
97
97
|
atSeconds: 300,
|
|
98
98
|
durationSeconds: 3000,
|
|
99
99
|
allowanceSeconds: 10,
|
|
100
100
|
playing: false
|
|
101
|
-
});
|
|
101
|
+
}));
|
|
102
102
|
applyPriorityMap.call(null, torrent, 0, zones, 3000);
|
|
103
103
|
|
|
104
104
|
const { register } = demandFor(torrent);
|
|
105
105
|
const stated = register.windows().filter((one) => String(one.claimant).startsWith("priority-map:"));
|
|
106
|
-
|
|
106
|
+
// A PAUSE REMOVES THE TIME, NOT THE DIRECTION. What they have watched is
|
|
107
|
+
// behind them and what they have not is in front, exactly as while they were
|
|
108
|
+
// watching; what changes is that nothing has a time any more, so every band
|
|
109
|
+
// of theirs yields to anybody who is on their way somewhere.
|
|
110
|
+
assert.ok(stated.length >= 2, "their position survives the pause");
|
|
111
|
+
for (const one of stated) {
|
|
112
|
+
assert.ok(
|
|
113
|
+
one.urgency === Urgency.BEHIND || one.urgency === Urgency.TAIL,
|
|
114
|
+
"and nothing of theirs is urgent, because nobody is coming"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const front = stated.filter((one) => one.urgency === Urgency.TAIL);
|
|
118
|
+
assert.ok(front.length >= 1, "the film in front of them is still in front");
|
|
119
|
+
assert.ok(
|
|
120
|
+
front[0].byteStart >= Math.floor((300 / 3000) * 1_000_000),
|
|
121
|
+
"and it begins where they stopped"
|
|
122
|
+
);
|
|
107
123
|
// Wanted last ABSOLUTELY, not relative to this film's own map. The two
|
|
108
124
|
// speculative levels are withheld across every torrent at once while
|
|
109
125
|
// anything urgent is missing anywhere, so this has to lose to a film
|
|
110
126
|
// somebody is actually watching — and judged against itself alone it would
|
|
111
127
|
// be the most urgent thing there is.
|
|
112
|
-
|
|
128
|
+
const behind = stated.filter((one) => one.urgency === Urgency.BEHIND);
|
|
129
|
+
assert.equal(behind.length, 1, "what they have already watched is wanted last of all");
|
|
130
|
+
assert.equal(behind[0].byteStart, 0, "and it is the stretch from the beginning to where they stopped");
|
|
113
131
|
} finally {
|
|
114
132
|
forgetTorrent(torrent);
|
|
115
133
|
}
|
|
@@ -1,112 +1,163 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file
|
|
2
|
+
* @file The priority map: one number per second of film.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The map is literally a map — an array as long as the film, saying how urgently
|
|
5
|
+
* each second is wanted, and beside it how long until somebody plays it. What is
|
|
6
|
+
* checked here is what the numbers MEAN, since only their order is meaningful:
|
|
7
|
+
* in front of a viewer beats behind them, nearer beats further, and two viewers
|
|
8
|
+
* merge to the more urgent of them.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here spawns anything, reads a disk or looks at a clock: this layer is
|
|
11
|
+
* exercised with plain values alone, which is the layer check made executable.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import test from "node:test";
|
|
10
15
|
import assert from "node:assert/strict";
|
|
11
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
emptyMap,
|
|
18
|
+
inWorkingOrder,
|
|
19
|
+
isBehindEverybody,
|
|
20
|
+
mapForViewer,
|
|
21
|
+
mergeMaps,
|
|
22
|
+
runsOf
|
|
23
|
+
} from "../services/priority/PriorityMap.js";
|
|
24
|
+
|
|
25
|
+
const FILM = 3600;
|
|
26
|
+
const ALLOWANCE = 8;
|
|
12
27
|
|
|
13
|
-
|
|
14
|
-
|
|
28
|
+
/**
|
|
29
|
+
* @param {number} atSeconds
|
|
30
|
+
* @param {boolean} [playing]
|
|
31
|
+
*/
|
|
32
|
+
function viewer(atSeconds, playing = true) {
|
|
33
|
+
return mapForViewer({
|
|
34
|
+
atSeconds,
|
|
35
|
+
durationSeconds: FILM,
|
|
36
|
+
allowanceSeconds: ALLOWANCE,
|
|
37
|
+
playing
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("the map is one entry per second of film", () => {
|
|
42
|
+
const map = viewer(600);
|
|
43
|
+
assert.equal(map.durationSeconds, FILM);
|
|
44
|
+
assert.equal(map.priority.length, FILM);
|
|
45
|
+
assert.equal(map.secondsUntilPlayed.length, FILM);
|
|
46
|
+
assert.equal(map.behind.length, FILM);
|
|
47
|
+
});
|
|
15
48
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
previousEnd = zone.to;
|
|
22
|
-
}
|
|
49
|
+
test("the nearer a viewer is to a second, the higher its number", () => {
|
|
50
|
+
// The whole of what a priority means: which of two seconds is wanted first.
|
|
51
|
+
const map = viewer(600);
|
|
52
|
+
assert.ok(map.priority[600] > map.priority[700], "their own second beats one a hundred on");
|
|
53
|
+
assert.ok(map.priority[700] > map.priority[3000], "and that beats one forty minutes on");
|
|
23
54
|
});
|
|
24
55
|
|
|
25
|
-
test("
|
|
26
|
-
const
|
|
27
|
-
|
|
56
|
+
test("the time is the distance, because a viewer covers a second of film in a second", () => {
|
|
57
|
+
const map = viewer(600);
|
|
58
|
+
assert.equal(map.secondsUntilPlayed[600], 0, "they are there now");
|
|
59
|
+
assert.equal(map.secondsUntilPlayed[660], 60, "a minute of film away is a minute away");
|
|
60
|
+
});
|
|
28
61
|
|
|
29
|
-
|
|
62
|
+
test("what is behind a viewer is wanted, and wanted last", () => {
|
|
63
|
+
// Still wanted — a seek back must be cheap — but it yields to everything
|
|
64
|
+
// anybody is walking towards, however far off that is.
|
|
65
|
+
const map = viewer(600);
|
|
66
|
+
assert.equal(map.behind[599], 1);
|
|
67
|
+
assert.equal(map.behind[600], 0);
|
|
68
|
+
assert.ok(isBehindEverybody(map.priority[599]));
|
|
69
|
+
assert.ok(map.priority[599] < map.priority[3599],
|
|
70
|
+
"the last second of the film outranks the second they just watched");
|
|
71
|
+
assert.equal(map.secondsUntilPlayed[599], Number.POSITIVE_INFINITY,
|
|
72
|
+
"nobody is on their way there, so there is no time by which it must exist");
|
|
73
|
+
});
|
|
30
74
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
75
|
+
test("a viewer who has stopped the picture keeps their position, and loses their times", () => {
|
|
76
|
+
// A pause removes the time, not the direction. Collapsed to one flat value
|
|
77
|
+
// over the whole film, as it was, their position disappeared entirely — and
|
|
78
|
+
// with it the rule that what is in front of them is made first.
|
|
79
|
+
const map = viewer(600, false);
|
|
80
|
+
assert.equal(map.behind[599], 1, "what they have watched is still behind them");
|
|
81
|
+
assert.equal(map.behind[600], 0, "and what they have not is still in front");
|
|
82
|
+
assert.ok(map.priority[600] > map.priority[599], "in front still outranks behind");
|
|
83
|
+
assert.ok(map.priority[600] > map.priority[3000], "and nearer still outranks further");
|
|
84
|
+
for (const second of [599, 600, 3000]) {
|
|
85
|
+
assert.equal(map.secondsUntilPlayed[second], Number.POSITIVE_INFINITY,
|
|
86
|
+
"but nothing has a time, because they are on their way nowhere");
|
|
35
87
|
}
|
|
36
|
-
const priorityAt = (at) => merged.find((zone) => at >= zone.from && at < zone.to)?.priority;
|
|
37
|
-
assert.equal(priorityAt(0), priorityAt(500), "both viewers' own positions are equally urgent");
|
|
38
|
-
assert.ok(priorityAt(500) > priorityAt(300), "a viewer at 500 outranks the far zone of the one at 0");
|
|
39
88
|
});
|
|
40
89
|
|
|
41
|
-
test("
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
assert.ok(firstTwo.includes(0), "the first viewer's own position is taken first");
|
|
51
|
-
assert.ok(firstTwo.includes(2000), "so is the second viewer's, before anything less urgent");
|
|
90
|
+
test("anybody who is watching outranks anybody who has stopped", () => {
|
|
91
|
+
// An ordering fact rather than a chosen number: somebody watching needs their
|
|
92
|
+
// next second almost at once, while somebody stopped needs theirs at a time
|
|
93
|
+
// nothing here knows.
|
|
94
|
+
const watching = viewer(600);
|
|
95
|
+
const stopped = viewer(3000, false);
|
|
96
|
+
assert.ok(watching.priority[3599] > stopped.priority[3000],
|
|
97
|
+
"the far tail of a watching viewer beats the very next second of a stopped one");
|
|
52
98
|
});
|
|
53
99
|
|
|
54
|
-
test("
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
]);
|
|
100
|
+
test("two viewers merge to the more urgent of them, second by second", () => {
|
|
101
|
+
const merged = mergeMaps([viewer(600), viewer(1800)]);
|
|
102
|
+
assert.equal(merged.priority[1800], merged.priority[600],
|
|
103
|
+
"each of them is at the top of the scale where they stand");
|
|
104
|
+
assert.equal(merged.secondsUntilPlayed[1800], 0, "the nearer time wins");
|
|
105
|
+
assert.equal(merged.behind[599], 1, "behind both of them is behind");
|
|
106
|
+
assert.equal(merged.behind[700], 0, "and in front of ANYBODY is in front");
|
|
107
|
+
});
|
|
60
108
|
|
|
61
|
-
|
|
109
|
+
test("the second viewer's position is not buried under the first viewer's distance", () => {
|
|
110
|
+
// The failure this guards: one viewer's far film outranking another viewer's
|
|
111
|
+
// own second, so the second of them is served by nobody.
|
|
112
|
+
const merged = mergeMaps([viewer(600), viewer(1800)]);
|
|
113
|
+
assert.ok(merged.priority[1800] > merged.priority[1799],
|
|
114
|
+
"where the second viewer stands beats the film just before them");
|
|
62
115
|
});
|
|
63
116
|
|
|
64
|
-
test("
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
117
|
+
test("a film nobody is watching wants nothing", () => {
|
|
118
|
+
const merged = mergeMaps([]);
|
|
119
|
+
assert.equal(merged.durationSeconds, 0);
|
|
120
|
+
assert.deepEqual(runsOf(merged), []);
|
|
121
|
+
});
|
|
69
122
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
123
|
+
test("the map as stretches says the same thing in fewer numbers", () => {
|
|
124
|
+
const runs = runsOf(viewer(600));
|
|
125
|
+
assert.ok(runs.length > 1 && runs.length < 40, "a handful of stretches, not thousands");
|
|
126
|
+
assert.equal(runs[0].from, 0, "starting at the beginning of the film");
|
|
127
|
+
assert.equal(runs[0].behind, true, "which is behind them");
|
|
128
|
+
assert.equal(runs[runs.length - 1].to, FILM, "and ending at its end");
|
|
129
|
+
for (let index = 0; index < runs.length - 1; index += 1) {
|
|
130
|
+
assert.equal(runs[index].to, runs[index + 1].from, "with no gaps");
|
|
74
131
|
}
|
|
75
132
|
});
|
|
76
133
|
|
|
77
|
-
test("the
|
|
78
|
-
// Near the viewer the difference between now and ten seconds away decides
|
|
79
|
-
//
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
assert.
|
|
134
|
+
test("the stretches widen with distance, so any film is a handful of them", () => {
|
|
135
|
+
// Near the viewer the difference between now and ten seconds away decides what
|
|
136
|
+
// is made first; twenty minutes out, one more division changes no decision.
|
|
137
|
+
const ahead = runsOf(viewer(0)).filter((run) => run.behind === false);
|
|
138
|
+
for (let index = 1; index < ahead.length; index += 1) {
|
|
139
|
+
assert.ok(ahead[index].to - ahead[index].from >= ahead[index - 1].to - ahead[index - 1].from,
|
|
140
|
+
"each stretch is at least as wide as the one before it");
|
|
141
|
+
}
|
|
142
|
+
assert.ok(ahead[0].to - ahead[0].from <= ALLOWANCE + 1,
|
|
143
|
+
"and the first is as wide as the measured allowance");
|
|
86
144
|
});
|
|
87
145
|
|
|
88
|
-
test("
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
test("the working order is most urgent first, earliest film within one priority", () => {
|
|
147
|
+
const ordered = inWorkingOrder(runsOf(viewer(600)));
|
|
148
|
+
for (let index = 0; index < ordered.length - 1; index += 1) {
|
|
149
|
+
const left = ordered[index];
|
|
150
|
+
const right = ordered[index + 1];
|
|
151
|
+
assert.ok(
|
|
152
|
+
left.priority > right.priority || (left.priority === right.priority && left.from < right.from),
|
|
153
|
+
"sorted by priority, then by position"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
assert.equal(ordered[0].from, 600, "and the first of all is where the viewer is stopped");
|
|
98
157
|
});
|
|
99
158
|
|
|
100
|
-
test("a
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
atSeconds: 300,
|
|
105
|
-
durationSeconds: 3000,
|
|
106
|
-
allowanceSeconds: 10,
|
|
107
|
-
playing: false
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
assert.equal(map.length, 1);
|
|
111
|
-
assert.deepEqual({ from: map[0].from, to: map[0].to }, { from: 0, to: 3000 });
|
|
159
|
+
test("a map of no length is a statement, and it says nothing is wanted", () => {
|
|
160
|
+
const map = emptyMap(0);
|
|
161
|
+
assert.equal(map.durationSeconds, 0);
|
|
162
|
+
assert.deepEqual(runsOf(map), []);
|
|
112
163
|
});
|
|
@@ -1,76 +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
|
-
});
|
|
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
|
+
});
|