@torrent-tv/proxy 2.101.1 → 2.101.3
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/bin/cli.js +0 -2
- package/package.json +1 -1
- package/server.js +9 -8
- package/services/encode/EncodeInputs.js +2 -16
- package/services/encode/ProcessorBudget.js +104 -0
- package/services/encode/available-share.js +39 -4
- package/services/encode/calibration.js +4 -1
- package/services/encode/fingerprint.js +12 -4
- package/services/encode/host-load.js +34 -6
- package/services/encode/hwaccel.js +15 -9
- package/services/encode/quality/HostLoad.js +7 -2
- package/services/server/ViewerRequests.js +24 -6
- package/services/server/wire-outputs.js +19 -15
- package/services/viewer/playback-readiness.js +229 -17
package/bin/cli.js
CHANGED
|
@@ -90,7 +90,6 @@ program
|
|
|
90
90
|
.option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
|
|
91
91
|
.option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
|
|
92
92
|
.option("--pin-preset <name>", "Encode every software picture at this libx264 preset only, for runs that must be compared at one quality (off by default)")
|
|
93
|
-
.option("--input-stretch-bytes <bytes>", "Size every original-input stretch to this many bytes instead of copy rate times open, for runs that compare input sizes; memory, downloaded bytes and the plan still bound it (off by default)")
|
|
94
93
|
.option("--heap-snapshots", "Write a heap snapshot of a thread whose memory reaches a new high-water; each one stops that thread while it is written (off by default)")
|
|
95
94
|
.option("--profile-start <seconds>", "Profile the CPU of the main thread and the torrent thread for this many seconds from the first source opened, written beside the state (off by default)")
|
|
96
95
|
.option(
|
|
@@ -400,7 +399,6 @@ try {
|
|
|
400
399
|
profileStartSeconds: Number(options.profileStart) || 0,
|
|
401
400
|
heapSnapshots: options.heapSnapshots === true,
|
|
402
401
|
pinnedPreset: typeof options.pinPreset === "string" && options.pinPreset.trim() ? options.pinPreset.trim() : null,
|
|
403
|
-
inputStretchBytes: Number(options.inputStretchBytes) > 0 ? Math.floor(Number(options.inputStretchBytes)) : null,
|
|
404
402
|
// Late-bound the same way `webRtcManager` is below: the torrent pool is
|
|
405
403
|
// built inside `startProxyServer`, before `dataChannelHandler` — the
|
|
406
404
|
// thing that actually owns a channel to push down — exists.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -88,6 +88,7 @@ import { takeTurns } from "./utils/take-turns.js";
|
|
|
88
88
|
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
89
89
|
import { startupCpuProfile } from "./services/storage/cpu-profile.js";
|
|
90
90
|
import { startProcessorReport } from "./services/storage/processor-report.js";
|
|
91
|
+
import { processorBound } from "./services/storage/machine-processor.js";
|
|
91
92
|
|
|
92
93
|
const require = createRequire(import.meta.url);
|
|
93
94
|
const { version } = require("./package.json");
|
|
@@ -134,9 +135,6 @@ export async function startProxyServer({
|
|
|
134
135
|
// The one software preset every picture is encoded at, for runs compared at
|
|
135
136
|
// one quality; null leaves the choice to the machine.
|
|
136
137
|
pinnedPreset = null,
|
|
137
|
-
// The size every original-input stretch is given in place of copy rate
|
|
138
|
-
// times open, for runs that compare input sizes; null keeps the rule.
|
|
139
|
-
inputStretchBytes = null,
|
|
140
138
|
// What viewers' connections carry beyond the film, per byte of film; the
|
|
141
139
|
// transport counts it and is built after this server, so it is late-bound.
|
|
142
140
|
serviceShare = () => null,
|
|
@@ -234,6 +232,10 @@ export async function startProxyServer({
|
|
|
234
232
|
let videoEncoder = transcodeAudio
|
|
235
233
|
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
236
234
|
: null;
|
|
235
|
+
// The cores this process may use (a container's quota or CPU set): the
|
|
236
|
+
// readings below divide out a share of those, the unit every later share of
|
|
237
|
+
// the machine is in (torrent-tv/meta#180, phase 3).
|
|
238
|
+
const processorCores = processorBound().cores;
|
|
237
239
|
// WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
|
|
238
240
|
// was gated on the chosen encoder being SOFTWARE, and two of the three are
|
|
239
241
|
// not about the encoder at all: a host with a GPU decodes in software just
|
|
@@ -246,7 +248,7 @@ export async function startProxyServer({
|
|
|
246
248
|
// (the rungs are timed on raw frames), but the order keeps the two figures
|
|
247
249
|
// side by side in the log.
|
|
248
250
|
const decodeCostModel = transcodeAudio
|
|
249
|
-
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
251
|
+
? await benchmarkDecodeCost({ ffmpegBin, logger, processorCores })
|
|
250
252
|
: null;
|
|
251
253
|
// What a second job costs on this host. Measured because the budget adds
|
|
252
254
|
// independent prices and this host says two jobs that each fit alone do not
|
|
@@ -267,16 +269,16 @@ export async function startProxyServer({
|
|
|
267
269
|
// the processor and the threads, the device and its driver — which is what
|
|
268
270
|
// decides whether anything kept from an earlier run still applies.
|
|
269
271
|
const fingerprint = videoEncoder
|
|
270
|
-
? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder })
|
|
272
|
+
? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder, processorCores })
|
|
271
273
|
: null;
|
|
272
274
|
/** @type {Record<string, object[]>} */
|
|
273
275
|
const calibratedModes = {};
|
|
274
276
|
if (videoEncoder) {
|
|
275
277
|
calibratedModes[videoEncoder.kind] =
|
|
276
|
-
(await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger })).modes;
|
|
278
|
+
(await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger, processorCores })).modes;
|
|
277
279
|
if (videoEncoder.kind !== "software") {
|
|
278
280
|
calibratedModes.software =
|
|
279
|
-
(await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger })).modes;
|
|
281
|
+
(await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger, processorCores })).modes;
|
|
280
282
|
// A device that passed detection's own test and then none of whose modes
|
|
281
283
|
// passed the product's is not used: detection encodes a synthetic pattern
|
|
282
284
|
// with arguments of its own, and it is the product's arguments a viewer
|
|
@@ -857,7 +859,6 @@ export async function startProxyServer({
|
|
|
857
859
|
let sourcePreparation;
|
|
858
860
|
const outputParts = wireOutputs({
|
|
859
861
|
pinnedPreset,
|
|
860
|
-
inputStretchBytes,
|
|
861
862
|
indexMemory,
|
|
862
863
|
serviceShare,
|
|
863
864
|
readMetadataActivity: () => containerOrchestrator.activity(),
|
|
@@ -62,20 +62,10 @@ export class EncodeInputs {
|
|
|
62
62
|
* @type {(output: object, from: number, to: number, input: object) => void}
|
|
63
63
|
*/
|
|
64
64
|
#stretchCopied;
|
|
65
|
-
/**
|
|
66
|
-
* A stretch size the operator named for comparison runs
|
|
67
|
-
* (`--input-stretch-bytes`), in place of copy rate times open; null keeps
|
|
68
|
-
* the measured rule. Only the target changes: memory, downloaded bytes and
|
|
69
|
-
* the plan's bound still end a stretch (torrent-tv/meta#182).
|
|
70
|
-
*
|
|
71
|
-
* @type {number | null}
|
|
72
|
-
*/
|
|
73
|
-
#fixedStretchBytes;
|
|
74
65
|
|
|
75
66
|
constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
|
|
76
|
-
rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {}
|
|
67
|
+
rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {} }) {
|
|
77
68
|
this.#stretchCopied = stretchCopied;
|
|
78
|
-
this.#fixedStretchBytes = Number.isSafeInteger(stretchBytes) && stretchBytes > 0 ? stretchBytes : null;
|
|
79
69
|
this.#openOf = openOf;
|
|
80
70
|
this.#resolve = resolve;
|
|
81
71
|
this.#read = readRanges;
|
|
@@ -354,10 +344,6 @@ export class EncodeInputs {
|
|
|
354
344
|
#sizingFor(output) {
|
|
355
345
|
const rate = middleOf(this.#copyRates);
|
|
356
346
|
const open = this.#openOf(output);
|
|
357
|
-
if (this.#fixedStretchBytes !== null) {
|
|
358
|
-
return { bytes: this.#fixedStretchBytes, openMeasured: true, openSeconds: open?.seconds ?? null,
|
|
359
|
-
copyBytesPerMs: rate, fixed: true };
|
|
360
|
-
}
|
|
361
347
|
return rate === null || !open ? null : { bytes: Math.floor(rate * open.seconds * 1000),
|
|
362
348
|
openMeasured: open.measured, openSeconds: open.seconds, copyBytesPerMs: rate };
|
|
363
349
|
}
|
|
@@ -621,7 +607,7 @@ export class EncodeInputs {
|
|
|
621
607
|
if (result.kind === "result") {
|
|
622
608
|
this.#log(`encode input selection of ${addressOf(output)}: ${JSON.stringify({
|
|
623
609
|
from, requestedTo: request.to, serves, floor, targetEnd, chosenEnd: end,
|
|
624
|
-
targetBytes: target,
|
|
610
|
+
targetBytes: target, minimumBytes: minimum, inputBytes: result.bytes,
|
|
625
611
|
copyBytesPerMs: sizing?.copyBytesPerMs ?? null, openSeconds: sizing?.openSeconds ?? null,
|
|
626
612
|
openMeasured: sizing?.openMeasured ?? null, reservations, probes
|
|
627
613
|
})}`);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one owner of what this machine's processor gives the encoders
|
|
3
|
+
* (torrent-tv/meta#180, phase 2: one owner, results unchanged).
|
|
4
|
+
*
|
|
5
|
+
* Before it, the same two readings reached the processor's decisions by six
|
|
6
|
+
* routes: the share of the machine other work leaves (`HostLoad`'s latest
|
|
7
|
+
* reading) was read separately by the admission, the quality offer, the cost
|
|
8
|
+
* model and the download share, and the contention penalties measured at
|
|
9
|
+
* startup were handed separately to the cost model, the runs and the plan. A
|
|
10
|
+
* reading that changes, or a new one (the processor's bound and measured free
|
|
11
|
+
* amount, `storage/machine-processor.js`), would have had to be threaded to
|
|
12
|
+
* each of them again. Now each asks this object, and the arithmetic stays in
|
|
13
|
+
* the two files that define it (`available-share.js`, `contention.js`).
|
|
14
|
+
*
|
|
15
|
+
* It lives in the encoding component because every question it answers is the
|
|
16
|
+
* encoders', and that component may import nothing outside itself; it is
|
|
17
|
+
* assembled beside the machine budget in `server/wire-outputs.js`. Its unit is
|
|
18
|
+
* today's: shares of the whole machine and seconds of the whole machine per
|
|
19
|
+
* second of film. Nothing here changes an answer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { correctForAvailability, leftForEncoders } from "./available-share.js";
|
|
23
|
+
import { contentionPenalty } from "./contention.js";
|
|
24
|
+
|
|
25
|
+
export class ProcessorBudget {
|
|
26
|
+
/** @type {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} */
|
|
27
|
+
#reading;
|
|
28
|
+
|
|
29
|
+
/** @type {import("./contention.js").ContentionPenalties | null} */
|
|
30
|
+
#penalties;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @param {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} params.reading -
|
|
35
|
+
* The latest share of the machine other work leaves, as `HostLoad` keeps it.
|
|
36
|
+
* @param {import("./contention.js").ContentionPenalties | null} [params.penalties] -
|
|
37
|
+
* How much slower one job runs beside others, measured at startup.
|
|
38
|
+
*/
|
|
39
|
+
constructor({ reading, penalties = null }) {
|
|
40
|
+
if (typeof reading !== "function") {
|
|
41
|
+
throw new TypeError("ProcessorBudget requires reading");
|
|
42
|
+
}
|
|
43
|
+
this.#reading = reading;
|
|
44
|
+
this.#penalties = penalties instanceof Map ? penalties : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The latest share of the machine other work leaves, or null before the
|
|
49
|
+
* first reading.
|
|
50
|
+
*
|
|
51
|
+
* @returns {{ share: number, forEncoders?: number, known: boolean } | null}
|
|
52
|
+
*/
|
|
53
|
+
availability() {
|
|
54
|
+
return this.#reading() ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The share of the machine an encoder gets now; 1 where the host does not
|
|
59
|
+
* report its load.
|
|
60
|
+
*
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
left() {
|
|
64
|
+
return leftForEncoders(this.availability());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A speed measured alone on a quiet machine, as it would run now.
|
|
69
|
+
*
|
|
70
|
+
* @param {number} aloneSpeed
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
speedOf(aloneSpeed) {
|
|
74
|
+
return correctForAvailability(aloneSpeed, this.availability());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The measured penalties, as the plan and the runs read them.
|
|
79
|
+
*
|
|
80
|
+
* @returns {import("./contention.js").ContentionPenalties | null}
|
|
81
|
+
*/
|
|
82
|
+
get penalties() {
|
|
83
|
+
return this.#penalties;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Replace the penalties, as a new measurement of them does.
|
|
88
|
+
*
|
|
89
|
+
* @param {import("./contention.js").ContentionPenalties | null} penalties
|
|
90
|
+
*/
|
|
91
|
+
set penalties(penalties) {
|
|
92
|
+
this.#penalties = penalties instanceof Map ? penalties : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How much slower one job runs with `others` beside it.
|
|
97
|
+
*
|
|
98
|
+
* @param {number} others
|
|
99
|
+
* @returns {{ penalty: number, measured: boolean, from: number }}
|
|
100
|
+
*/
|
|
101
|
+
penaltyFor(others) {
|
|
102
|
+
return contentionPenalty(others, this.#penalties);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -41,6 +41,19 @@
|
|
|
41
41
|
* `share`, what other processes leave, stays for the download's share of the
|
|
42
42
|
* proxy's processor (`processor-share.js`), which divides the proxy's room
|
|
43
43
|
* between its own download and its encoders and must not lose that room twice.
|
|
44
|
+
*
|
|
45
|
+
* THE WHOLE MACHINE IS WHAT THIS PROCESS MAY USE (torrent-tv/meta#180, phase
|
|
46
|
+
* 3). `/proc/stat` and `os.cpus()` describe the host; a container's CPU quota
|
|
47
|
+
* (`cpu.max`, Docker `--cpus`) or a CPU set (`--cpuset-cpus`) gives the proxy
|
|
48
|
+
* fewer cores than that. Where such a bound is smaller than the host, both
|
|
49
|
+
* shares are fractions of the bound instead: the encoders get what the host's
|
|
50
|
+
* other work leaves, but never more than the bound less the proxy's own draw.
|
|
51
|
+
* Measured as fractions of the host, a 1.5-core quota on an 8-core host read
|
|
52
|
+
* a busy neighbour as lost room the quota never had, and the proxy's own draw
|
|
53
|
+
* as an eighth of what it takes from the quota. The startup measurements are
|
|
54
|
+
* divided by the same fraction (`host-load.js` `freeShareDuring`), so a cost
|
|
55
|
+
* filed from either stays in one unit. Without a bound smaller than the host,
|
|
56
|
+
* nothing changes.
|
|
44
57
|
*/
|
|
45
58
|
|
|
46
59
|
/**
|
|
@@ -52,10 +65,17 @@
|
|
|
52
65
|
* @param {number | null} reading.encoderShare - Our own ffmpeg processes.
|
|
53
66
|
* @param {number | null} reading.proxyShare - The proxy process itself, every
|
|
54
67
|
* thread of it.
|
|
55
|
-
* @
|
|
68
|
+
* @param {number | null} [reading.hostCores] - The cores the three shares
|
|
69
|
+
* above are fractions of.
|
|
70
|
+
* @param {number | null} [reading.boundCores] - The cores this process may use
|
|
71
|
+
* (`storage/machine-processor.js` `processorBound`); counts only when
|
|
72
|
+
* smaller than `hostCores`.
|
|
73
|
+
* @returns {{ share: number, forEncoders: number, unattributed: number, known: boolean, boundCores?: number }}
|
|
56
74
|
* `share` is what other processes leave; `forEncoders` what they and the
|
|
57
|
-
* proxy leave
|
|
58
|
-
*
|
|
75
|
+
* proxy leave; both of `boundCores` where it is given, else of the whole
|
|
76
|
+
* host. `unattributed` stays a fraction of the host. `known` is false when
|
|
77
|
+
* the host does not report its own load — then both are 1 and the caller
|
|
78
|
+
* must say it is uncorrected rather than pretend.
|
|
59
79
|
*/
|
|
60
80
|
export function availableShareFrom(reading = {}) {
|
|
61
81
|
const systemBusy = finite(reading.systemBusy);
|
|
@@ -65,11 +85,26 @@ export function availableShareFrom(reading = {}) {
|
|
|
65
85
|
return { share: 1, forEncoders: 1, unattributed: 0, known: false };
|
|
66
86
|
}
|
|
67
87
|
const encoders = finite(reading.encoderShare) ?? 0;
|
|
68
|
-
const
|
|
88
|
+
const proxy = finite(reading.proxyShare) ?? 0;
|
|
89
|
+
const ours = encoders + proxy;
|
|
69
90
|
// Rounding, and the two readings being taken microseconds apart, can put our
|
|
70
91
|
// own share fractionally above the system total. Below zero is not a
|
|
71
92
|
// measurement of anything.
|
|
72
93
|
const unattributed = clamp(systemBusy - ours, 0, 1);
|
|
94
|
+
const hostCores = finite(reading.hostCores);
|
|
95
|
+
const boundCores = finite(reading.boundCores);
|
|
96
|
+
if (hostCores !== null && boundCores !== null && boundCores > 0 && boundCores < hostCores) {
|
|
97
|
+
// In cores: the host's other work takes from the host, the proxy's own
|
|
98
|
+
// draw from the bound as well.
|
|
99
|
+
const leftOnHost = hostCores - clamp(systemBusy - encoders, 0, 1) * hostCores;
|
|
100
|
+
return {
|
|
101
|
+
share: clamp(Math.min(boundCores, hostCores - unattributed * hostCores) / boundCores, 0, 1),
|
|
102
|
+
forEncoders: clamp(Math.min(boundCores - proxy * hostCores, leftOnHost) / boundCores, 0, 1),
|
|
103
|
+
unattributed,
|
|
104
|
+
known: true,
|
|
105
|
+
boundCores
|
|
106
|
+
};
|
|
107
|
+
}
|
|
73
108
|
return { share: clamp(1 - unattributed, 0, 1), forEncoders: clamp(1 - (systemBusy - encoders), 0, 1),
|
|
74
109
|
unattributed, known: true };
|
|
75
110
|
}
|
|
@@ -185,6 +185,8 @@ async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, rung, segm
|
|
|
185
185
|
* @param {{ info: (m: string) => void, warn: (m: string) => void }} [params.logger]
|
|
186
186
|
* @param {number} [params.segmentDurationSec]
|
|
187
187
|
* @param {Array<{ width: number, height: number }>} [params.frames]
|
|
188
|
+
* @param {number | null} [params.processorCores] - The cores this process may
|
|
189
|
+
* use, which the readings' share of the machine is of (`measureEncodeSlope`).
|
|
188
190
|
* @param {object} [params.measure] - Replaces the three readings, for a check
|
|
189
191
|
* that must not run ffmpeg: `check`, `rawFrames`, `speed`. `speed` answers a
|
|
190
192
|
* number, or `{ speed, freeShare }` as `measureEncodeSlope` does.
|
|
@@ -196,12 +198,13 @@ export async function calibrateEncoder({
|
|
|
196
198
|
logger,
|
|
197
199
|
segmentDurationSec = 4,
|
|
198
200
|
frames = CALIBRATION_FRAMES,
|
|
201
|
+
processorCores = null,
|
|
199
202
|
measure = null
|
|
200
203
|
}) {
|
|
201
204
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
202
205
|
const check = measure?.check ?? checkModeProducesCorrectSegments;
|
|
203
206
|
const rawFrames = measure?.rawFrames ?? ((frame) => decodeToRawFrames(ffmpegBin, log, frame));
|
|
204
|
-
const speed = measure?.speed ?? ((rung, rawPath, frame) => measureEncodeSlope(ffmpegBin, encoder, rung, rawPath, frame));
|
|
207
|
+
const speed = measure?.speed ?? ((rung, rawPath, frame) => measureEncodeSlope(ffmpegBin, encoder, rung, rawPath, frame, processorCores));
|
|
205
208
|
const release = measure?.release ?? ((rawPath) => {
|
|
206
209
|
try {
|
|
207
210
|
rmSync(path.dirname(rawPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* 2. the libx264 build — the version string x264 writes into every stream it
|
|
23
23
|
* produces (`x264 - core N rNNNN hash`), read from one encoded frame;
|
|
24
24
|
* 3. the processor model and the thread count the encoder is given
|
|
25
|
-
* (`CPU_THREADS`, which is the core count)
|
|
25
|
+
* (`CPU_THREADS`, which is the core count), and the cores this process may
|
|
26
|
+
* use where a container's CPU quota or CPU set makes them fewer: a speed
|
|
27
|
+
* filed as a share of those cores (`available-share.js`) does not hold
|
|
28
|
+
* under another quota (torrent-tv/meta#180);
|
|
26
29
|
* 4. for a device-backed encoder, the device model and its driver: NVIDIA's
|
|
27
30
|
* own tool where it exists, and otherwise what the kernel publishes under
|
|
28
31
|
* `/sys` for the render node or the video device.
|
|
@@ -116,7 +119,9 @@ export function configurationKeyOf(fingerprint, encoder) {
|
|
|
116
119
|
...common,
|
|
117
120
|
`x264=${fingerprint?.x264 ?? UNKNOWN}`,
|
|
118
121
|
`cpu=${fingerprint?.cpu ?? UNKNOWN}`,
|
|
119
|
-
`threads=${fingerprint?.threads ?? UNKNOWN}
|
|
122
|
+
`threads=${fingerprint?.threads ?? UNKNOWN}`,
|
|
123
|
+
// Only where it binds, so a host without a quota keeps its key.
|
|
124
|
+
...(Number.isFinite(fingerprint?.boundCores) ? [`bound=${fingerprint.boundCores}`] : [])
|
|
120
125
|
].join(";");
|
|
121
126
|
}
|
|
122
127
|
const device = fingerprint?.devices?.[encoder?.kind] ?? null;
|
|
@@ -137,6 +142,8 @@ export function configurationKeyOf(fingerprint, encoder) {
|
|
|
137
142
|
* @property {string} x264
|
|
138
143
|
* @property {string} cpu
|
|
139
144
|
* @property {number} threads
|
|
145
|
+
* @property {number | null} [boundCores] - The cores this process may use,
|
|
146
|
+
* where fewer than the host's; absent or null otherwise.
|
|
140
147
|
* @property {Record<string, DeviceFingerprint>} devices - By encoder kind.
|
|
141
148
|
*/
|
|
142
149
|
|
|
@@ -288,10 +295,10 @@ async function nvidiaDevice() {
|
|
|
288
295
|
* Read this host's fingerprint. Always resolves: an unreadable part is
|
|
289
296
|
* `unknown`, never a refusal to start.
|
|
290
297
|
*
|
|
291
|
-
* @param {{ ffmpegBin: string, encoder?: { kind: string, device: string | null } | null }} params
|
|
298
|
+
* @param {{ ffmpegBin: string, encoder?: { kind: string, device: string | null } | null, processorCores?: number | null }} params
|
|
292
299
|
* @returns {Promise<HostFingerprint>}
|
|
293
300
|
*/
|
|
294
|
-
export async function readHostFingerprint({ ffmpegBin, encoder = null }) {
|
|
301
|
+
export async function readHostFingerprint({ ffmpegBin, encoder = null, processorCores = null }) {
|
|
295
302
|
const versionText = await capture(ffmpegBin, ["-hide_banner", "-version"], 5000);
|
|
296
303
|
// `-hide_banner` does not hide the version line itself: it is the answer.
|
|
297
304
|
const ffmpeg = parseFfmpegVersion(versionText ? versionText.toString("utf8") : "");
|
|
@@ -317,6 +324,7 @@ export async function readHostFingerprint({ ffmpegBin, encoder = null }) {
|
|
|
317
324
|
x264,
|
|
318
325
|
cpu: os.cpus()[0]?.model?.trim() || UNKNOWN,
|
|
319
326
|
threads: CPU_THREADS,
|
|
327
|
+
boundCores: Number.isFinite(processorCores) && processorCores < os.cpus().length ? processorCores : null,
|
|
320
328
|
devices
|
|
321
329
|
};
|
|
322
330
|
}
|
|
@@ -189,14 +189,14 @@ export function shareOfMachine(before, after, cores = os.cpus().length) {
|
|
|
189
189
|
* One reading of everything above, to be compared against the next.
|
|
190
190
|
*
|
|
191
191
|
* @param {number | null} pid - The encoder's process, when one is running.
|
|
192
|
-
* @returns {Promise<{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }>}
|
|
192
|
+
* @returns {Promise<{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null, proxyCpuSeconds: number }>}
|
|
193
193
|
*/
|
|
194
194
|
export async function sampleHost(pid) {
|
|
195
195
|
const [processCpuSeconds, system] = await Promise.all([
|
|
196
196
|
pid === null ? Promise.resolve(null) : readProcessCpuSeconds(pid),
|
|
197
197
|
readSystemCpu()
|
|
198
198
|
]);
|
|
199
|
-
return { takenAt: Date.now(), processCpuSeconds, system };
|
|
199
|
+
return { takenAt: Date.now(), processCpuSeconds, system, proxyCpuSeconds: readProxyCpuSeconds() };
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
/**
|
|
@@ -215,12 +215,22 @@ export async function sampleHost(pid) {
|
|
|
215
215
|
* Null when the host does not report its load (no `/proc`), or a reading was
|
|
216
216
|
* refused; the measurement is then used as it was read, and says so.
|
|
217
217
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
* Where this process may use fewer cores than the host has (a container's CPU
|
|
219
|
+
* quota or CPU set, `boundCores`), the share is of those cores, as the share a
|
|
220
|
+
* running encoder is filed with (`available-share.js`): what the host's other
|
|
221
|
+
* work leaves, but never more than the bound less this process's own draw.
|
|
222
|
+
* Otherwise the measurement, which ran inside the bound, was divided by a
|
|
223
|
+
* share of the whole host, and a busy neighbour made it read faster than the
|
|
224
|
+
* bound allows (torrent-tv/meta#180, phase 3).
|
|
225
|
+
*
|
|
226
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: object | null, proxyCpuSeconds?: number | null } | null} before
|
|
227
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: object | null, proxyCpuSeconds?: number | null } | null} after
|
|
220
228
|
* @param {number} [cores]
|
|
229
|
+
* @param {number | null} [boundCores] - The cores this process may use; counts
|
|
230
|
+
* only when fewer than `cores`.
|
|
221
231
|
* @returns {number | null} Between 0 and 1.
|
|
222
232
|
*/
|
|
223
|
-
export function freeShareDuring(before, after, cores = os.cpus().length) {
|
|
233
|
+
export function freeShareDuring(before, after, cores = os.cpus().length, boundCores = null) {
|
|
224
234
|
if (!before || !after) {
|
|
225
235
|
return null;
|
|
226
236
|
}
|
|
@@ -229,10 +239,28 @@ export function freeShareDuring(before, after, cores = os.cpus().length) {
|
|
|
229
239
|
return null;
|
|
230
240
|
}
|
|
231
241
|
const others = Math.min(1, Math.max(0, shares.systemShare - shares.processShare));
|
|
232
|
-
const free =
|
|
242
|
+
const free = Number.isFinite(boundCores) && boundCores > 0 && boundCores < cores
|
|
243
|
+
? Math.min(1, (cores - others * cores) / boundCores, (boundCores - proxyCoresBetween(before, after)) / boundCores)
|
|
244
|
+
: 1 - others;
|
|
233
245
|
return free > 0 ? free : null;
|
|
234
246
|
}
|
|
235
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The cores this process drew between two readings; 0 where either has no
|
|
250
|
+
* reading of it.
|
|
251
|
+
*
|
|
252
|
+
* @param {{ takenAt: number, proxyCpuSeconds?: number | null }} before
|
|
253
|
+
* @param {{ takenAt: number, proxyCpuSeconds?: number | null }} after
|
|
254
|
+
* @returns {number}
|
|
255
|
+
*/
|
|
256
|
+
function proxyCoresBetween(before, after) {
|
|
257
|
+
const elapsedSec = (after.takenAt - before.takenAt) / 1000;
|
|
258
|
+
if (!(elapsedSec > 0) || !Number.isFinite(before.proxyCpuSeconds) || !Number.isFinite(after.proxyCpuSeconds)) {
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
return Math.max(0, (after.proxyCpuSeconds - before.proxyCpuSeconds) / elapsedSec);
|
|
262
|
+
}
|
|
263
|
+
|
|
236
264
|
/**
|
|
237
265
|
* How much CPU THIS process has used, across every thread it owns.
|
|
238
266
|
*
|
|
@@ -679,13 +679,13 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
679
679
|
return penalties;
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
-
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
682
|
+
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, processorCores = null }) {
|
|
683
683
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
684
684
|
const startedAllAt = Date.now();
|
|
685
685
|
/** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
|
|
686
686
|
const families = {};
|
|
687
687
|
for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
|
|
688
|
-
const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
|
|
688
|
+
const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips, processorCores });
|
|
689
689
|
if (fitted) {
|
|
690
690
|
families[family] = fitted;
|
|
691
691
|
}
|
|
@@ -721,10 +721,10 @@ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
721
721
|
/**
|
|
722
722
|
* Fit one codec family's decode cost from its own clips.
|
|
723
723
|
*
|
|
724
|
-
* @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
|
|
724
|
+
* @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[], processorCores?: number | null }} params
|
|
725
725
|
* @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
|
|
726
726
|
*/
|
|
727
|
-
async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
727
|
+
async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips, processorCores = null }) {
|
|
728
728
|
const startedAllAt = Date.now();
|
|
729
729
|
/** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
|
|
730
730
|
const samples = [];
|
|
@@ -745,7 +745,7 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
|
745
745
|
}
|
|
746
746
|
for (const [index, clip] of clips.entries()) {
|
|
747
747
|
const stream = streams[index];
|
|
748
|
-
const measured = stream ? await decodePipedStream(ffmpegBin, stream, log) : null;
|
|
748
|
+
const measured = stream ? await decodePipedStream(ffmpegBin, stream, log, processorCores) : null;
|
|
749
749
|
if (!measured?.speed) {
|
|
750
750
|
log.warn(
|
|
751
751
|
`hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
|
|
@@ -998,9 +998,11 @@ function runCapturingStderr(ffmpegBin, args, timeoutMs) {
|
|
|
998
998
|
*
|
|
999
999
|
* @param {string} ffmpegBin
|
|
1000
1000
|
* @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
|
|
1001
|
+
* @param {{ info: Function, warn: Function }} [log]
|
|
1002
|
+
* @param {number | null} [processorCores] - The cores this process may use, for `freeShareDuring`.
|
|
1001
1003
|
* @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
|
|
1002
1004
|
*/
|
|
1003
|
-
function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }) {
|
|
1005
|
+
function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }, processorCores = null) {
|
|
1004
1006
|
return new Promise((resolve) => {
|
|
1005
1007
|
const args = [
|
|
1006
1008
|
"-hide_banner", "-loglevel", "error", "-nostats",
|
|
@@ -1047,7 +1049,7 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
|
|
|
1047
1049
|
const hostAfter = hostBefore !== null && child?.pid && child.exitCode === null
|
|
1048
1050
|
? await sampleHost(child.pid).catch(() => null)
|
|
1049
1051
|
: null;
|
|
1050
|
-
const freeShare = freeShareDuring(hostBefore === null ? null : await hostBefore, hostAfter);
|
|
1052
|
+
const freeShare = freeShareDuring(hostBefore === null ? null : await hostBefore, hostAfter, undefined, processorCores);
|
|
1051
1053
|
try {
|
|
1052
1054
|
child?.stdin?.destroy();
|
|
1053
1055
|
} catch {
|
|
@@ -1418,9 +1420,13 @@ export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC)
|
|
|
1418
1420
|
* A hardware encoder is limited by its device and not by the cores, so its
|
|
1419
1421
|
* reading is kept as read.
|
|
1420
1422
|
*
|
|
1423
|
+
* `processorCores` is the cores this process may use (a container's quota or
|
|
1424
|
+
* CPU set); the share divided out is of those where they are fewer than the
|
|
1425
|
+
* host's (`freeShareDuring`, torrent-tv/meta#180).
|
|
1426
|
+
*
|
|
1421
1427
|
* @returns {Promise<{ speed: number, measuredSpeed: number, freeShare: number | null } | null>}
|
|
1422
1428
|
*/
|
|
1423
|
-
export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
|
|
1429
|
+
export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }, processorCores = null) {
|
|
1424
1430
|
const cpuBound = encoder?.kind === "software";
|
|
1425
1431
|
return new Promise((resolve) => {
|
|
1426
1432
|
const args = [
|
|
@@ -1470,7 +1476,7 @@ export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, fram
|
|
|
1470
1476
|
resolve(null);
|
|
1471
1477
|
return;
|
|
1472
1478
|
}
|
|
1473
|
-
const freeShare = freeShareDuring(before, after);
|
|
1479
|
+
const freeShare = freeShareDuring(before, after, undefined, processorCores);
|
|
1474
1480
|
resolve({ speed: freeShare === null ? value : value / freeShare, measuredSpeed: value, freeShare });
|
|
1475
1481
|
});
|
|
1476
1482
|
};
|
|
@@ -593,6 +593,7 @@ export class HostLoad {
|
|
|
593
593
|
// What the offer will multiply a prediction by, in the same line as the
|
|
594
594
|
// readings it comes from.
|
|
595
595
|
` available=${asPercent(this.hostAvailability.share)} for-encoders=${asPercent(this.hostAvailability.forEncoders)}` +
|
|
596
|
+
(Number.isFinite(this.hostAvailability.boundCores) ? ` of ${this.hostAvailability.boundCores.toFixed(2)} cores` : "") +
|
|
596
597
|
` free=${asPercent(free)}` + processor + late
|
|
597
598
|
);
|
|
598
599
|
}
|
|
@@ -664,7 +665,7 @@ export class HostLoad {
|
|
|
664
665
|
/**
|
|
665
666
|
* @param {{ elapsedSec: number, processShare: number | null, systemShare: number | null, rejected?: string[] } | null} share
|
|
666
667
|
* @param {{ proxyCpuSeconds: number | null }} previous
|
|
667
|
-
* @param {{ proxyCpuSeconds: number | null }} sample
|
|
668
|
+
* @param {{ proxyCpuSeconds: number | null, processor?: { bound?: { cores: number } | null } | null }} sample
|
|
668
669
|
* @returns {number | null} The proxy's own share of the machine, for the log.
|
|
669
670
|
*/
|
|
670
671
|
#setAvailability(share, previous, sample) {
|
|
@@ -682,10 +683,14 @@ export class HostLoad {
|
|
|
682
683
|
const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
|
|
683
684
|
? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
|
|
684
685
|
: null;
|
|
686
|
+
// Of the cores this process may use where that is fewer than the host's
|
|
687
|
+
// (torrent-tv/meta#180, phase 3).
|
|
685
688
|
this.hostAvailability = availableShareFrom({
|
|
686
689
|
systemBusy: share.systemShare,
|
|
687
690
|
encoderShare: share.processShare,
|
|
688
|
-
proxyShare
|
|
691
|
+
proxyShare,
|
|
692
|
+
hostCores: cores,
|
|
693
|
+
boundCores: sample.processor?.bound?.cores ?? null
|
|
689
694
|
});
|
|
690
695
|
return proxyShare;
|
|
691
696
|
}
|
|
@@ -658,6 +658,7 @@ export class ViewerRequests {
|
|
|
658
658
|
fileLength: stats?.fileLength,
|
|
659
659
|
pieceLength: stats?.pieceLength,
|
|
660
660
|
downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
|
|
661
|
+
downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
|
|
661
662
|
downloadForecast,
|
|
662
663
|
});
|
|
663
664
|
}
|
|
@@ -774,11 +775,14 @@ export class ViewerRequests {
|
|
|
774
775
|
bufferLimitSeconds: input.bufferLimitSeconds,
|
|
775
776
|
lookaheadSeconds: input.lookaheadSeconds,
|
|
776
777
|
link: input.linkReadings,
|
|
777
|
-
sources: input.sources.map(({ id, complete, residence, downloadForecast, downloadRateReadings
|
|
778
|
+
sources: input.sources.map(({ id, complete, residence, downloadForecast, downloadRateReadings,
|
|
779
|
+
downloadRateSummaries }) => ({
|
|
778
780
|
id, complete, residenceRanges: residence?.length ?? 0,
|
|
779
781
|
forecastAt: downloadForecast?.measuredAt ?? null, plannedPieces: downloadForecast?.ranges.length ?? 0,
|
|
780
782
|
unknownPieces: downloadForecast?.ranges.filter(range => range.availableAt === null).length ?? 0,
|
|
781
|
-
|
|
783
|
+
// The rate the forecast reads, and both summaries it was chosen from.
|
|
784
|
+
downloadRateBytesPerSecond: this.#host.playbackReadiness.forecastRate(downloadRateReadings, now, 0, "time"),
|
|
785
|
+
downloadRateSummaries
|
|
782
786
|
})),
|
|
783
787
|
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
|
|
784
788
|
id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
|
|
@@ -818,7 +822,7 @@ export class ViewerRequests {
|
|
|
818
822
|
const rates = (forecast.operations ?? []).map(operation =>
|
|
819
823
|
`${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
|
|
820
824
|
const downloads = (input.sources ?? []).map(source => {
|
|
821
|
-
const rate = source.downloadRateReadings
|
|
825
|
+
const rate = this.#host.playbackReadiness.forecastRate(source.downloadRateReadings, now, 0, "time");
|
|
822
826
|
return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
|
|
823
827
|
});
|
|
824
828
|
logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
|
|
@@ -949,7 +953,14 @@ export class ViewerRequests {
|
|
|
949
953
|
}
|
|
950
954
|
// Totals that went back belong to a torrent added again: read on from them.
|
|
951
955
|
if (limit) source.limit = limit;
|
|
952
|
-
return sourceService(source.free.snapshot(), source.delivered.snapshot()
|
|
956
|
+
return sourceService(source.free.snapshot(), source.delivered.snapshot(),
|
|
957
|
+
(summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Both summaries a source's service was chosen from, for the forecast's log line. */
|
|
961
|
+
#sourceRateSummaries(state, id) {
|
|
962
|
+
const source = state.sources.get(id);
|
|
963
|
+
return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
|
|
953
964
|
}
|
|
954
965
|
}
|
|
955
966
|
|
|
@@ -962,12 +973,19 @@ export class ViewerRequests {
|
|
|
962
973
|
* downloaded 5-18 MB/s, answered "service not advancing", and held the viewer
|
|
963
974
|
* (r54, torrent-tv/meta#178). What the swarm did carry, it can carry.
|
|
964
975
|
*
|
|
976
|
+
* The two are compared by the rate the forecast reads from them, bytes over
|
|
977
|
+
* the span measured. Compared by their last readings, a summary whose last
|
|
978
|
+
* reading was 10.7 MB/s and whose span read 0 B/s won over 3.4-16.7 MB/s
|
|
979
|
+
* carried, and the forecast held the viewer with no delay to state (r74,
|
|
980
|
+
* torrent-tv/meta#185).
|
|
981
|
+
*
|
|
965
982
|
* @param {object | null} free - A rate summary (`RateTrend.snapshot`).
|
|
966
983
|
* @param {object | null} delivered
|
|
984
|
+
* @param {(summary: object) => number | null} rateOf - The rate the forecast reads from a summary.
|
|
967
985
|
* @returns {object | null}
|
|
968
986
|
*/
|
|
969
|
-
export function sourceService(free, delivered) {
|
|
987
|
+
export function sourceService(free, delivered, rateOf) {
|
|
970
988
|
if (!free) return delivered;
|
|
971
989
|
if (!delivered) return free;
|
|
972
|
-
return delivered
|
|
990
|
+
return (rateOf(delivered) ?? 0) > (rateOf(free) ?? 0) ? delivered : free;
|
|
973
991
|
}
|
|
@@ -102,6 +102,7 @@ import { configurationKeyOf } from "../encode/fingerprint.js";
|
|
|
102
102
|
import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
|
|
103
103
|
import { downloadShare } from "../storage/processor-share.js";
|
|
104
104
|
import { describeProcessor, processorBetween, sampleProcessor } from "../storage/machine-processor.js";
|
|
105
|
+
import { ProcessorBudget } from "../encode/ProcessorBudget.js";
|
|
105
106
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
|
|
106
107
|
import { secondsUntilClosed } from "../encode/piece-close-time.js";
|
|
107
108
|
|
|
@@ -196,9 +197,6 @@ export function wireOutputs({
|
|
|
196
197
|
// A software preset every picture is encoded at, for runs compared at one
|
|
197
198
|
// quality (`--pin-preset`); null leaves the choice to the machine.
|
|
198
199
|
pinnedPreset = null,
|
|
199
|
-
// A size every original-input stretch is given in place of copy rate
|
|
200
|
-
// times open (`--input-stretch-bytes`); null keeps the rule.
|
|
201
|
-
inputStretchBytes = null,
|
|
202
200
|
// What every encoder this host may use was qualified and measured to do at
|
|
203
201
|
// startup, by kind (`encode/calibration.js`). Takes the place of the one
|
|
204
202
|
// array above, which a test may still hand in on its own.
|
|
@@ -350,7 +348,7 @@ export function wireOutputs({
|
|
|
350
348
|
producedNumbers: (...args) => parts.serving.producedNumbers(...args),
|
|
351
349
|
servesAudioSeparately: (...args) => parts.renditions.servesAudioSeparately(...args),
|
|
352
350
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
353
|
-
get contentionPenalties() { return parts.
|
|
351
|
+
get contentionPenalties() { return parts.processor?.penalties ?? null; },
|
|
354
352
|
get encodeCost() { return parts.encodeCost; },
|
|
355
353
|
get encodeOrchestrator() { return parts.encodeOrchestrator; },
|
|
356
354
|
get ffmpegBin() { return parts.ffmpegBin; },
|
|
@@ -736,7 +734,7 @@ export function wireOutputs({
|
|
|
736
734
|
files.set(key, file);
|
|
737
735
|
}
|
|
738
736
|
const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
|
|
739
|
-
const share = downloadShare({ availableShare: parts.
|
|
737
|
+
const share = downloadShare({ availableShare: parts.processor?.availability()?.share ?? 1,
|
|
740
738
|
baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
|
|
741
739
|
// Kept beside the readings it is judged against: what the torrents moved
|
|
742
740
|
// over each interval is said next to the limit in force (meta#178).
|
|
@@ -744,7 +742,17 @@ export function wireOutputs({
|
|
|
744
742
|
void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
|
|
745
743
|
`over ${watched.length} file(s), ${Math.round(share.capacity * 100)}% of the machine for the proxy`)).catch(() => {});
|
|
746
744
|
};
|
|
747
|
-
|
|
745
|
+
// The one owner of what the processor gives the encoders: the share of the
|
|
746
|
+
// machine other work leaves and the measured contention penalties, read by
|
|
747
|
+
// every place that decides on the processor (torrent-tv/meta#180).
|
|
748
|
+
parts.processor = new ProcessorBudget({ reading: () => parts.hostLoad.hostAvailability, penalties: contentionPenalties });
|
|
749
|
+
// Kept as the name the penalties were always set and read by, now the budget's.
|
|
750
|
+
Object.defineProperty(parts, "contentionPenalties", {
|
|
751
|
+
get: () => parts.processor.penalties,
|
|
752
|
+
set: (penalties) => { parts.processor.penalties = penalties; },
|
|
753
|
+
enumerable: true,
|
|
754
|
+
configurable: true
|
|
755
|
+
});
|
|
748
756
|
// Seconds of film per second when the picture is COPIED, measured at
|
|
749
757
|
// startup. Nothing else prices that branch: the other benchmarks measure
|
|
750
758
|
// encoding and decoding, and a copy does neither.
|
|
@@ -913,11 +921,11 @@ export function wireOutputs({
|
|
|
913
921
|
host: () => ({
|
|
914
922
|
benchmark: parts.softwarePresetBenchmark,
|
|
915
923
|
decodeModel: parts.decodeCostModel,
|
|
916
|
-
contentionPenalties: parts.
|
|
924
|
+
contentionPenalties: parts.processor.penalties,
|
|
917
925
|
copySpeedX: parts.copySpeedX,
|
|
918
926
|
audioCalibration,
|
|
919
927
|
audioDescriptionOf,
|
|
920
|
-
availability: parts.
|
|
928
|
+
availability: parts.processor.availability() ?? undefined,
|
|
921
929
|
// WHICH encoder this host settled on. Only the software ladder is
|
|
922
930
|
// benchmarked, so a reading taken off a hardware encoder cannot be
|
|
923
931
|
// split into its decode and encode halves and is not filed as one.
|
|
@@ -957,7 +965,7 @@ export function wireOutputs({
|
|
|
957
965
|
encodeCost: parts.encodeCost,
|
|
958
966
|
occupiedCostSec: (fileKey) => parts.admission?.occupiedCost({ exceptFileKey: fileKey }).costSec ?? 0,
|
|
959
967
|
occupancyKnownFor: (fileKey) => (parts.admission?.occupiedCost({ exceptFileKey: fileKey }).unpriced ?? 0) === 0,
|
|
960
|
-
availability: () => parts.
|
|
968
|
+
availability: () => parts.processor.availability(),
|
|
961
969
|
outputs: parts.outputs,
|
|
962
970
|
stateFor: (session) => parts.encodeRuns.runStateOf(session),
|
|
963
971
|
// WHICH HEIGHTS A LIVE VIEWER HAS ON SCREEN, as numbers. Who is watching
|
|
@@ -1051,7 +1059,7 @@ export function wireOutputs({
|
|
|
1051
1059
|
finished: (address) => parts.outputs.outputsOn(address).some((session) => finishedOutput(session)),
|
|
1052
1060
|
loadOf: (address) => parts.encodeCost.loadOfOutput(address),
|
|
1053
1061
|
loadForCandidate: (spec, file) => parts.encodeCost.loadForCandidate(spec, file),
|
|
1054
|
-
availability: () => parts.
|
|
1062
|
+
availability: () => parts.processor.availability()
|
|
1055
1063
|
});
|
|
1056
1064
|
parts.encodeOrchestrator = new EncodeOrchestrator({
|
|
1057
1065
|
inputDemandChanged: (address, windows) => {
|
|
@@ -1067,7 +1075,7 @@ export function wireOutputs({
|
|
|
1067
1075
|
maxRunsFor: (address) => parts.encodeRuns.maxRunsForOutput(address),
|
|
1068
1076
|
makeRun: ({ address, from, to, because }) => parts.encodeRuns.makeRunAt(address, from, to, because),
|
|
1069
1077
|
segmentSeconds: parts.segmentDurationSec,
|
|
1070
|
-
contentionPenalties: parts.
|
|
1078
|
+
contentionPenalties: parts.processor.penalties,
|
|
1071
1079
|
startingSpeedFor: (address) => parts.encodeCost.speedForOutput(address),
|
|
1072
1080
|
segmentCoverage,
|
|
1073
1081
|
// HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
|
|
@@ -1106,7 +1114,6 @@ export function wireOutputs({
|
|
|
1106
1114
|
readRanges: readEncodeRanges,
|
|
1107
1115
|
heldRanges: readEncodeHeldRanges,
|
|
1108
1116
|
stretchCopied,
|
|
1109
|
-
stretchBytes: inputStretchBytes,
|
|
1110
1117
|
reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
|
|
1111
1118
|
capacity: () => parts.machineBudget.capacityOf("memory"),
|
|
1112
1119
|
// How long this output's input takes to open, from the one owner of start
|
|
@@ -1130,9 +1137,6 @@ export function wireOutputs({
|
|
|
1130
1137
|
parts.serving.invalidateWaits(output);
|
|
1131
1138
|
}
|
|
1132
1139
|
}) : null);
|
|
1133
|
-
if (parts.encodeInputs && inputStretchBytes) {
|
|
1134
|
-
logger.info(`encode input: every original-input stretch sized to ${inputStretchBytes} bytes (--input-stretch-bytes)`);
|
|
1135
|
-
}
|
|
1136
1140
|
// One owner of the disk, and the list of what takes it lives with the owner.
|
|
1137
1141
|
parts.machineBudget = wireMachineBudget({
|
|
1138
1142
|
segmentStore: parts.segmentStore,
|
|
@@ -143,8 +143,20 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
143
143
|
// Kept are the pieces still to play, the first piece (where the track
|
|
144
144
|
// starts), the read piece nearest before them (what an unread piece is placed
|
|
145
145
|
// by) and read pieces inside what the page reports holding (their frames).
|
|
146
|
+
//
|
|
147
|
+
// THE NEAR WINDOW AND THE TAIL (torrent-tv/meta#185). Pieces starting within
|
|
148
|
+
// the proxy's look-ahead of the viewer are placed in exact time, where whether
|
|
149
|
+
// the media is continuous is decided, and their bytes are those the torrent
|
|
150
|
+
// thread plans arrivals for. The pieces after it are scheduled by the same
|
|
151
|
+
// rules in seconds, by {@link scheduleTail}: no exact time and no planned
|
|
152
|
+
// arrival exists for them. Placing every remaining piece of a two-hour film
|
|
153
|
+
// in exact time on every poll cost 70-1128 ms a call on Home Assistant (r73,
|
|
154
|
+
// r74).
|
|
155
|
+
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
156
|
+
const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
|
|
146
157
|
const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
|
|
147
|
-
Array.isArray(track.segments) ? { ...track,
|
|
158
|
+
Array.isArray(track.segments) ? { ...track,
|
|
159
|
+
segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
|
|
148
160
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
149
161
|
// Each track's served ticks are placed on the clock the page reports by the
|
|
150
162
|
// offset its player applied (`timestampOffset`).
|
|
@@ -212,11 +224,20 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
212
224
|
bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
|
|
213
225
|
new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
|
|
214
226
|
segments: segmentsOf[trackIndex]
|
|
215
|
-
.filter((segment) =>
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
.filter((segment) => isPiece(segment) && segment.endSeconds > position && segment.startSeconds < horizonEnd)
|
|
228
|
+
.sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
|
|
229
|
+
// The pieces after the near window, as the playlist cuts them, and the
|
|
230
|
+
// offset the page's player placed them by.
|
|
231
|
+
tail: horizonEnd === Number.POSITIVE_INFINITY ? [] :
|
|
232
|
+
(Array.isArray(input.tracks[trackIndex].segments) ? input.tracks[trackIndex].segments : [])
|
|
233
|
+
.filter((segment) => isPiece(segment) && segment.startSeconds >= horizonEnd && segment.endSeconds > position)
|
|
234
|
+
.sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
|
|
235
|
+
clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
|
|
219
236
|
}));
|
|
237
|
+
for (const state of trackState) {
|
|
238
|
+
state.tailEndSeconds = state.tail.length > 0
|
|
239
|
+
? Math.min(duration, state.tail.at(-1).endSeconds + state.clockSeconds) : null;
|
|
240
|
+
}
|
|
220
241
|
if (trackState.some(({ segments }) => segments.length === 0)) {
|
|
221
242
|
return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
|
|
222
243
|
}
|
|
@@ -241,8 +262,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
241
262
|
return result(false, null, buffered, reserve, null, "link-rate-unavailable", 0);
|
|
242
263
|
}
|
|
243
264
|
|
|
244
|
-
const preparedSegments = trackState.reduce((sum, { ready, segments }) =>
|
|
245
|
-
sum + segments.filter((segment) => ready.has(segment.index)).length
|
|
265
|
+
const preparedSegments = trackState.reduce((sum, { ready, segments, tail }) =>
|
|
266
|
+
sum + segments.filter((segment) => ready.has(segment.index)).length +
|
|
267
|
+
tail.filter((segment) => ready.has(segment.index)).length, 0);
|
|
246
268
|
const jobs = [];
|
|
247
269
|
|
|
248
270
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
@@ -497,12 +519,22 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
497
519
|
for (const range of production.segment.ranges) addMediaRange(prepared, range);
|
|
498
520
|
// Supply interruption coverage is stock held by the proxy as well as
|
|
499
521
|
// the browser. It is not a browser buffer-size admission rule.
|
|
500
|
-
|
|
501
|
-
|
|
522
|
+
// The reserve is bounded by what is left of the track, not of the window.
|
|
523
|
+
const left = state.tailEndSeconds === null ? secondsAhead(trackEnd, before)
|
|
524
|
+
: Math.max(0, state.tailEndSeconds - toSeconds(before));
|
|
525
|
+
const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
|
|
502
526
|
constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
|
|
503
527
|
production.requiresSource);
|
|
504
528
|
}
|
|
505
529
|
}
|
|
530
|
+
if (trackState.some(({ tail }) => tail.length > 0)) {
|
|
531
|
+
const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish,
|
|
532
|
+
link, position, reserve, transferFinish, constrain });
|
|
533
|
+
if (stop) {
|
|
534
|
+
return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
|
|
535
|
+
...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
506
538
|
const delay = Math.max(0, ...constraints);
|
|
507
539
|
const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
|
|
508
540
|
const ready = delay === 0 && originReady;
|
|
@@ -527,6 +559,183 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
527
559
|
|
|
528
560
|
}
|
|
529
561
|
|
|
562
|
+
/** A playlist piece with an index and a positive span in seconds. */
|
|
563
|
+
function isPiece(segment) {
|
|
564
|
+
return Number.isInteger(segment?.index) && Number.isFinite(segment?.startSeconds) &&
|
|
565
|
+
Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The pieces after the near window, scheduled by the near window's rules in
|
|
570
|
+
* seconds and constrained as they are scheduled: their source bytes arrive in
|
|
571
|
+
* byte order at the source's measured service after the near window's bytes,
|
|
572
|
+
* a piece whose bytes come after its output's last run was admitted starts a
|
|
573
|
+
* run and pays the output's open, it is encoded at the output's measured rate
|
|
574
|
+
* and delivered over the one link in media order.
|
|
575
|
+
*
|
|
576
|
+
* Where a byte stands in that order is the bytes still missing before it,
|
|
577
|
+
* read off what is held, planned and brought by the near window
|
|
578
|
+
* ({@link missingBefore}); a piece is placed by the last range of it that
|
|
579
|
+
* misses bytes, so a range it reads that is already held — an AVI's index at
|
|
580
|
+
* the end of the file — does not place it at the end of the file. Planned and
|
|
581
|
+
* brought bytes come when they were to come. No exact time, list of arrivals
|
|
582
|
+
* or per-piece object is built: this runs over the rest of the film on every
|
|
583
|
+
* poll (torrent-tv/meta#185).
|
|
584
|
+
*
|
|
585
|
+
* @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
|
|
586
|
+
*/
|
|
587
|
+
function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish, link,
|
|
588
|
+
position, reserve, transferFinish, constrain }) {
|
|
589
|
+
// Per source: what is missing before a byte, and how far the tail has asked.
|
|
590
|
+
const supply = new Map();
|
|
591
|
+
const supplyOf = (sourceId) => {
|
|
592
|
+
let state = supply.get(sourceId);
|
|
593
|
+
if (!state) {
|
|
594
|
+
const coverage = sourceByteCoverage.get(sourceId) ?? [];
|
|
595
|
+
const heldBefore = [0];
|
|
596
|
+
for (const range of coverage) heldBefore.push(heldBefore.at(-1) + range.end - range.start);
|
|
597
|
+
state = { coverage, heldBefore, asked: null, orders: [], finishes: [] };
|
|
598
|
+
supply.set(sourceId, state);
|
|
599
|
+
}
|
|
600
|
+
return state;
|
|
601
|
+
};
|
|
602
|
+
const next = trackState.map(() => 0);
|
|
603
|
+
let delivered = transferFinish;
|
|
604
|
+
for (;;) {
|
|
605
|
+
// The next piece in media order across the tracks, as the near window orders them.
|
|
606
|
+
let trackIndex = -1;
|
|
607
|
+
for (let index = 0; index < trackState.length; index += 1) {
|
|
608
|
+
const candidate = trackState[index].tail[next[index]];
|
|
609
|
+
if (!candidate) continue;
|
|
610
|
+
const best = trackIndex < 0 ? null : trackState[trackIndex].tail[next[trackIndex]];
|
|
611
|
+
if (!best || candidate.startSeconds < best.startSeconds ||
|
|
612
|
+
(candidate.startSeconds === best.startSeconds && candidate.endSeconds < best.endSeconds)) trackIndex = index;
|
|
613
|
+
}
|
|
614
|
+
if (trackIndex < 0) return null;
|
|
615
|
+
const state = trackState[trackIndex];
|
|
616
|
+
const { track } = state;
|
|
617
|
+
const segment = state.tail[next[trackIndex]++];
|
|
618
|
+
const startsAt = segment.startSeconds + state.clockSeconds;
|
|
619
|
+
const endsAt = segment.endSeconds + state.clockSeconds;
|
|
620
|
+
// Media the page already holds is not transferred again.
|
|
621
|
+
if (Array.isArray(track.clientRanges) && track.clientRanges.some((range) =>
|
|
622
|
+
range?.start <= Math.max(startsAt, position) && range?.end >= endsAt)) continue;
|
|
623
|
+
const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
|
|
624
|
+
const sizeBits = exactBytes > 0 ? exactBytes * 8 : state.bitsPerMediaSecond * (segment.endSeconds - segment.startSeconds);
|
|
625
|
+
if (!(sizeBits > 0)) return { reason: "segment-size-unavailable" };
|
|
626
|
+
const produced = state.ready.has(segment.index);
|
|
627
|
+
let work = 0;
|
|
628
|
+
if (!produced) {
|
|
629
|
+
work = segment.endSeconds - segment.startSeconds;
|
|
630
|
+
for (const range of Array.isArray(track.processingRanges) ? track.processingRanges : []) {
|
|
631
|
+
if (Number.isFinite(range?.start) && Number.isFinite(range?.end)) {
|
|
632
|
+
work -= Math.max(0, Math.min(range.end, segment.endSeconds) - Math.max(range.start, segment.startSeconds));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
work = Math.max(0, work);
|
|
636
|
+
}
|
|
637
|
+
let sourceArrivalAt = 0;
|
|
638
|
+
if (work > 0) {
|
|
639
|
+
if (!state.curve) return { reason: "encode-rate-unavailable" };
|
|
640
|
+
const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
|
|
641
|
+
const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
|
|
642
|
+
const count = trackSources.length + (exactInputs?.length ?? 0);
|
|
643
|
+
for (let place = 0; place < count; place += 1) {
|
|
644
|
+
const sourceId = place < trackSources.length ? trackSources[place]
|
|
645
|
+
: exactInputs[place - trackSources.length]?.sourceId;
|
|
646
|
+
if (namedBefore(sourceId, place, trackSources, exactInputs)) continue;
|
|
647
|
+
const source = sourceState.get(sourceId);
|
|
648
|
+
if (!source) return { reason: "source-measurement-unavailable" };
|
|
649
|
+
if (source.source.complete === true) continue;
|
|
650
|
+
const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
|
|
651
|
+
if (ranges === null) return { reason: "source-input-ranges-unavailable" };
|
|
652
|
+
const bytes = supplyOf(sourceId);
|
|
653
|
+
let order = 0;
|
|
654
|
+
for (const range of ranges) {
|
|
655
|
+
// Bytes the torrent thread planned, or the near window brought, come when they were to come.
|
|
656
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId)));
|
|
657
|
+
const after = missingBefore(bytes, range.end);
|
|
658
|
+
if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
|
|
659
|
+
}
|
|
660
|
+
if (order === 0) continue;
|
|
661
|
+
const { service } = source;
|
|
662
|
+
if (!service.rate || !(service.rate.rateAt(0) > 0)) {
|
|
663
|
+
return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
|
|
664
|
+
unavailableSource: { sourceId, segmentIndex: segment.index, missingBytes: order - (bytes.asked ?? 0) } };
|
|
665
|
+
}
|
|
666
|
+
// The tail's first asked byte is where this source's tail begins:
|
|
667
|
+
// bytes missing before it are not this film's to wait for.
|
|
668
|
+
bytes.asked ??= Math.max(0, order - missingWithin(bytes, ranges));
|
|
669
|
+
let arrival;
|
|
670
|
+
if (order > bytes.asked) {
|
|
671
|
+
service.finish = service.rate.finish(order - bytes.asked, service.finish);
|
|
672
|
+
bytes.asked = order;
|
|
673
|
+
bytes.orders.push(order);
|
|
674
|
+
bytes.finishes.push(service.finish);
|
|
675
|
+
arrival = service.finish;
|
|
676
|
+
} else {
|
|
677
|
+
// Brought by an earlier piece's ask: when that ask reached it.
|
|
678
|
+
let low = 0, high = bytes.orders.length - 1;
|
|
679
|
+
while (low < high) {
|
|
680
|
+
const middle = (low + high) >>> 1;
|
|
681
|
+
if (bytes.orders[middle] < order) low = middle + 1;
|
|
682
|
+
else high = middle;
|
|
683
|
+
}
|
|
684
|
+
arrival = bytes.finishes[low] ?? service.finish;
|
|
685
|
+
}
|
|
686
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, arrival);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
|
|
690
|
+
if (work > 0) {
|
|
691
|
+
const run = runStarts[trackIndex];
|
|
692
|
+
const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
|
|
693
|
+
if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
|
|
694
|
+
run.admittedAt = admittedAt;
|
|
695
|
+
admittedAt += run.openSeconds;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const producedAt = work > 0 ? state.curve.finish(work, admittedAt) : 0;
|
|
699
|
+
trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
|
|
700
|
+
delivered = link.finish(sizeBits, Math.max(delivered, producedAt));
|
|
701
|
+
const completedAt = delivered + finiteNonNegative(track.appendSeconds);
|
|
702
|
+
// The media before a piece is what the playlist puts before it: whether a
|
|
703
|
+
// hole stops the media is decided in the near window, in exact time.
|
|
704
|
+
const ahead = Math.max(0, startsAt - position);
|
|
705
|
+
const requiresSource = sourceArrivalAt > 0;
|
|
706
|
+
constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false);
|
|
707
|
+
const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
|
|
708
|
+
Number(requiresSource));
|
|
709
|
+
constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The bytes not held, and not brought by the near window, before `byte` of a
|
|
715
|
+
* source: `byte` less what the sorted coverage holds below it.
|
|
716
|
+
*
|
|
717
|
+
* @param {{ coverage: Array<{ start: number, end: number }>, heldBefore: number[] }} bytes
|
|
718
|
+
* @param {number} byte
|
|
719
|
+
*/
|
|
720
|
+
function missingBefore(bytes, byte) {
|
|
721
|
+
const { coverage, heldBefore } = bytes;
|
|
722
|
+
let low = 0, high = coverage.length;
|
|
723
|
+
while (low < high) {
|
|
724
|
+
const middle = (low + high) >>> 1;
|
|
725
|
+
if (coverage[middle].end <= byte) low = middle + 1;
|
|
726
|
+
else high = middle;
|
|
727
|
+
}
|
|
728
|
+
const inside = low < coverage.length && coverage[low].start < byte ? byte - coverage[low].start : 0;
|
|
729
|
+
return byte - heldBefore[low] - inside;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The missing bytes of a piece's ranges of one source. */
|
|
733
|
+
function missingWithin(bytes, ranges) {
|
|
734
|
+
let missing = 0;
|
|
735
|
+
for (const range of ranges) missing += missingBefore(bytes, range.end) - missingBefore(bytes, range.start);
|
|
736
|
+
return missing;
|
|
737
|
+
}
|
|
738
|
+
|
|
530
739
|
/**
|
|
531
740
|
* Seconds from `from` to `to`, or zero when `to` is not later.
|
|
532
741
|
*
|
|
@@ -628,21 +837,24 @@ function heldRanges(ranges, segments, unit, position) {
|
|
|
628
837
|
}
|
|
629
838
|
|
|
630
839
|
/**
|
|
631
|
-
* The pieces of a track this forecast
|
|
632
|
-
* `position`)
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
* no
|
|
840
|
+
* The pieces of a track this forecast places in exact time: those still to
|
|
841
|
+
* play (ending after `position`) that start before `horizonEnd`, the first
|
|
842
|
+
* piece, every piece that has been read and every piece that is made. A piece
|
|
843
|
+
* that is none of these changes no exact answer: after the near window it is
|
|
844
|
+
* scheduled in seconds, it is not where the track starts, no unread piece is
|
|
845
|
+
* placed by it (only read pieces are), it lends no frame to what the page
|
|
846
|
+
* holds, and it has no size to measure the output's rate by.
|
|
637
847
|
*
|
|
638
848
|
* @param {{ segments: object[], readySegmentIndices?: number[] }} track
|
|
639
849
|
* @param {number} position - Seconds.
|
|
850
|
+
* @param {number} [horizonEnd] - Seconds: where the near window ends.
|
|
640
851
|
* @returns {object[]}
|
|
641
852
|
*/
|
|
642
|
-
function piecesThatMatter(track, position) {
|
|
853
|
+
function piecesThatMatter(track, position, horizonEnd = Number.POSITIVE_INFINITY) {
|
|
643
854
|
const made = new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []);
|
|
644
855
|
const kept = track.segments.filter((segment) => segment?.index === 0 || segment?.mediaRanges !== undefined ||
|
|
645
|
-
made.has(segment?.index) || !(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position)
|
|
856
|
+
made.has(segment?.index) || (!(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position) &&
|
|
857
|
+
!(Number.isFinite(segment?.startSeconds) && segment.startSeconds >= horizonEnd)));
|
|
646
858
|
return kept.length === track.segments.length ? track.segments : kept;
|
|
647
859
|
}
|
|
648
860
|
|