@torrent-tv/proxy 2.80.14 → 2.80.16
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 +13 -0
- package/docs/encode-architecture.md +79 -0
- package/package.json +1 -1
- package/services/encode/EncodePlan.js +1274 -1101
- package/services/encode/run-costs.js +54 -3
- package/services/hls-session-manager.js +6 -2
- package/services/output/LiveOutputs.js +17 -7
- package/services/output/rates.js +35 -5
- package/test/encode-plan.test.js +9 -3
- package/test/move-cost.test.js +145 -0
|
@@ -53,9 +53,22 @@ export class RunCosts {
|
|
|
53
53
|
* that was never told to stop did not die on command, and one that produced
|
|
54
54
|
* nothing has no first output — and an absent reading is not a zero.
|
|
55
55
|
*
|
|
56
|
-
* @param {{ dyingMs?: number | null, firstOutputMs?: number | null
|
|
56
|
+
* @param {{ dyingMs?: number | null, firstOutputMs?: number | null,
|
|
57
|
+
* livedMs?: number | null }} ended - `livedMs` is how long a run that
|
|
58
|
+
* produced NOTHING was alive, which is a lower bound on the first output.
|
|
57
59
|
*/
|
|
58
60
|
note(ended) {
|
|
61
|
+
// A RUN THAT PRODUCED NOTHING IS A MEASUREMENT TOO — of a lower bound. It
|
|
62
|
+
// says the first output takes at least as long as this run lived, which is
|
|
63
|
+
// a fact and not an estimate, and it is the only reading a thrash can
|
|
64
|
+
// supply: every run in one is killed before it finishes anything.
|
|
65
|
+
//
|
|
66
|
+
// Without it the figure that prices a move could only ever be learned from
|
|
67
|
+
// runs that survived, so the state in which moves are ruinous was exactly
|
|
68
|
+
// the state in which their cost stayed unknown.
|
|
69
|
+
if (!Number.isFinite(ended?.firstOutputMs) && Number.isFinite(ended?.livedMs) && ended.livedMs > 0) {
|
|
70
|
+
RunCosts.#keep(this.#firstOutput, /** @type {number} */ (ended.livedMs));
|
|
71
|
+
}
|
|
59
72
|
if (Number.isFinite(ended?.dyingMs)) {
|
|
60
73
|
RunCosts.#keep(this.#dying, /** @type {number} */ (ended.dyingMs));
|
|
61
74
|
}
|
|
@@ -85,9 +98,47 @@ export class RunCosts {
|
|
|
85
98
|
* @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
|
|
86
99
|
*/
|
|
87
100
|
seconds() {
|
|
101
|
+
const dying = middleOf(this.#dying);
|
|
102
|
+
const first = middleOf(this.#firstOutput);
|
|
88
103
|
return {
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
// UNKNOWN IS NOT ZERO, and for a cost it is not a small number either: it
|
|
105
|
+
// is the figure that makes the act it prices never worth doing. Reported
|
|
106
|
+
// as 0, an unmeasured move was FREE in the plan's arithmetic, so any gain
|
|
107
|
+
// however small justified it — and moving an encoder is irreversible,
|
|
108
|
+
// because the process it kills cannot be un-killed.
|
|
109
|
+
//
|
|
110
|
+
// The blindness was self-sustaining: `#firstOutput` only takes a reading
|
|
111
|
+
// from a run that produced something, and a run killed 0.8 s after
|
|
112
|
+
// starting produces nothing. So a thrash prevented the measurement that
|
|
113
|
+
// would have stopped it. Field 2026-09-08: 39 moves in one session, 24 of
|
|
114
|
+
// them between three adjacent numbers — #58 to #59, #59 to #58, #58 to
|
|
115
|
+
// #60, #60 to #58, six times each — while the picture stood still for
|
|
116
|
+
// 116.7 s.
|
|
117
|
+
//
|
|
118
|
+
// TWO QUESTIONS, NOT ONE, and they take the unknown differently.
|
|
119
|
+
//
|
|
120
|
+
// PLACING an encoder where there is none has no alternative: the film gets
|
|
121
|
+
// made or it does not. So an unmeasured cost must not stand in the way,
|
|
122
|
+
// and the honest figure is what has been measured or nothing.
|
|
123
|
+
//
|
|
124
|
+
// MOVING one has an alternative — leave it alone — and it is
|
|
125
|
+
// irreversible, because the process it kills cannot be un-killed. There
|
|
126
|
+
// an unmeasured cost must not license the act, and `Infinity` is the
|
|
127
|
+
// identity of the comparison that consumes it: "nobody has measured what
|
|
128
|
+
// this costs" and "never worth doing" are the same statement about an
|
|
129
|
+
// action whose price is unknown.
|
|
130
|
+
//
|
|
131
|
+
// Reported as 0 for both, an unmeasured move was FREE in the plan's
|
|
132
|
+
// arithmetic, so a gain of a fraction of a second justified it. And the
|
|
133
|
+
// blindness was self-sustaining: `#firstOutput` takes a reading only from
|
|
134
|
+
// a run that produced something, and every run in a thrash is killed
|
|
135
|
+
// before it finishes anything.
|
|
136
|
+
killCostSec: (dying ?? 0) / 1000,
|
|
137
|
+
firstByteWaitSec: (first ?? 0) / 1000,
|
|
138
|
+
moveCostSec:
|
|
139
|
+
first === null
|
|
140
|
+
? Number.POSITIVE_INFINITY
|
|
141
|
+
: ((dying ?? 0) + first) / 1000,
|
|
91
142
|
samples: Math.min(this.#dying.length, this.#firstOutput.length)
|
|
92
143
|
};
|
|
93
144
|
}
|
|
@@ -1510,7 +1510,11 @@ export class HlsSessionManager {
|
|
|
1510
1510
|
// picture a step belongs to, the steps, the soundtracks, the height a
|
|
1511
1511
|
// session is named by. Read-only over the register above, and the layer the
|
|
1512
1512
|
// quality budget and the serving path both stand on.
|
|
1513
|
-
this.liveOutputs = new LiveOutputs({
|
|
1513
|
+
this.liveOutputs = new LiveOutputs({
|
|
1514
|
+
sessionsById: this.sessionsById,
|
|
1515
|
+
fileLengthOf: (session) => this.#fileLengthByKey.get(session.file.key) ?? 0,
|
|
1516
|
+
largestPieceOf: (address) => this.segmentStore.largestPiece(address)
|
|
1517
|
+
});
|
|
1514
1518
|
// What this host learned last time it ran. Without it every restart shows
|
|
1515
1519
|
// the first viewer a figure with no measurement behind it.
|
|
1516
1520
|
this.#loadHostTimings();
|
|
@@ -8317,7 +8321,7 @@ export class HlsSessionManager {
|
|
|
8317
8321
|
// the live judgement travels in `offeredHeights` and in every progress
|
|
8318
8322
|
// report, and letting it decide the master's existence made a live session
|
|
8319
8323
|
// answer 404 to its own published address.
|
|
8320
|
-
...this.liveOutputs.masterFactsOf(session
|
|
8324
|
+
...this.liveOutputs.masterFactsOf(session),
|
|
8321
8325
|
renditions,
|
|
8322
8326
|
playlistFileName: PLAYLIST_FILE_NAME
|
|
8323
8327
|
});
|
|
@@ -24,8 +24,14 @@ export class LiveOutputs {
|
|
|
24
24
|
* @param {Map<string, object>} params.sessionsById - The live sessions. Read,
|
|
25
25
|
* never written.
|
|
26
26
|
*/
|
|
27
|
-
constructor({ sessionsById }) {
|
|
27
|
+
constructor({ sessionsById, fileLengthOf = () => 0, largestPieceOf = () => ({ index: -1, size: 0 }) }) {
|
|
28
28
|
this.sessionsById = sessionsById;
|
|
29
|
+
// Two facts this layer needs and does not own: how many bytes a source file
|
|
30
|
+
// is, which the torrent reports, and the biggest piece an output has made,
|
|
31
|
+
// which the disk knows. Taken as plain functions, so nothing of either layer
|
|
32
|
+
// is held here.
|
|
33
|
+
this.fileLengthOf = fileLengthOf;
|
|
34
|
+
this.largestPieceOf = largestPieceOf;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -278,21 +284,25 @@ export class LiveOutputs {
|
|
|
278
284
|
* the asking VIEWER'S business, and that belongs to whoever holds viewers.
|
|
279
285
|
*
|
|
280
286
|
* @param {object} session
|
|
281
|
-
* @param {(address: string) => { index: number, size: number }} largestPiece -
|
|
282
|
-
* The biggest piece an output has made, asked of whoever owns the disk. A
|
|
283
|
-
* plain function, so this layer holds no store.
|
|
284
287
|
* @returns {object}
|
|
285
288
|
*/
|
|
286
|
-
masterFactsOf(session
|
|
289
|
+
masterFactsOf(session) {
|
|
287
290
|
return {
|
|
288
291
|
playlistVersion: session.segmentFormat.playlistVersion,
|
|
289
292
|
heights: this.splicableHeights(session),
|
|
290
293
|
sourceWidth: Number(session.file?.width) || 0,
|
|
291
294
|
sourceHeight: Number(session.file?.height) || 0,
|
|
292
295
|
...masterRateArgs({
|
|
293
|
-
|
|
296
|
+
// FROM WHOEVER KNOWS IT. A source file does not carry its own byte
|
|
297
|
+
// length — the torrent reports it — and reading a field that does not
|
|
298
|
+
// exist is what declared every variant at the floor in 2.80.14.
|
|
299
|
+
fileLength: Number(this.fileLengthOf(session)) || 0,
|
|
294
300
|
durationSeconds: Number(session.file?.durationSeconds) || 0,
|
|
295
|
-
|
|
301
|
+
// The probe's own reading of the video stream, which is known from the
|
|
302
|
+
// moment the session exists and covers the gap before the torrent has
|
|
303
|
+
// reported a length.
|
|
304
|
+
streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
|
|
305
|
+
largest: this.largestPieceOf(session.outputKey ?? ""),
|
|
296
306
|
boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
|
|
297
307
|
producedHeight: this.producedHeightOf(session),
|
|
298
308
|
capKbps: Number(session.rateCapKbps) || 0
|
package/services/output/rates.js
CHANGED
|
@@ -21,21 +21,49 @@
|
|
|
21
21
|
* The two rates to declare, from what has been measured of this file.
|
|
22
22
|
*
|
|
23
23
|
* @param {object} params
|
|
24
|
-
* @param {number} params.fileLength - Bytes of the source file
|
|
24
|
+
* @param {number} params.fileLength - Bytes of the source file, 0 until the
|
|
25
|
+
* torrent has said. Counts every track and every byte of container, so it is
|
|
26
|
+
* the better figure of the two.
|
|
25
27
|
* @param {number} params.durationSeconds
|
|
28
|
+
* @param {number} [params.streamBitsPerSecond] - What a probe read off the
|
|
29
|
+
* video stream, known from session creation. Lower than the whole file's rate
|
|
30
|
+
* because it is one track of it, and used while the length is not known.
|
|
26
31
|
* @param {{ index: number, size: number }} [params.largest] - The biggest piece
|
|
27
32
|
* produced so far, with its number. An index of `-1` means none yet.
|
|
28
33
|
* @param {number[]} [params.boundaries] - Where the file is cut, so the biggest
|
|
29
34
|
* piece's own span is known. Bytes alone cannot give a rate.
|
|
30
35
|
* @returns {{ averageBitsPerSecond: number, peakOverAverage: number }}
|
|
31
36
|
*/
|
|
32
|
-
export function declaredRates({
|
|
37
|
+
export function declaredRates({
|
|
38
|
+
fileLength,
|
|
39
|
+
durationSeconds,
|
|
40
|
+
streamBitsPerSecond = 0,
|
|
41
|
+
largest = null,
|
|
42
|
+
boundaries = null
|
|
43
|
+
}) {
|
|
33
44
|
const length = Number(fileLength);
|
|
34
45
|
const duration = Number(durationSeconds);
|
|
35
|
-
|
|
46
|
+
// TWO SOURCES, AND THE LARGER OF THEM, because each is measured and each can
|
|
47
|
+
// be absent — and absent is 0, so the larger is whichever was measured.
|
|
48
|
+
//
|
|
49
|
+
// - the whole file's length over its duration: the best figure, since it
|
|
50
|
+
// counts every track and every byte of container. Known only once the
|
|
51
|
+
// torrent has reported the file's size;
|
|
52
|
+
// - what the probe read off the video stream: known when the session is
|
|
53
|
+
// created, and lower, because it is one track of several.
|
|
54
|
+
//
|
|
55
|
+
// The first alone was what shipped in 2.80.14, read from a field that does
|
|
56
|
+
// not exist on a source file. It came back 0 on every session, so every
|
|
57
|
+
// variant was declared at the 400 kbit/s floor — nine times WORSE than the
|
|
58
|
+
// rule of thumb it replaced — and the browser, which sizes its cushion in
|
|
59
|
+
// bytes from this figure, held 0.1 s of film against 120 s asked. The picture
|
|
60
|
+
// stood still for 116.7 s of one viewing. Assuming a field instead of
|
|
61
|
+
// checking it is the whole of that fault.
|
|
62
|
+
const fromLength = length > 0 && duration > 0 ? (length * 8) / duration : 0;
|
|
63
|
+
const averageBitsPerSecond = Math.max(fromLength, Number(streamBitsPerSecond) || 0);
|
|
64
|
+
if (!(averageBitsPerSecond > 0)) {
|
|
36
65
|
return { averageBitsPerSecond: 0, peakOverAverage: 1 };
|
|
37
66
|
}
|
|
38
|
-
const averageBitsPerSecond = (length * 8) / duration;
|
|
39
67
|
const index = Number(largest?.index);
|
|
40
68
|
const size = Number(largest?.size);
|
|
41
69
|
if (!Number.isInteger(index) || index < 0 || !(size > 0) || !Array.isArray(boundaries)) {
|
|
@@ -73,6 +101,7 @@ export function declaredRates({ fileLength, durationSeconds, largest = null, bou
|
|
|
73
101
|
* @param {object} params
|
|
74
102
|
* @param {number} params.fileLength
|
|
75
103
|
* @param {number} params.durationSeconds
|
|
104
|
+
* @param {number} [params.streamBitsPerSecond]
|
|
76
105
|
* @param {{ index: number, size: number } | null} [params.largest]
|
|
77
106
|
* @param {number[] | null} [params.boundaries]
|
|
78
107
|
* @param {number} [params.producedHeight] - The height this output encodes at.
|
|
@@ -83,13 +112,14 @@ export function declaredRates({ fileLength, durationSeconds, largest = null, bou
|
|
|
83
112
|
export function masterRateArgs({
|
|
84
113
|
fileLength,
|
|
85
114
|
durationSeconds,
|
|
115
|
+
streamBitsPerSecond = 0,
|
|
86
116
|
largest = null,
|
|
87
117
|
boundaries = null,
|
|
88
118
|
producedHeight = 0,
|
|
89
119
|
capKbps = 0
|
|
90
120
|
}) {
|
|
91
121
|
return {
|
|
92
|
-
...declaredRates({ fileLength, durationSeconds, largest, boundaries }),
|
|
122
|
+
...declaredRates({ fileLength, durationSeconds, streamBitsPerSecond, largest, boundaries }),
|
|
93
123
|
capKbpsFor: (height) => (height === producedHeight ? capKbps : 0)
|
|
94
124
|
};
|
|
95
125
|
}
|
package/test/encode-plan.test.js
CHANGED
|
@@ -367,9 +367,15 @@ test("the one machine goes to whoever is due soonest, not to the smallest number
|
|
|
367
367
|
...HOST,
|
|
368
368
|
maxRuns: 2
|
|
369
369
|
});
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
370
|
+
// WHERE the machine ends up, not how it got there. A run standing at #900
|
|
371
|
+
// reaches nothing anybody wants, so it is taken to #500 rather than killed and
|
|
372
|
+
// replaced — one process instead of a death and a cold start, which is
|
|
373
|
+
// strictly better and is what the plan now answers.
|
|
374
|
+
const placed = actions
|
|
375
|
+
.filter((action) => action.type === "start" || action.type === "move")
|
|
376
|
+
.map((action) => action.from);
|
|
377
|
+
|
|
378
|
+
assert.deepEqual(placed, [500], "the one machine goes where somebody is stopped");
|
|
373
379
|
});
|
|
374
380
|
|
|
375
381
|
test("two viewers far apart are both served, by however many encoders serve them soonest", () => {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What moving a running encoder costs, and what the plan does while
|
|
3
|
+
* nobody has measured it.
|
|
4
|
+
*
|
|
5
|
+
* Field 2026-09-08: 39 moves in one session, 24 of them between three adjacent
|
|
6
|
+
* numbers — #58 to #59, #59 to #58, #58 to #60, #60 to #58, six times each,
|
|
7
|
+
* about 0.8 s apart — while the viewer's picture stood still for 116.7 s in
|
|
8
|
+
* three interruptions, the worst of them 91.8 s. The zone the viewer's own
|
|
9
|
+
* position defines slides forward one number at a time, and every slide made
|
|
10
|
+
* standing one number behind it score worse than standing in it.
|
|
11
|
+
*
|
|
12
|
+
* THREE FAULTS IN THE ARITHMETIC, and no threshold anywhere. There was one for
|
|
13
|
+
* a while — "a move must beat staying by at least what moving costs" — and it
|
|
14
|
+
* was a prop under a comparison that was wrong rather than indifferent. It is
|
|
15
|
+
* gone.
|
|
16
|
+
*
|
|
17
|
+
* 1. **A body was charged a whole piece for the one it was already making.**
|
|
18
|
+
* `arrival = delay + (index - at + 1) / rate` is right for a body that does
|
|
19
|
+
* not exist yet and wrong for a run 0.8 s into a 0.9 s piece. `delaySec` is
|
|
20
|
+
* now when the body finishes the piece it STANDS ON, so from #58 reaching #59
|
|
21
|
+
* costs 0.14 + 0.94 = 1.08 s against 1.88 s for a kill and a cold start — a
|
|
22
|
+
* decision by eight hundred milliseconds, where the double charge had made it
|
|
23
|
+
* a coin flip lost by ten.
|
|
24
|
+
* 2. **The piece was priced at the unpenalised rate** while arrivals used the
|
|
25
|
+
* penalised one, so every extra body looked cheaper than it is and the plan
|
|
26
|
+
* bought a second encoder where one served. One rate, the one in force.
|
|
27
|
+
* 3. **`withinSeconds: null` was read through `Number()`**, where it is 0, so
|
|
28
|
+
* the film BEHIND the viewers was due immediately and was the most urgent
|
|
29
|
+
* material in the file. It bought encoders and it took the run standing in
|
|
30
|
+
* front of the viewer, because that run was the nearest body to it.
|
|
31
|
+
*
|
|
32
|
+
* And what a move costs is `Infinity` until something has been measured, because
|
|
33
|
+
* a move is irreversible while leaving the encoder alone is always available. A
|
|
34
|
+
* run killed before producing anything is a measurement too — a lower bound on
|
|
35
|
+
* the first output — which is the only reading a thrash can supply.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import test from "node:test";
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
import { RunCosts } from "../services/encode/run-costs.js";
|
|
41
|
+
import { planEncoders } from "../services/encode/EncodePlan.js";
|
|
42
|
+
import { CoverageMap } from "../services/encode/CoverageMap.js";
|
|
43
|
+
|
|
44
|
+
test("nothing measured means a move is refused, not priced at zero", () => {
|
|
45
|
+
const costs = new RunCosts();
|
|
46
|
+
|
|
47
|
+
const { moveCostSec, firstByteWaitSec, killCostSec } = costs.seconds();
|
|
48
|
+
assert.equal(moveCostSec, Number.POSITIVE_INFINITY, "moving is not free while unpriced");
|
|
49
|
+
// Placing one where there is none is the OTHER question, and it has no
|
|
50
|
+
// alternative: the film gets made or it does not.
|
|
51
|
+
assert.equal(firstByteWaitSec, 0, "placing an encoder is not blocked by an unknown price");
|
|
52
|
+
assert.equal(killCostSec, 0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a run killed before producing anything is a lower bound on the first output", () => {
|
|
56
|
+
const costs = new RunCosts();
|
|
57
|
+
|
|
58
|
+
// Exactly what a thrash supplies: a run that lived 800 ms and finished
|
|
59
|
+
// nothing. It says the first output takes AT LEAST that long, which is a fact.
|
|
60
|
+
costs.note({ livedMs: 800, dyingMs: 40 });
|
|
61
|
+
|
|
62
|
+
const { moveCostSec } = costs.seconds();
|
|
63
|
+
assert.ok(Number.isFinite(moveCostSec), "one killed run is enough to stop the blindness");
|
|
64
|
+
assert.ok(Math.abs(moveCostSec - 0.84) < 0.001, `got ${moveCostSec}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a run that produced something is measured by its first output, not its life", () => {
|
|
68
|
+
const costs = new RunCosts();
|
|
69
|
+
|
|
70
|
+
costs.note({ livedMs: 60_000, firstOutputMs: 900, dyingMs: 100 });
|
|
71
|
+
|
|
72
|
+
const { moveCostSec, firstByteWaitSec } = costs.seconds();
|
|
73
|
+
assert.ok(Math.abs(firstByteWaitSec - 0.9) < 0.001, `got ${firstByteWaitSec}`);
|
|
74
|
+
assert.ok(Math.abs(moveCostSec - 1.0) < 0.001, `got ${moveCostSec}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the zone sliding one number does not move an encoder that is already reaching it", () => {
|
|
78
|
+
// The field shape exactly: a run standing at #58 with the viewer's urgent zone
|
|
79
|
+
// sliding #58..#59 → #59..#60. Driving through one segment costs the encoder a
|
|
80
|
+
// fraction of a second; moving costs a kill and a cold start.
|
|
81
|
+
const coverage = new CoverageMap();
|
|
82
|
+
coverage.setSegmentCount(482);
|
|
83
|
+
const run = { from: 58, to: 481, head: 58, speedX: 4.45, isAlive: true };
|
|
84
|
+
coverage.claim(run, 58, 481);
|
|
85
|
+
|
|
86
|
+
const actions = planEncoders({
|
|
87
|
+
coverage,
|
|
88
|
+
windows: [
|
|
89
|
+
{ from: 0, to: 57, priority: 1, withinSeconds: null, behind: true },
|
|
90
|
+
{ from: 59, to: 60, priority: 100, withinSeconds: 0, behind: false }
|
|
91
|
+
],
|
|
92
|
+
runs: [run],
|
|
93
|
+
maxRuns: 3,
|
|
94
|
+
segmentSeconds: 4.2,
|
|
95
|
+
speedX: 4.45,
|
|
96
|
+
// Measured on this host: killing takes 40 ms, a fresh encoder's first piece
|
|
97
|
+
// 900 ms. Against that, driving one segment at 4.45x costs 0.94 s — so the
|
|
98
|
+
// two are close, and what settles it is that the move ALSO has to encode
|
|
99
|
+
// the same segment afterwards.
|
|
100
|
+
killCostSec: 0.04,
|
|
101
|
+
firstByteWaitSec: 0.9,
|
|
102
|
+
moveCostSec: 0.94,
|
|
103
|
+
refetchSecPerFilmSecond: 0,
|
|
104
|
+
contentionPenaltyFor: () => 1
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
actions.filter((one) => one.type === "move"),
|
|
109
|
+
[],
|
|
110
|
+
"a run one number behind the zone is already on its way into it"
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a move that genuinely saves the viewer time still happens", () => {
|
|
115
|
+
// The other half: the viewer jumped fourteen segments ahead, and driving there
|
|
116
|
+
// at 4.45x would take 13 s while a cold start takes 0.94 s. Refusing this
|
|
117
|
+
// would be the opposite fault.
|
|
118
|
+
const coverage = new CoverageMap();
|
|
119
|
+
coverage.setSegmentCount(482);
|
|
120
|
+
const run = { from: 0, to: 481, head: 44, speedX: 4.45, isAlive: true };
|
|
121
|
+
coverage.claim(run, 0, 481);
|
|
122
|
+
|
|
123
|
+
const actions = planEncoders({
|
|
124
|
+
coverage,
|
|
125
|
+
windows: [{ from: 58, to: 59, priority: 100, withinSeconds: 0, behind: false }],
|
|
126
|
+
runs: [run],
|
|
127
|
+
maxRuns: 3,
|
|
128
|
+
segmentSeconds: 4.2,
|
|
129
|
+
speedX: 4.45,
|
|
130
|
+
killCostSec: 0.04,
|
|
131
|
+
firstByteWaitSec: 0.9,
|
|
132
|
+
moveCostSec: 0.94,
|
|
133
|
+
refetchSecPerFilmSecond: 0,
|
|
134
|
+
contentionPenaltyFor: () => 1
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// WHERE the encoder ends up, not how it got there: the run at #44 reaches
|
|
138
|
+
// nothing anybody waits for, so the plan may either take it to #58 or stop it
|
|
139
|
+
// and start one there. Both are one process at #58, and which is cheaper is
|
|
140
|
+
// the measured difference between a kill and a cold start.
|
|
141
|
+
const placed = actions
|
|
142
|
+
.filter((one) => one.type === "move" || one.type === "start")
|
|
143
|
+
.map((one) => one.from);
|
|
144
|
+
assert.deepEqual(placed, [58], "fourteen segments of driving is worth a cold start");
|
|
145
|
+
});
|