@torrent-tv/proxy 2.77.0 → 2.78.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 +10 -0
- package/package.json +1 -1
- package/services/encode/DemandMap.js +187 -0
- package/services/encode/EncodePlan.js +283 -272
- package/services/encode/SegmentDemand.js +0 -0
- package/services/hls-session-manager.js +10902 -10846
- package/services/orchestrators/EncodeOrchestrator.js +13 -3
- package/test/demand-map.test.js +102 -0
- package/test/encode-plan.test.js +44 -2
- package/test/run-intervals.test.js +67 -303
|
@@ -1,272 +1,283 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file How many encoders there should be on one output, and where each of them
|
|
3
|
-
* belongs — decided from numbers alone.
|
|
4
|
-
*
|
|
5
|
-
* The decision is separated from carrying it out on purpose. Every rule below
|
|
6
|
-
* was previously a condition somewhere inside an eleven-thousand-line file,
|
|
7
|
-
* reachable only by starting a real ffmpeg, and each of them was written for
|
|
8
|
-
* one viewer:
|
|
9
|
-
*
|
|
10
|
-
* - a run was placed at the position of whoever asked, and never at the first
|
|
11
|
-
* thing missing, so a viewer moving into a stretch already on disk restarted
|
|
12
|
-
* an encoder to make it a second time;
|
|
13
|
-
* - a run had no end at all — neither `-to` nor `-t` appeared anywhere — so it
|
|
14
|
-
* ran until something killed it, and two runs on one output could not exist
|
|
15
|
-
* without writing over each other;
|
|
16
|
-
* - nothing stopped a run that had caught up with material somebody else had
|
|
17
|
-
* already made.
|
|
18
|
-
*
|
|
19
|
-
* The rule this file exists to express, stated by the user 2026-09-04:
|
|
20
|
-
*
|
|
21
|
-
* > Viewers are always independent and always reuse what can be reused. The
|
|
22
|
-
* > number of encoders is however many are needed; how many are needed follows
|
|
23
|
-
* > from which sets of output parameters are wanted and where the viewers stand
|
|
24
|
-
* > inside each. Segments produced by ANY encoder are available to ANY viewer,
|
|
25
|
-
* > and which viewer asked never enters the question.
|
|
26
|
-
*
|
|
27
|
-
* So no name of a viewer reaches this file. It is given what is wanted, what
|
|
28
|
-
* exists, what is being made, and what the machine can afford.
|
|
29
|
-
*
|
|
30
|
-
* **
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
* @
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* @
|
|
77
|
-
* @
|
|
78
|
-
* @
|
|
79
|
-
*/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* @param {
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* @param {
|
|
97
|
-
* @param {number} params.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
/** @type {PlanAction[]} */
|
|
116
|
-
const
|
|
117
|
-
/** @type {PlanAction[]} */
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
type: "
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
starts.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file How many encoders there should be on one output, and where each of them
|
|
3
|
+
* belongs — decided from numbers alone.
|
|
4
|
+
*
|
|
5
|
+
* The decision is separated from carrying it out on purpose. Every rule below
|
|
6
|
+
* was previously a condition somewhere inside an eleven-thousand-line file,
|
|
7
|
+
* reachable only by starting a real ffmpeg, and each of them was written for
|
|
8
|
+
* one viewer:
|
|
9
|
+
*
|
|
10
|
+
* - a run was placed at the position of whoever asked, and never at the first
|
|
11
|
+
* thing missing, so a viewer moving into a stretch already on disk restarted
|
|
12
|
+
* an encoder to make it a second time;
|
|
13
|
+
* - a run had no end at all — neither `-to` nor `-t` appeared anywhere — so it
|
|
14
|
+
* ran until something killed it, and two runs on one output could not exist
|
|
15
|
+
* without writing over each other;
|
|
16
|
+
* - nothing stopped a run that had caught up with material somebody else had
|
|
17
|
+
* already made.
|
|
18
|
+
*
|
|
19
|
+
* The rule this file exists to express, stated by the user 2026-09-04:
|
|
20
|
+
*
|
|
21
|
+
* > Viewers are always independent and always reuse what can be reused. The
|
|
22
|
+
* > number of encoders is however many are needed; how many are needed follows
|
|
23
|
+
* > from which sets of output parameters are wanted and where the viewers stand
|
|
24
|
+
* > inside each. Segments produced by ANY encoder are available to ANY viewer,
|
|
25
|
+
* > and which viewer asked never enters the question.
|
|
26
|
+
*
|
|
27
|
+
* So no name of a viewer reaches this file. It is given what is wanted, what
|
|
28
|
+
* exists, what is being made, and what the machine can afford.
|
|
29
|
+
*
|
|
30
|
+
* **A viewer decides the ORDER the map is walked in and, through the budget, how
|
|
31
|
+
* many processes walk it. Nothing else.** Stated by the user 2026-09-05, and it
|
|
32
|
+
* is the rule the rest of this file now follows: while a file is being encoded
|
|
33
|
+
* it is encoded WHOLE, in the order the map dictates. Who wants which segment
|
|
34
|
+
* decides which gap is closed first, never whether a run may go on living.
|
|
35
|
+
*
|
|
36
|
+
* **A run is therefore never stopped for standing outside a viewer's window.**
|
|
37
|
+
* It used to be, and the two decisions that produced that were in direct
|
|
38
|
+
* contradiction — measured in the field 2026-09-05 on a viewer watching an
|
|
39
|
+
* episode:
|
|
40
|
+
*
|
|
41
|
+
* 1. this file commanded a start inside the window, at #46;
|
|
42
|
+
* 2. `planRunInterval` in the session manager moved the start to #78, because
|
|
43
|
+
* it counted a suspended run's claim as reaching `head + look-ahead`;
|
|
44
|
+
* 3. this file then saw a run at #78 against a window of [27, 57], found no
|
|
45
|
+
* overlap, and killed it as "nothing it was given is wanted";
|
|
46
|
+
* 4. neither coverage nor demand had changed, so the same start was commanded
|
|
47
|
+
* again — 350-700ms per cycle, dozens of times, no segment ever produced,
|
|
48
|
+
* the viewer's picture stopped for 125 seconds.
|
|
49
|
+
*
|
|
50
|
+
* Both of those other authorities are gone (roadmap item 76, step 5). What is
|
|
51
|
+
* left is this file, and the only reasons it stops a run are: nobody is
|
|
52
|
+
* watching the output at all; the machine affords fewer processes; or there is
|
|
53
|
+
* nothing left unmade anywhere in the track.
|
|
54
|
+
*
|
|
55
|
+
* **A run's end comes from the coverage**, never from a window: it runs until
|
|
56
|
+
* it meets material somebody else has made or is making, or until the end of
|
|
57
|
+
* the film.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One encoder that is running now.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {object} LiveRun
|
|
64
|
+
* @property {string} id
|
|
65
|
+
* @property {number} from - The first number it was given.
|
|
66
|
+
* @property {number} to - The last number it was given, inclusive.
|
|
67
|
+
* @property {number} head - The next number it will produce. Its position.
|
|
68
|
+
* @property {number} speedX - Measured encode speed against realtime, from
|
|
69
|
+
* ffmpeg's own progress. Zero or less means nothing has measured it yet, and
|
|
70
|
+
* then no comparison involving its speed can be made.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What a viewer is waiting for. Which viewer is deliberately absent.
|
|
75
|
+
*
|
|
76
|
+
* @typedef {object} WantedSpan
|
|
77
|
+
* @property {number} from
|
|
78
|
+
* @property {number} to
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {{ type: "start", from: number, to: number, because: string }
|
|
83
|
+
* | { type: "move", run: object, from: number, to: number, because: string }
|
|
84
|
+
* | { type: "stop", run: object, because: string }
|
|
85
|
+
* | { type: "keep", run: object, from: number, to: number }} PlanAction
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decide what to do with the encoders on one output.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} params
|
|
92
|
+
* @param {import("./CoverageMap.js").CoverageMap} params.coverage - What has
|
|
93
|
+
* been made and what is being made.
|
|
94
|
+
* @param {WantedSpan[]} params.windows - What viewers are waiting for, one
|
|
95
|
+
* window each. Empty means nobody is watching this output.
|
|
96
|
+
* @param {LiveRun[]} params.runs - The encoders running on it now.
|
|
97
|
+
* @param {number} params.maxRuns - How many encoders this machine can afford on
|
|
98
|
+
* this output. Comes from the same arithmetic that decides the quality offer;
|
|
99
|
+
* it is measured per host and never chosen here.
|
|
100
|
+
* @param {number} params.segmentSeconds - How much film one segment holds.
|
|
101
|
+
* @param {number} params.restartCostSec - What it costs to stop an encoder and
|
|
102
|
+
* start it somewhere else: process start plus opening the input. Measured —
|
|
103
|
+
* 0.12 s on the addon host, 0.5-0.6 s on a desktop.
|
|
104
|
+
* @returns {PlanAction[]} Stops first, then moves, then starts, so that a plan
|
|
105
|
+
* carried out in order never holds two encoders where it means to hold one.
|
|
106
|
+
*/
|
|
107
|
+
export function planEncoders({
|
|
108
|
+
coverage,
|
|
109
|
+
windows,
|
|
110
|
+
runs,
|
|
111
|
+
maxRuns,
|
|
112
|
+
segmentSeconds,
|
|
113
|
+
restartCostSec
|
|
114
|
+
}) {
|
|
115
|
+
/** @type {PlanAction[]} */
|
|
116
|
+
const stops = [];
|
|
117
|
+
/** @type {PlanAction[]} */
|
|
118
|
+
const moves = [];
|
|
119
|
+
/** @type {PlanAction[]} */
|
|
120
|
+
const starts = [];
|
|
121
|
+
/** @type {PlanAction[]} */
|
|
122
|
+
const keeps = [];
|
|
123
|
+
|
|
124
|
+
const wanted = Array.isArray(windows) ? windows : [];
|
|
125
|
+
const live = Array.isArray(runs) ? runs : [];
|
|
126
|
+
|
|
127
|
+
// Nobody is watching this output: every encoder on it is making segments for
|
|
128
|
+
// no one. This is the case a look-ahead cannot answer, because look-ahead
|
|
129
|
+
// asks how far AHEAD of a viewer a run is and there is no viewer.
|
|
130
|
+
if (wanted.length === 0) {
|
|
131
|
+
for (const run of live) {
|
|
132
|
+
stops.push({ type: "stop", run, because: "nobody is watching this output" });
|
|
133
|
+
}
|
|
134
|
+
return stops;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// How far a search for a gap needs to look: past the furthest thing anybody
|
|
138
|
+
// is waiting for there is nothing to decide about.
|
|
139
|
+
const demandTo = Math.max(...wanted.map((span) => span.to));
|
|
140
|
+
|
|
141
|
+
/** Runs that survive this pass. @type {Set<object>} */
|
|
142
|
+
const surviving = new Set();
|
|
143
|
+
|
|
144
|
+
for (const run of live) {
|
|
145
|
+
// 1. A RUN IS NEVER STOPPED FOR STANDING OUTSIDE A WINDOW. While a file is
|
|
146
|
+
// being encoded it is encoded whole; a viewer decides the ORDER the work
|
|
147
|
+
// is taken in and, through the budget, how many processes take it.
|
|
148
|
+
//
|
|
149
|
+
// This used to stop a run whose stretch touched no window, and that
|
|
150
|
+
// decision contradicted the one below it: the run was placed by a search
|
|
151
|
+
// the retention test did not accept, so it was killed on the pass after
|
|
152
|
+
// it started and started again in the same place — 350-700ms per cycle in
|
|
153
|
+
// the field on 2026-09-05, no segment ever produced, the viewer's picture
|
|
154
|
+
// stopped for 125 seconds.
|
|
155
|
+
//
|
|
156
|
+
// 2. Has it arrived at material that already exists, or that another run is
|
|
157
|
+
// making? Its own claim does not count against it.
|
|
158
|
+
const coveredAhead = coverage.coveredRunFrom(run.head, run);
|
|
159
|
+
if (coveredAhead === 0) {
|
|
160
|
+
surviving.add(run);
|
|
161
|
+
keeps.push({ type: "keep", run, from: run.head, to: run.to });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Where it would go instead: the first thing nobody has and nobody is
|
|
166
|
+
// making, at or after where it stands.
|
|
167
|
+
const gap = coverage.firstGapFrom(run.head, demandTo, run);
|
|
168
|
+
if (gap === null) {
|
|
169
|
+
stops.push({
|
|
170
|
+
type: "stop",
|
|
171
|
+
run,
|
|
172
|
+
because: "everything wanted ahead of it is already made or being made"
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Driving through costs its own encode time for material that exists.
|
|
178
|
+
// Moving costs one restart. Both are measured; neither is chosen here.
|
|
179
|
+
//
|
|
180
|
+
// A run whose speed nothing has measured yet cannot be compared, and then
|
|
181
|
+
// moving is the answer rather than a default: driving through is work that
|
|
182
|
+
// is certainly wasted, while the restart is a known and small cost.
|
|
183
|
+
const driveSec = run.speedX > 0 ? (coveredAhead * segmentSeconds) / run.speedX : null;
|
|
184
|
+
if (driveSec !== null && driveSec <= restartCostSec) {
|
|
185
|
+
surviving.add(run);
|
|
186
|
+
keeps.push({ type: "keep", run, from: run.head, to: run.to });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const free = coverage.freeRunFrom(gap, run);
|
|
191
|
+
surviving.add(run);
|
|
192
|
+
moves.push({
|
|
193
|
+
type: "move",
|
|
194
|
+
run,
|
|
195
|
+
from: gap,
|
|
196
|
+
to: endOfStretch(gap, free),
|
|
197
|
+
because: driveSec === null
|
|
198
|
+
? `${coveredAhead} segment(s) ahead are already covered and its speed is not measured`
|
|
199
|
+
: `driving through ${coveredAhead} covered segment(s) costs ${driveSec.toFixed(2)}s ` +
|
|
200
|
+
`against ${restartCostSec.toFixed(2)}s to move`
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. Gaps somebody is waiting for that nobody is making, IN THE ORDER THE
|
|
205
|
+
// DEMAND MAP PUTS THEM: most urgent zone first, and within one zone the
|
|
206
|
+
// lowest number, because that is where a viewer is stopped. The budget
|
|
207
|
+
// rarely stretches to every gap, so which one is taken first is the whole
|
|
208
|
+
// of what a viewer's presence decides.
|
|
209
|
+
const gapsWanted = [];
|
|
210
|
+
for (const span of [...wanted].sort(
|
|
211
|
+
(left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from
|
|
212
|
+
)) {
|
|
213
|
+
const gap = coverage.firstGapFrom(span.from, span.to);
|
|
214
|
+
if (gap !== null) {
|
|
215
|
+
gapsWanted.push(gap);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
|
|
219
|
+
const budget = Math.max(0, maxRuns - surviving.size);
|
|
220
|
+
for (const gap of [...new Set(gapsWanted)]) {
|
|
221
|
+
if (starts.length >= budget) {
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
if (alreadyPlanned.has(gap)) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const free = coverage.freeRunFrom(gap);
|
|
228
|
+
if (free === 0) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
alreadyPlanned.add(gap);
|
|
232
|
+
starts.push({
|
|
233
|
+
type: "start",
|
|
234
|
+
from: gap,
|
|
235
|
+
to: endOfStretch(gap, free),
|
|
236
|
+
because: `#${gap} is wanted and nobody is making it`
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return [...stops, ...moves, ...starts, ...keeps];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The last number of a stretch that begins at `from` and is `length` long.
|
|
245
|
+
*
|
|
246
|
+
* `-1` when the length is not finite, which is this layer's word for a run with
|
|
247
|
+
* no end: the film's length is not known, so there is nothing to stop it at, and
|
|
248
|
+
* a number invented here would be an end nobody measured.
|
|
249
|
+
*
|
|
250
|
+
* @param {number} from
|
|
251
|
+
* @param {number} length
|
|
252
|
+
* @returns {number}
|
|
253
|
+
*/
|
|
254
|
+
function endOfStretch(from, length) {
|
|
255
|
+
return Number.isFinite(length) ? from + Math.max(1, length) - 1 : -1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The lowest number a viewer is waiting for that is not ready — what the plan
|
|
260
|
+
* is judged by.
|
|
261
|
+
*
|
|
262
|
+
* Not used to decide anything: it is the figure a log line carries, so that a
|
|
263
|
+
* plan that keeps producing while a viewer waits is visible rather than
|
|
264
|
+
* inferred.
|
|
265
|
+
*
|
|
266
|
+
* @param {import("./CoverageMap.js").CoverageMap} coverage
|
|
267
|
+
* @param {WantedSpan[]} windows
|
|
268
|
+
* @returns {number | null}
|
|
269
|
+
*/
|
|
270
|
+
export function firstUnmetWant(coverage, windows) {
|
|
271
|
+
let lowest = null;
|
|
272
|
+
for (const span of windows ?? []) {
|
|
273
|
+
for (let at = span.from; at <= span.to; at += 1) {
|
|
274
|
+
if (!coverage.isReady(at)) {
|
|
275
|
+
if (lowest === null || at < lowest) {
|
|
276
|
+
lowest = at;
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return lowest;
|
|
283
|
+
}
|
|
Binary file
|