@torrent-tv/proxy 2.76.2 → 2.76.4
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 +11 -0
- package/package.json +1 -1
- package/services/encode/CoverageMap.js +48 -1
- package/services/encode/EncodePlan.js +20 -3
- package/services/encode/EncodeRun.js +21 -1
- package/services/hls-session-manager.js +13 -4
- package/services/orchestrators/EncodeOrchestrator.js +42 -3
- package/test/auto-quality-step.test.js +0 -1
- package/test/behind-head-repair.test.js +0 -1
- package/test/coverage-map.test.js +25 -0
- package/test/encode-orchestrator.test.js +31 -0
- package/test/encode-plan.test.js +33 -0
- package/test/run-intervals.test.js +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.76.4
|
|
2
|
+
|
|
3
|
+
- **Fix**: An encoder was started and killed every five seconds, each one producing 0-2 segments, for as long as anybody watched. Measured on the addon host: a run given no end carries a `to` below its `from` — which is how "no end" is written everywhere here — and two places read that as a number instead. The plan's test for "is anybody waiting for what this run was given" said no, so it was stopped as unwanted; and the stretch it claimed in the coverage map collapsed to a single segment, so the plan saw the rest of the film as free and started another encoder one number along. The two together are the loop. The rule is stated once now (`endOfRun`) and read from that one place, including where a run's own ending is judged.
|
|
4
|
+
- **Fix**: A run with no end holds the look-ahead in front of it, not the rest of the film. It is what actually bounds one — a run is suspended once it is that far in front of the segment its viewer asked for and produces nothing until somebody asks — and claiming further would leave a viewer who opens the same film further in with no encoder at all, waiting for that run to encode its way there. The rule was already applied where a session plans its own interval and not on the path the plan uses.
|
|
5
|
+
|
|
6
|
+
## 2.76.3
|
|
7
|
+
|
|
8
|
+
- **Fix**: The proxy stopped answering anything — playback, health, its own log — a few seconds after a viewer opened a film, and burned a whole processor doing it. Measured on the addon host with the stack read out of the live process: the look-ahead timer asked the plan where a new encoder could start, and the walk that answers that walked one segment number at a time towards nine quadrillion, scanning every claim at each step. It did that because the map had no length, and the map had no length because the field naming it moved onto the timeline in 2.76.0 while three readers were left on the old name, where every session answers `undefined`. Those three are the whole defect: with them wrong, no run was ever given an end either, so the feature that lets two encoders share one output was inert as well.
|
|
9
|
+
- **Fix**: That walk can no longer do this whatever the length says. With no length known there is nothing to walk towards, and the answer — where the free stretch ends — is read off what the map already holds: the segments made and the stretches claimed, both finite however long the film is. A run then gets no end, which is what "the length is unknown" honestly means.
|
|
10
|
+
- **Chore**: Three test fixtures stated the moved field on the session, so the checks went on passing over code that could not work. They state the timeline now, which is where the product reads it.
|
|
11
|
+
|
|
1
12
|
## 2.76.2
|
|
2
13
|
|
|
3
14
|
- **Fix**: How long a session waits for a film's keyframe table is bounded, and the bound is one the read already had rather than a new number. That table decides which branch a picture takes — with it the picture is passed through untouched, without it the whole picture is re-encoded — and nothing limited the wait, while the file comes off a torrent and the bytes the table lives in may still be arriving. Measured on the addon host over seventeen files from four containers, pieces from 0.25 to 16 MB (`research/keyframe-table-read-2026-09-04.md`): every table that arrived did so within 24.8 s and most within half a second, while two files answered nothing for 120.9 s and 120.5 s — which is exactly TWO of the sixty-second bound the read already has, one for the wait on the file's edges and one for the read, in series. A session now waits for one of them. The read is not cancelled: it goes on, is remembered on the file, and the next session of that file gets the copy.
|
package/package.json
CHANGED
|
@@ -251,7 +251,22 @@ export class CoverageMap {
|
|
|
251
251
|
*/
|
|
252
252
|
freeRunFrom(index, exceptRun = null) {
|
|
253
253
|
const start = Number.isInteger(index) && index > 0 ? index : 0;
|
|
254
|
-
|
|
254
|
+
if (this.#segmentCount <= 0) {
|
|
255
|
+
// The length is not known, so how far the free stretch reaches is not
|
|
256
|
+
// known either, and the honest answer is "as far as there is film" — the
|
|
257
|
+
// caller turns that into a run with no end.
|
|
258
|
+
//
|
|
259
|
+
// Never walked one number at a time to find that out. It used to be, up to
|
|
260
|
+
// MAX_SAFE_INTEGER, with a scan of every claim at each step: on the addon
|
|
261
|
+
// host, 2026-09-05, the main thread spun at 100% from the look-ahead timer
|
|
262
|
+
// and the proxy stopped answering anything at all, its own log included.
|
|
263
|
+
// The length was missing because the field naming it had moved and three
|
|
264
|
+
// readers were left on the old name — but a walk whose end depends on a
|
|
265
|
+
// field being present must not be able to do this even then.
|
|
266
|
+
const covered = this.#firstCoveredFrom(start, exceptRun);
|
|
267
|
+
return covered === null ? Number.POSITIVE_INFINITY : covered - start;
|
|
268
|
+
}
|
|
269
|
+
const last = this.#segmentCount - 1;
|
|
255
270
|
let at = start;
|
|
256
271
|
while (at <= last) {
|
|
257
272
|
if (this.#ready.has(at)) {
|
|
@@ -266,6 +281,38 @@ export class CoverageMap {
|
|
|
266
281
|
return at - start;
|
|
267
282
|
}
|
|
268
283
|
|
|
284
|
+
/**
|
|
285
|
+
* The first number at or after `index` that somebody has made or is making,
|
|
286
|
+
* or null when nobody has touched anything from there on.
|
|
287
|
+
*
|
|
288
|
+
* Asked of what the map HOLDS rather than by walking the numbers, so it can be
|
|
289
|
+
* answered without a length: the map knows every ready number and every claim,
|
|
290
|
+
* and both are finite however long the film is.
|
|
291
|
+
*
|
|
292
|
+
* @param {number} index
|
|
293
|
+
* @param {object | null} exceptRun
|
|
294
|
+
* @returns {number | null}
|
|
295
|
+
*/
|
|
296
|
+
#firstCoveredFrom(index, exceptRun) {
|
|
297
|
+
let lowest = null;
|
|
298
|
+
for (const ready of this.#ready) {
|
|
299
|
+
if (ready >= index && (lowest === null || ready < lowest)) {
|
|
300
|
+
lowest = ready;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const [run, span] of this.#claims) {
|
|
304
|
+
if (run === exceptRun) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
// A claim that has already begun covers `index` itself.
|
|
308
|
+
const covers = span.from <= index && index <= span.to ? index : span.from;
|
|
309
|
+
if (covers >= index && (lowest === null || covers < lowest)) {
|
|
310
|
+
lowest = covers;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return lowest;
|
|
314
|
+
}
|
|
315
|
+
|
|
269
316
|
/**
|
|
270
317
|
* What this map holds, for a log line.
|
|
271
318
|
*
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
* own answer — the look-ahead, which suspends a run rather than bounding it.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
+
import { endOfRun } from "./EncodeRun.js";
|
|
42
|
+
|
|
41
43
|
/**
|
|
42
44
|
* One encoder that is running now.
|
|
43
45
|
*
|
|
@@ -138,7 +140,7 @@ export function planEncoders({
|
|
|
138
140
|
for (const run of live) {
|
|
139
141
|
// 1. Is anybody waiting for what this run was given? A run whose stretch
|
|
140
142
|
// touches no window is making material nobody has asked for.
|
|
141
|
-
const stillWanted = wanted.some((span) => overlaps(run.from, run
|
|
143
|
+
const stillWanted = wanted.some((span) => overlaps(run.from, endOfRun(run), span.from, span.to));
|
|
142
144
|
if (!stillWanted) {
|
|
143
145
|
stops.push({ type: "stop", run, because: "nothing it was given is wanted" });
|
|
144
146
|
continue;
|
|
@@ -184,7 +186,7 @@ export function planEncoders({
|
|
|
184
186
|
type: "move",
|
|
185
187
|
run,
|
|
186
188
|
from: gap,
|
|
187
|
-
to: gap
|
|
189
|
+
to: endOfStretch(gap, free),
|
|
188
190
|
because: driveSec === null
|
|
189
191
|
? `${coveredAhead} segment(s) ahead are already covered and its speed is not measured`
|
|
190
192
|
: `driving through ${coveredAhead} covered segment(s) costs ${driveSec.toFixed(2)}s ` +
|
|
@@ -219,7 +221,7 @@ export function planEncoders({
|
|
|
219
221
|
starts.push({
|
|
220
222
|
type: "start",
|
|
221
223
|
from: gap,
|
|
222
|
-
to: gap
|
|
224
|
+
to: endOfStretch(gap, free),
|
|
223
225
|
because: `#${gap} is wanted and nobody is making it`
|
|
224
226
|
});
|
|
225
227
|
}
|
|
@@ -227,6 +229,21 @@ export function planEncoders({
|
|
|
227
229
|
return [...stops, ...moves, ...starts, ...keeps];
|
|
228
230
|
}
|
|
229
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The last number of a stretch that begins at `from` and is `length` long.
|
|
234
|
+
*
|
|
235
|
+
* `-1` when the length is not finite, which is this layer's word for a run with
|
|
236
|
+
* no end: the film's length is not known, so there is nothing to stop it at, and
|
|
237
|
+
* a number invented here would be an end nobody measured.
|
|
238
|
+
*
|
|
239
|
+
* @param {number} from
|
|
240
|
+
* @param {number} length
|
|
241
|
+
* @returns {number}
|
|
242
|
+
*/
|
|
243
|
+
function endOfStretch(from, length) {
|
|
244
|
+
return Number.isFinite(length) ? from + Math.max(1, length) - 1 : -1;
|
|
245
|
+
}
|
|
246
|
+
|
|
230
247
|
/**
|
|
231
248
|
* The lowest number a viewer is waiting for that is not ready — what the plan
|
|
232
249
|
* is judged by.
|
|
@@ -63,6 +63,26 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
|
63
63
|
* @property {string} lastError - The last thing ffmpeg said on stderr.
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The last number this run was given, or Infinity when it was given no end.
|
|
68
|
+
*
|
|
69
|
+
* ONE reading of one convention. "No end" is written as a `to` below `from` —
|
|
70
|
+
* the session layer returns -1 for it and the log prints `#-1` — and every place
|
|
71
|
+
* that has to compare against it read that for itself. One place did not: the
|
|
72
|
+
* coverage map was handed the raw -1, `Math.max(from, -1)` made the claim one
|
|
73
|
+
* segment long, and the plan then saw the rest of the film as free. Field,
|
|
74
|
+
* 2026-09-05: an encoder was started and killed every five seconds, each one
|
|
75
|
+
* producing 0-2 segments, for as long as anybody watched.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ from: number, to: number }} run
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
export function endOfRun(run) {
|
|
81
|
+
const from = Number(run?.from);
|
|
82
|
+
const to = Number(run?.to);
|
|
83
|
+
return Number.isInteger(to) && to >= from ? to : Number.POSITIVE_INFINITY;
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
export class EncodeRun {
|
|
67
87
|
/** @type {import("node:child_process").ChildProcess | null} */
|
|
68
88
|
#process = null;
|
|
@@ -466,7 +486,7 @@ export class EncodeRun {
|
|
|
466
486
|
// one, and the end of the film where it was not. A run told to make #10..#14
|
|
467
487
|
// that exits cleanly at #11 has not finished, whatever the film's length;
|
|
468
488
|
// and a run with no end has nothing but the film to be measured against.
|
|
469
|
-
const endOfWork =
|
|
489
|
+
const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
|
|
470
490
|
const outcome = classifyEncodeExit({
|
|
471
491
|
code,
|
|
472
492
|
producedThrough: this.#produced.size > 0 ? this.reached : null,
|
|
@@ -68,6 +68,7 @@ import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./ou
|
|
|
68
68
|
import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
|
|
69
69
|
import { ProducedIndex } from "./produced-index.js";
|
|
70
70
|
import { SegmentStore } from "./encode/SegmentStore.js";
|
|
71
|
+
import { endOfRun } from "./encode/EncodeRun.js";
|
|
71
72
|
import {
|
|
72
73
|
buildRunCommand,
|
|
73
74
|
ffmpegSeconds,
|
|
@@ -1807,6 +1808,7 @@ export class HlsSessionManager {
|
|
|
1807
1808
|
makeRun: ({ address, from }) => this.#makeRunAt(address, from),
|
|
1808
1809
|
segmentSeconds: this.segmentDurationSec,
|
|
1809
1810
|
restartCostSec: RUN_RESTART_COST_SEC,
|
|
1811
|
+
lookaheadSegments: Math.ceil(this.lookaheadSeconds / this.segmentDurationSec),
|
|
1810
1812
|
logger
|
|
1811
1813
|
});
|
|
1812
1814
|
// Where each file is cut, held once per file and grid rather than once per
|
|
@@ -3856,7 +3858,13 @@ export class HlsSessionManager {
|
|
|
3856
3858
|
const now = Date.now();
|
|
3857
3859
|
for (const [address, sessions] of byOutput) {
|
|
3858
3860
|
const coverage = this.encodeOrchestrator.coverageOf(address);
|
|
3859
|
-
|
|
3861
|
+
// From the TIMELINE, which is where how a file is cut has lived since
|
|
3862
|
+
// 2.76.0. Read off the session it left, this was `undefined` on every
|
|
3863
|
+
// session ever made: the map then held no length, and the walk that
|
|
3864
|
+
// gives a run its end ran to MAX_SAFE_INTEGER — the main thread spun at
|
|
3865
|
+
// 100% and the proxy answered nothing, measured on the addon host
|
|
3866
|
+
// 2026-09-05 with the stack read out of the live process.
|
|
3867
|
+
const segmentCount = Number(sessions[0].timeline?.segmentCount) || 0;
|
|
3860
3868
|
if (segmentCount > 0) {
|
|
3861
3869
|
coverage.setSegmentCount(segmentCount);
|
|
3862
3870
|
}
|
|
@@ -5461,7 +5469,7 @@ export class HlsSessionManager {
|
|
|
5461
5469
|
|
|
5462
5470
|
planRunInterval(session, startIndex, exceptRun = null) {
|
|
5463
5471
|
const key = session.outputKey ?? "";
|
|
5464
|
-
const lastIndex = (Number(session.segmentCount) || 0) - 1;
|
|
5472
|
+
const lastIndex = (Number(session.timeline?.segmentCount) || 0) - 1;
|
|
5465
5473
|
if (!key || lastIndex < 0) {
|
|
5466
5474
|
// Nothing to plan against: no address, or no playlist yet. The run keeps
|
|
5467
5475
|
// the shape it has always had — start here, no end.
|
|
@@ -5489,7 +5497,7 @@ export class HlsSessionManager {
|
|
|
5489
5497
|
// the honest extent of its claim, and it is a measured figure rather than
|
|
5490
5498
|
// a chosen one — the same allowance the browser sizes its cushion from.
|
|
5491
5499
|
const willReach = run.head + lookaheadSegments;
|
|
5492
|
-
const allowed = Number.
|
|
5500
|
+
const allowed = Number.isFinite(endOfRun(run)) ? run.to : lastIndex;
|
|
5493
5501
|
claims.push({ from: run.from, to: Math.min(allowed, willReach) });
|
|
5494
5502
|
}
|
|
5495
5503
|
const takenAt = (index) =>
|
|
@@ -5692,7 +5700,8 @@ export class HlsSessionManager {
|
|
|
5692
5700
|
// The film's last segment number, which is what tells "it reached the
|
|
5693
5701
|
// end" from "its input dried up": ffmpeg exits zero for both and over a
|
|
5694
5702
|
// torrent cannot tell them apart.
|
|
5695
|
-
lastSegmentIndex: () =>
|
|
5703
|
+
lastSegmentIndex: () =>
|
|
5704
|
+
session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
|
|
5696
5705
|
inputUnavailable: (message) => isInputUnavailable(message),
|
|
5697
5706
|
onProgress: (report) => this.#noteRunProgress(session, run, report),
|
|
5698
5707
|
onEnded: (ended) => this.#onRunEnded(session, run, ended)
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { CoverageMap } from "../encode/CoverageMap.js";
|
|
27
27
|
import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
|
|
28
|
+
import { endOfRun } from "../encode/EncodeRun.js";
|
|
28
29
|
import { ENCODE_EXIT } from "../encode/encode-exit.js";
|
|
29
30
|
import { SegmentDemand } from "../encode/SegmentDemand.js";
|
|
30
31
|
|
|
@@ -52,12 +53,17 @@ export class EncodeOrchestrator {
|
|
|
52
53
|
* @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
|
|
53
54
|
* @param {() => number} [params.now]
|
|
54
55
|
*/
|
|
55
|
-
constructor({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, logger, now }) {
|
|
56
|
+
constructor({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, lookaheadSegments = 0, logger, now }) {
|
|
56
57
|
this.demand = new SegmentDemand();
|
|
57
58
|
this.maxRunsFor = maxRunsFor;
|
|
58
59
|
this.makeRun = makeRun;
|
|
59
60
|
this.segmentSeconds = segmentSeconds;
|
|
60
61
|
this.restartCostSec = restartCostSec;
|
|
62
|
+
// How far in front of its viewer a run is allowed to get. It is what bounds
|
|
63
|
+
// the claim of a run that was given no end — see #claimFor.
|
|
64
|
+
this.lookaheadSegments = Number.isFinite(lookaheadSegments) && lookaheadSegments > 0
|
|
65
|
+
? Math.ceil(lookaheadSegments)
|
|
66
|
+
: 0;
|
|
61
67
|
this.logger = logger;
|
|
62
68
|
this.now = typeof now === "function" ? now : Date.now;
|
|
63
69
|
}
|
|
@@ -227,7 +233,7 @@ export class EncodeOrchestrator {
|
|
|
227
233
|
}
|
|
228
234
|
// A run that stays keeps its claim current: the free stretch ahead of it
|
|
229
235
|
// may have shrunk since it was given one.
|
|
230
|
-
coverage
|
|
236
|
+
this.#claimFor(coverage, action.run, action.from, action.to);
|
|
231
237
|
}
|
|
232
238
|
}
|
|
233
239
|
|
|
@@ -278,7 +284,40 @@ export class EncodeOrchestrator {
|
|
|
278
284
|
}
|
|
279
285
|
onThisOutput.push(run);
|
|
280
286
|
this.#runs.set(address, onThisOutput);
|
|
281
|
-
this.coverageOf(address)
|
|
287
|
+
this.#claimFor(this.coverageOf(address), run, run.from, run.to);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* What a run holds, as far as the map is concerned.
|
|
292
|
+
*
|
|
293
|
+
* A run given an end holds exactly that stretch. A run given NO end — `to`
|
|
294
|
+
* below `from`, which is how this is written everywhere here — would hold the
|
|
295
|
+
* rest of the film, and that is what must not be claimed: a second viewer
|
|
296
|
+
* opening the same film further in would find every number taken and get no
|
|
297
|
+
* encoder at all, waiting instead for the first run to encode its way there,
|
|
298
|
+
* which on a long film is an hour.
|
|
299
|
+
*
|
|
300
|
+
* What bounds it in practice is the look-ahead: a run is suspended once it is
|
|
301
|
+
* that far in front of the segment its viewer last asked for, and past that it
|
|
302
|
+
* produces nothing until somebody asks. So that is the honest extent of the
|
|
303
|
+
* claim, and it is a measured figure rather than a chosen one — the same
|
|
304
|
+
* allowance the browser sizes its cushion from. `planRunInterval` has applied
|
|
305
|
+
* this rule since runs got intervals; this path did not, which is how an
|
|
306
|
+
* encoder came to be started and killed every five seconds in the field.
|
|
307
|
+
*
|
|
308
|
+
* @param {CoverageMap} coverage
|
|
309
|
+
* @param {object} run
|
|
310
|
+
* @param {number} from
|
|
311
|
+
* @param {number} to
|
|
312
|
+
*/
|
|
313
|
+
#claimFor(coverage, run, from, to) {
|
|
314
|
+
const end = endOfRun({ from, to });
|
|
315
|
+
if (Number.isFinite(end)) {
|
|
316
|
+
coverage.claim(run, from, end);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const head = Number.isFinite(run?.head) ? run.head : from;
|
|
320
|
+
coverage.claim(run, from, Math.max(from, head + this.lookaheadSegments));
|
|
282
321
|
}
|
|
283
322
|
|
|
284
323
|
/**
|
|
@@ -99,7 +99,6 @@ function fakeSession({ dirPath, transcodeVideo = true, cutGrid = transcodeVideo
|
|
|
99
99
|
usesExplicitCuts: false,
|
|
100
100
|
useSyntheticPlaylist: true,
|
|
101
101
|
playlistText: "#EXTM3U\n",
|
|
102
|
-
segmentCount: 100,
|
|
103
102
|
progress: { state: "running", processedSeconds: 40, startPositionSeconds: 0, speed: "1.0x" }
|
|
104
103
|
};
|
|
105
104
|
}
|
|
@@ -151,3 +151,28 @@ test("with the length unknown, a gap search needs its own bound", () => {
|
|
|
151
151
|
assert.equal(map.firstGapFrom(0), null, "no length and no bound answers nothing");
|
|
152
152
|
assert.equal(map.firstGapFrom(0, 3), 0);
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
test("with the length unknown, the free stretch is answered without walking to it", () => {
|
|
156
|
+
// What this pins: on the addon host, 2026-09-05, a map with no length walked
|
|
157
|
+
// one number at a time to MAX_SAFE_INTEGER, scanning every claim at each step.
|
|
158
|
+
// The main thread spun at 100% from the look-ahead timer and the proxy stopped
|
|
159
|
+
// answering anything, its own log included. The length was missing because the
|
|
160
|
+
// field naming it had moved to the timeline and three readers were left on the
|
|
161
|
+
// old name — but this walk must not be able to do that even when it is.
|
|
162
|
+
//
|
|
163
|
+
// This check cannot FAIL against the old code — a synchronous walk cannot be
|
|
164
|
+
// interrupted by the runner, so it would hang the whole run instead, which is
|
|
165
|
+
// worse than no check. What it pins is the contract that replaced the walk:
|
|
166
|
+
// the answer comes from what the map holds.
|
|
167
|
+
const map = new CoverageMap();
|
|
168
|
+
|
|
169
|
+
assert.equal(map.freeRunFrom(0), Number.POSITIVE_INFINITY, "nothing is covered, so nothing bounds it");
|
|
170
|
+
|
|
171
|
+
const run = aRun();
|
|
172
|
+
map.claim(run, 500, 900);
|
|
173
|
+
assert.equal(map.freeRunFrom(0), 500, "and a claim ahead is where the free stretch ends");
|
|
174
|
+
assert.equal(map.freeRunFrom(0, run), Number.POSITIVE_INFINITY, "its own claim does not bound it");
|
|
175
|
+
|
|
176
|
+
map.markReady(200);
|
|
177
|
+
assert.equal(map.freeRunFrom(0), 200, "so is a segment somebody has already made");
|
|
178
|
+
});
|
|
@@ -194,3 +194,34 @@ test("nothing wanted anywhere is said plainly", () => {
|
|
|
194
194
|
const { made } = orchestrator();
|
|
195
195
|
assert.match(made.describe(), /nothing wanted/);
|
|
196
196
|
});
|
|
197
|
+
|
|
198
|
+
test("a run adopted with no end holds the look-ahead, not the rest of the film", () => {
|
|
199
|
+
// A session's own encoder is handed to the plan rather than built by it, and
|
|
200
|
+
// it carries `to = -1` — no end. Claiming the film from there would leave a
|
|
201
|
+
// viewer further in with no encoder at all: they would wait for this run to
|
|
202
|
+
// encode its way to them, which on a long film is an hour. What bounds it is
|
|
203
|
+
// the look-ahead, because a run is suspended once it is that far in front of
|
|
204
|
+
// its viewer and produces nothing until somebody asks.
|
|
205
|
+
const { made } = orchestrator({ maxRuns: 2 });
|
|
206
|
+
made.lookaheadSegments = 30;
|
|
207
|
+
const adopted = {
|
|
208
|
+
from: 0,
|
|
209
|
+
to: -1,
|
|
210
|
+
head: 3,
|
|
211
|
+
isAlive: true,
|
|
212
|
+
isStopping: false,
|
|
213
|
+
speedX: 8,
|
|
214
|
+
stop() {
|
|
215
|
+
this.isAlive = false;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
made.adopt(PICTURE, adopted);
|
|
219
|
+
|
|
220
|
+
made.want({ claimant: "far", address: PICTURE, from: 200, to: 230 });
|
|
221
|
+
made.reconcile();
|
|
222
|
+
|
|
223
|
+
const runs = made.runsOn(PICTURE);
|
|
224
|
+
assert.equal(runs.length, 2, "the viewer further in got an encoder of their own");
|
|
225
|
+
assert.ok(adopted.isAlive, "and the adopted run was not stopped to make room");
|
|
226
|
+
assert.equal(runs.some((run) => run.from === 200), true, "started where that viewer is waiting");
|
|
227
|
+
});
|
package/test/encode-plan.test.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import test from "node:test";
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
12
|
import { CoverageMap } from "../services/encode/CoverageMap.js";
|
|
13
|
+
import { endOfRun } from "../services/encode/EncodeRun.js";
|
|
13
14
|
import { firstUnmetWant, planEncoders } from "../services/encode/EncodePlan.js";
|
|
14
15
|
|
|
15
16
|
/** A host that can afford two encoders, four-second segments, a cheap restart. */
|
|
@@ -243,3 +244,35 @@ test("the lowest thing a viewer is waiting for is reported, so a stalled plan is
|
|
|
243
244
|
coverage.markReadyAll([42, 43, 44]);
|
|
244
245
|
assert.equal(firstUnmetWant(coverage, [{ from: 40, to: 44 }]), null);
|
|
245
246
|
});
|
|
247
|
+
|
|
248
|
+
test("a run with no end is making what the viewers ahead of it are waiting for", () => {
|
|
249
|
+
// Field, 2026-09-05: an encoder was started and killed every five seconds,
|
|
250
|
+
// each producing 0-2 segments, for as long as anybody watched. A run given no
|
|
251
|
+
// end carries `to = -1`, and two places read that as a number instead of as
|
|
252
|
+
// "no end": the overlap test called its work unwanted, and the claim it made
|
|
253
|
+
// in the coverage map was one segment long, so the plan saw the rest of the
|
|
254
|
+
// film as free and started another encoder there.
|
|
255
|
+
const coverage = new CoverageMap({ segmentCount: 570 });
|
|
256
|
+
const run = { from: 0, to: -1, head: 3, speedX: 8, isAlive: true };
|
|
257
|
+
coverage.claim(run, run.from, endOfRun(run));
|
|
258
|
+
|
|
259
|
+
const actions = planEncoders({
|
|
260
|
+
coverage,
|
|
261
|
+
live: [run],
|
|
262
|
+
wanted: [{ from: 0, to: 30 }],
|
|
263
|
+
maxRuns: 2,
|
|
264
|
+
segmentSeconds: 4,
|
|
265
|
+
restartCostSec: 0.12
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
assert.deepEqual(
|
|
269
|
+
actions.filter((action) => action.type === "stop"),
|
|
270
|
+
[],
|
|
271
|
+
"nothing is stopped: it is making exactly what is wanted"
|
|
272
|
+
);
|
|
273
|
+
assert.deepEqual(
|
|
274
|
+
actions.filter((action) => action.type === "start"),
|
|
275
|
+
[],
|
|
276
|
+
"and nothing new is started over ground it already holds"
|
|
277
|
+
);
|
|
278
|
+
});
|
|
@@ -61,7 +61,14 @@ function sessionOn({ id, dirPath, segmentCount = 100, runState = null, encodeSta
|
|
|
61
61
|
get inputFile() { return this.file; },
|
|
62
62
|
get audioFile() { return this.file; },
|
|
63
63
|
segmentFormat: fmp4Format,
|
|
64
|
-
|
|
64
|
+
// How the file is cut, held by the TIMELINE. A fixture that stated it on the
|
|
65
|
+
// session was describing a shape the product had left, and it kept a defect
|
|
66
|
+
// alive for a release: `session.segmentCount` is undefined on every real
|
|
67
|
+
// session, so runs were given no end and the coverage map had no length.
|
|
68
|
+
timeline: new Timeline({
|
|
69
|
+
boundaries: Array.from({ length: segmentCount + 1 }, (_, index) => index * 4),
|
|
70
|
+
cutGrid: "uniform"
|
|
71
|
+
}),
|
|
65
72
|
runs: new Set(),
|
|
66
73
|
consumers: new Set(),
|
|
67
74
|
lastAccessedAt: Date.now()
|