@torrent-tv/proxy 2.101.28 → 2.101.30
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/package.json +1 -1
- package/routes/api/client-logs/post.js +7 -2
- package/routes/api/sources/files/get.js +4 -1
- package/routes/api/sources/stats/get.js +5 -2
- package/services/encode/LocalObservations.js +112 -56
- package/services/server/ViewerRequests.js +186 -32
- package/services/viewer/playback-readiness.js +10 -0
package/package.json
CHANGED
|
@@ -91,14 +91,19 @@ export async function handleApiClientLogsPost(req, reply, { clientLogs, viewerRe
|
|
|
91
91
|
}
|
|
92
92
|
for (const row of rows) {
|
|
93
93
|
const message = row && typeof row.msg === "string" ? row.msg : "";
|
|
94
|
-
const match = /^\[first-frame-observation\] consumer=([^ ]+) generation=(\d+) waitId=([^ ]+) wait=(\d+) audioReady=(true|false) elapsedMs=([\d.]+)
|
|
94
|
+
const match = /^\[first-frame-observation\] consumer=([^ ]+) generation=(\d+) waitId=([^ ]+) wait=(\d+) audioReady=(true|false) elapsedMs=([\d.]+)(?: coldStartTotalMs=([\d.]+) transportMs=([\d.]+) planMs=([\d.]+) prepareMs=([\d.]+) prebufferMs=([\d.]+) presentationMs=([\d.]+))?$/.exec(message);
|
|
95
95
|
if (match) {
|
|
96
96
|
const elapsedMs = Number(match[6]);
|
|
97
97
|
if (match[1].length <= 64 && Number.isSafeInteger(Number(match[2])) && match[3].length <= 256 &&
|
|
98
98
|
Number.isSafeInteger(Number(match[4])) && match[5] === "true" &&
|
|
99
99
|
Number.isFinite(elapsedMs) && elapsedMs >= 0) {
|
|
100
|
+
const stageValues = match.slice(7).map(value => value === undefined ? null : Number(value));
|
|
101
|
+
const stages = stageValues.every(value => value !== null && Number.isFinite(value) && value >= 0)
|
|
102
|
+
? { totalMs: stageValues[0], transportMs: stageValues[1], planMs: stageValues[2],
|
|
103
|
+
prepareMs: stageValues[3], prebufferMs: stageValues[4], presentationMs: stageValues[5] }
|
|
104
|
+
: null;
|
|
100
105
|
viewerRequests?.noteFirstFrameObservation?.({ consumerId: match[1], generation: Number(match[2]),
|
|
101
|
-
waitId: match[3], wait: Number(match[4]), elapsedMs });
|
|
106
|
+
waitId: match[3], wait: Number(match[4]), elapsedMs, stages });
|
|
102
107
|
}
|
|
103
108
|
}
|
|
104
109
|
}
|
|
@@ -77,13 +77,16 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
77
77
|
const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
|
|
78
78
|
const linkMbps = Number(req.query?.linkMbps);
|
|
79
79
|
const linkSampleAgeMs = Number(req.query?.linkSampleAgeMs);
|
|
80
|
+
const waitElapsedMs = Number(req.query?.waitElapsedMs);
|
|
80
81
|
const earlyLink = Number.isFinite(linkMbps) && linkMbps > 0
|
|
81
82
|
? { linkMbps, linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null }
|
|
82
83
|
: {};
|
|
84
|
+
const earlyWaitAge = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? { waitElapsedMs } : {};
|
|
83
85
|
const files = contents.files().map(file => {
|
|
84
86
|
if (!consumerId || !Number.isSafeInteger(generation) || file.kind !== "video" || file.excludedReason) return file;
|
|
85
87
|
const waitEstimate = viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey,
|
|
86
|
-
fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length,
|
|
88
|
+
fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length,
|
|
89
|
+
...earlyLink, ...earlyWaitAge }) ?? null;
|
|
87
90
|
return waitEstimate ? { ...file, waitEstimate } : file;
|
|
88
91
|
});
|
|
89
92
|
return reply.send({
|
|
@@ -39,12 +39,14 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
39
39
|
? Number(req.query.fileLength) : null;
|
|
40
40
|
const linkMbps = Number(req.query.linkMbps);
|
|
41
41
|
const linkSampleAgeMs = Number(req.query.linkSampleAgeMs);
|
|
42
|
+
const waitElapsedMs = Number(req.query.waitElapsedMs);
|
|
42
43
|
const earlyLink = Number.isFinite(linkMbps) && linkMbps > 0
|
|
43
44
|
? { linkMbps, linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null }
|
|
44
45
|
: {};
|
|
46
|
+
const earlyWaitAge = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? { waitElapsedMs } : {};
|
|
45
47
|
const initialEstimate = () => consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)
|
|
46
48
|
? viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey, fileIndex,
|
|
47
|
-
fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink }) ?? null
|
|
49
|
+
fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink, ...earlyWaitAge }) ?? null
|
|
48
50
|
: null;
|
|
49
51
|
|
|
50
52
|
let torrent;
|
|
@@ -142,7 +144,8 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
142
144
|
|
|
143
145
|
const waitEstimate = consumerId && Number.isSafeInteger(generation)
|
|
144
146
|
? viewerRequests?.waitEstimateForSource?.({ consumerId, generation, sourceKey, fileIndex,
|
|
145
|
-
fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength,
|
|
147
|
+
fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength,
|
|
148
|
+
stats, ...earlyLink, ...earlyWaitAge }) ?? null
|
|
146
149
|
: null;
|
|
147
150
|
return reply.send(waitEstimate ? { ...stats, waitEstimate } : stats);
|
|
148
151
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* NOT A TABLE OF QUALITY, and not a condition for anything. The proxy selects,
|
|
7
7
|
* admits and serves with nothing kept here at all: the startup calibration is
|
|
8
8
|
* what qualifies a mode and prices it, and the admission is what decides
|
|
9
|
-
* whether there is room. What is kept here makes
|
|
9
|
+
* whether there is room. What is kept here makes four of those figures more
|
|
10
10
|
* exact on THIS machine once it has done the work for real:
|
|
11
11
|
*
|
|
12
12
|
* 1. how fast an encode of this mode, at this size, actually ran alone on this
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* 3. how long an output of this mode took to have the piece a viewer needed
|
|
20
20
|
* after it was asked for, and what that viewer held when they moved — used
|
|
21
21
|
* as the time to ready when deciding whether a draining buffer is a threat.
|
|
22
|
+
* 4. the measured transport, track-plan, output-preparation, prebuffer and
|
|
23
|
+
* first-presented-frame stages for an earlier start on this proxy
|
|
24
|
+
* configuration, used as separate inputs to an early wait forecast.
|
|
22
25
|
*
|
|
23
26
|
* WHAT IS NEVER KEPT: a picture, a clip, a segment, anything about a viewer
|
|
24
27
|
* beyond the seconds they held, and anything that leaves this machine.
|
|
@@ -59,6 +62,7 @@ export const PROXY_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url))
|
|
|
59
62
|
* figure follows the host as it changes.
|
|
60
63
|
*/
|
|
61
64
|
const READINGS_KEPT = 20;
|
|
65
|
+
const FIRST_FRAME_CATEGORICAL = new Set(["fileAvailable", "videoOperation", "audioOperation"]);
|
|
62
66
|
|
|
63
67
|
/** The version of the file's shape; a file of another shape is not read. */
|
|
64
68
|
const FILE_VERSION = 1;
|
|
@@ -264,27 +268,21 @@ export class LocalObservations {
|
|
|
264
268
|
}
|
|
265
269
|
|
|
266
270
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* only on the encoder configurations present on this host.
|
|
271
|
+
* The viewer presented its first audio-ready frame: keep the separately
|
|
272
|
+
* measured stage timings and the input snapshots from the start of that run.
|
|
270
273
|
*
|
|
271
|
-
* @param {{ seconds: number, features: object }} params
|
|
274
|
+
* @param {{ seconds: number, features: object, featureSnapshots?: object[], stages?: object | null }} params
|
|
272
275
|
* @returns {void}
|
|
273
276
|
*/
|
|
274
|
-
noteFirstFrame({ seconds, features }) {
|
|
277
|
+
noteFirstFrame({ seconds, features, featureSnapshots = null, stages = null }) {
|
|
275
278
|
if (!Number.isFinite(seconds) || seconds < 0 || !features || typeof features !== "object") return;
|
|
276
279
|
const key = this.#firstFrameKey();
|
|
280
|
+
const snapshots = [features, ...(Array.isArray(featureSnapshots) ? featureSnapshots : [])]
|
|
281
|
+
.filter(value => value && typeof value === "object").slice(0, 2).map(firstFrameFeatures);
|
|
277
282
|
const readings = [...(this.#entries.firstFrame[key] ?? []), {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
downloadSpeed: nonNegative(features.downloadSpeed),
|
|
282
|
-
linkMbps: nonNegative(features.linkMbps),
|
|
283
|
-
linkSampleAgeMs: nonNegative(features.linkSampleAgeMs),
|
|
284
|
-
preparedOutputBytes: nonNegative(features.preparedOutputBytes),
|
|
285
|
-
fileAvailable: typeof features.fileAvailable === "boolean" ? features.fileAvailable : null,
|
|
286
|
-
headerRemaining: nonNegative(features.headerRemaining),
|
|
287
|
-
peers: nonNegative(features.peers),
|
|
283
|
+
...snapshots[0],
|
|
284
|
+
featureSnapshots: snapshots,
|
|
285
|
+
stages: stagesOf(stages),
|
|
288
286
|
at: this.#now()
|
|
289
287
|
}].slice(-READINGS_KEPT);
|
|
290
288
|
this.#entries.firstFrame[key] = readings;
|
|
@@ -292,60 +290,86 @@ export class LocalObservations {
|
|
|
292
290
|
}
|
|
293
291
|
|
|
294
292
|
/**
|
|
295
|
-
* Predict
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
293
|
+
* Predict the first-frame wait as the sum of separately predicted cold-start
|
|
294
|
+
* stages. Each stage uses all compatible measurements, weighted by the
|
|
295
|
+
* normalized distance between the measured inputs; no complete past wait is
|
|
296
|
+
* returned as the forecast. A known cache/source state must also be known in
|
|
297
|
+
* the training observation, otherwise that observation is not comparable.
|
|
300
298
|
*
|
|
301
299
|
* @param {object} features
|
|
302
300
|
* @returns {number | null}
|
|
303
301
|
*/
|
|
304
302
|
firstFrameSeconds(features) {
|
|
305
303
|
if (!features || typeof features !== "object") return null;
|
|
306
|
-
const
|
|
307
|
-
|
|
304
|
+
const candidates = (this.#entries.firstFrame[this.#firstFrameKey()] ?? [])
|
|
305
|
+
.filter(reading => reading.extension === (features.extension ?? "") && reading.stages);
|
|
308
306
|
if (candidates.length === 0) return null;
|
|
309
307
|
const dimensions = ["fileLength", "downloadSpeed", "linkMbps", "linkSampleAgeMs", "preparedOutputBytes",
|
|
310
|
-
"fileAvailable", "headerRemaining", "peers"];
|
|
311
|
-
const
|
|
312
|
-
|
|
308
|
+
"fileAvailable", "headerRemaining", "peers", "videoOperation", "audioOperation", "videoSpeedX", "audioSpeedX"];
|
|
309
|
+
const pointsOf = reading => Array.isArray(reading.featureSnapshots) && reading.featureSnapshots.length > 0
|
|
310
|
+
? reading.featureSnapshots : [reading];
|
|
311
|
+
const scales = new Map(dimensions.map(key => [key, median(candidates.flatMap(reading => pointsOf(reading)
|
|
312
|
+
.map(point => FIRST_FRAME_CATEGORICAL.has(key) ? null : nonNegative(point[key])))
|
|
313
313
|
.filter(value => value !== null && value > 0))]));
|
|
314
|
-
|
|
314
|
+
const distances = [];
|
|
315
315
|
for (const reading of candidates) {
|
|
316
|
-
let
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
316
|
+
let bestDistance = null;
|
|
317
|
+
for (const point of pointsOf(reading)) {
|
|
318
|
+
if ((features.preparedOutputBytes !== null && features.preparedOutputBytes !== undefined &&
|
|
319
|
+
point.preparedOutputBytes === null) ||
|
|
320
|
+
[...FIRST_FRAME_CATEGORICAL].some(key => {
|
|
321
|
+
const current = key === "fileAvailable" ? features[key] : operationOf(features[key]);
|
|
322
|
+
return current !== null && current !== undefined && point[key] !== current;
|
|
323
|
+
})) continue;
|
|
324
|
+
let distance = 0;
|
|
325
|
+
let used = 0;
|
|
326
|
+
let shared = 0;
|
|
327
|
+
for (const key of dimensions) {
|
|
328
|
+
const current = FIRST_FRAME_CATEGORICAL.has(key)
|
|
329
|
+
? key === "fileAvailable" ? (typeof features[key] === "boolean" ? features[key] : null)
|
|
330
|
+
: operationOf(features[key])
|
|
331
|
+
: nonNegative(features[key]);
|
|
332
|
+
const previous = FIRST_FRAME_CATEGORICAL.has(key)
|
|
333
|
+
? key === "fileAvailable" ? (typeof point[key] === "boolean" ? point[key] : null)
|
|
334
|
+
: operationOf(point[key])
|
|
335
|
+
: nonNegative(point[key]);
|
|
336
|
+
if (current === null && previous === null) continue;
|
|
337
|
+
if (current === null || previous === null) {
|
|
338
|
+
distance += 1;
|
|
339
|
+
used += 1;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
shared += 1;
|
|
343
|
+
const scale = scales.get(key);
|
|
344
|
+
const difference = typeof current !== "number" && typeof previous !== "number"
|
|
345
|
+
? Number(current !== previous)
|
|
346
|
+
: scale === null
|
|
347
|
+
? Number(current !== previous)
|
|
348
|
+
: Math.log1p(current / scale) - Math.log1p(previous / scale);
|
|
349
|
+
distance += difference * difference;
|
|
329
350
|
used += 1;
|
|
330
|
-
continue;
|
|
331
351
|
}
|
|
332
|
-
shared
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
? Number(current !== previous)
|
|
336
|
-
: scale === null
|
|
337
|
-
? Number(current !== previous)
|
|
338
|
-
: Math.log1p(current / scale) - Math.log1p(previous / scale);
|
|
339
|
-
distance += difference * difference;
|
|
340
|
-
used += 1;
|
|
341
|
-
}
|
|
342
|
-
if (used === 0 || shared === 0) continue;
|
|
343
|
-
distance /= used;
|
|
344
|
-
if (!best || distance < best.distance || (distance === best.distance && reading.at > best.reading.at)) {
|
|
345
|
-
best = { distance, reading };
|
|
352
|
+
if (used === 0 || shared === 0) continue;
|
|
353
|
+
distance /= used;
|
|
354
|
+
if (bestDistance === null || distance < bestDistance) bestDistance = distance;
|
|
346
355
|
}
|
|
356
|
+
if (bestDistance !== null) distances.push({ distance: bestDistance, reading });
|
|
347
357
|
}
|
|
348
|
-
|
|
358
|
+
if (distances.length === 0) return null;
|
|
359
|
+
const exact = distances.filter(candidate => candidate.distance === 0);
|
|
360
|
+
const weighted = exact.length > 0
|
|
361
|
+
? exact.map(candidate => ({ ...candidate, weight: 1 }))
|
|
362
|
+
: distances.map(candidate => ({ ...candidate, weight: 1 / candidate.distance }));
|
|
363
|
+
const stages = ["transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
|
|
364
|
+
const predicted = stages.map(stage => {
|
|
365
|
+
const applicable = weighted.filter(candidate => Number.isFinite(candidate.reading.stages[stage]));
|
|
366
|
+
const totalWeight = applicable.reduce((sum, candidate) => sum + candidate.weight, 0);
|
|
367
|
+
return totalWeight > 0
|
|
368
|
+
? applicable.reduce((sum, candidate) => sum + candidate.reading.stages[stage] * candidate.weight, 0) / totalWeight
|
|
369
|
+
: null;
|
|
370
|
+
});
|
|
371
|
+
if (predicted.some(value => value === null)) return null;
|
|
372
|
+
return predicted.reduce((sum, value) => sum + value, 0) / 1000;
|
|
349
373
|
}
|
|
350
374
|
|
|
351
375
|
#firstFrameKey() {
|
|
@@ -458,6 +482,38 @@ function nonNegative(value) {
|
|
|
458
482
|
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
459
483
|
}
|
|
460
484
|
|
|
485
|
+
/** @param {unknown} value @returns {"copy" | "encode" | null} */
|
|
486
|
+
function operationOf(value) {
|
|
487
|
+
return value === "copy" || value === "encode" ? value : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** @param {unknown} value @returns {{ totalMs: number, transportMs: number, planMs: number, prepareMs: number, prebufferMs: number, presentationMs: number } | null} */
|
|
491
|
+
function stagesOf(value) {
|
|
492
|
+
if (!value || typeof value !== "object") return null;
|
|
493
|
+
const keys = ["totalMs", "transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
|
|
494
|
+
const stages = Object.fromEntries(keys.map(key => [key, nonNegative(value[key])]));
|
|
495
|
+
return keys.every(key => stages[key] !== null) ? stages : null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** @param {object} features @returns {object} */
|
|
499
|
+
function firstFrameFeatures(features) {
|
|
500
|
+
return {
|
|
501
|
+
extension: typeof features.extension === "string" ? features.extension : "",
|
|
502
|
+
fileLength: positive(features.fileLength),
|
|
503
|
+
downloadSpeed: nonNegative(features.downloadSpeed),
|
|
504
|
+
linkMbps: nonNegative(features.linkMbps),
|
|
505
|
+
linkSampleAgeMs: nonNegative(features.linkSampleAgeMs),
|
|
506
|
+
preparedOutputBytes: nonNegative(features.preparedOutputBytes),
|
|
507
|
+
fileAvailable: typeof features.fileAvailable === "boolean" ? features.fileAvailable : null,
|
|
508
|
+
headerRemaining: nonNegative(features.headerRemaining),
|
|
509
|
+
peers: nonNegative(features.peers),
|
|
510
|
+
videoOperation: operationOf(features.videoOperation),
|
|
511
|
+
audioOperation: operationOf(features.audioOperation),
|
|
512
|
+
videoSpeedX: nonNegative(features.videoSpeedX),
|
|
513
|
+
audioSpeedX: nonNegative(features.audioSpeedX)
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
461
517
|
/** @param {number[]} values @returns {number | null} */
|
|
462
518
|
function median(values) {
|
|
463
519
|
if (values.length === 0) return null;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { logger } from "../../utils/logger.js";
|
|
11
|
-
import { isOutputName } from "../encode/output/index.js";
|
|
11
|
+
import { isOutputName, OutputSpec } from "../encode/output/index.js";
|
|
12
12
|
import { withSubtitleReadiness } from "./subtitle-readiness.js";
|
|
13
13
|
|
|
14
14
|
function isWarmupTimeoutError(error) {
|
|
@@ -106,19 +106,20 @@ export class ViewerRequests {
|
|
|
106
106
|
* @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number, fileName: string, fileLength: number }} params
|
|
107
107
|
* @returns {object | null}
|
|
108
108
|
*/
|
|
109
|
-
waitEstimateForFile({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs
|
|
109
|
+
waitEstimateForFile({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs,
|
|
110
|
+
waitElapsedMs }) {
|
|
110
111
|
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
|
|
111
112
|
!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null;
|
|
112
113
|
const waitId = `${sourceKey}:${fileIndex}:${generation}`;
|
|
113
114
|
const key = `${consumerId}:${waitId}`;
|
|
114
115
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
116
|
+
const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
|
|
115
117
|
const features = {
|
|
116
118
|
extension,
|
|
117
119
|
fileLength,
|
|
118
120
|
downloadSpeed: null,
|
|
119
|
-
linkMbps:
|
|
120
|
-
|
|
121
|
-
linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null,
|
|
121
|
+
linkMbps: link.mbps,
|
|
122
|
+
linkSampleAgeMs: link.ageMs,
|
|
122
123
|
preparedOutputBytes: this.#preparedOutputBytes(sourceKey, fileIndex),
|
|
123
124
|
fileAvailable: null,
|
|
124
125
|
headerRemaining: null,
|
|
@@ -126,19 +127,27 @@ export class ViewerRequests {
|
|
|
126
127
|
};
|
|
127
128
|
const current = this.#earlyWaits.get(key);
|
|
128
129
|
if (current?.waitId === waitId && current.hasSourceObservation) {
|
|
129
|
-
const
|
|
130
|
+
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features,
|
|
131
|
+
revision: current.revision, current, waitElapsedMs });
|
|
132
|
+
const changed = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
133
|
+
if (changed) context.revision += 1;
|
|
134
|
+
this.#pruneEarlyWaits();
|
|
135
|
+
const totalSeconds = this.#host.firstFrameSecondsOf?.(context.features) ?? null;
|
|
136
|
+
const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
|
|
130
137
|
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
131
|
-
return { version: 1, waitId, revision:
|
|
138
|
+
return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
132
139
|
}
|
|
133
140
|
const sameWait = current?.waitId === waitId;
|
|
134
141
|
const revision = sameWait ? current.revision : 0;
|
|
135
|
-
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current
|
|
136
|
-
|
|
142
|
+
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
143
|
+
waitElapsedMs });
|
|
144
|
+
const changed = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
137
145
|
if (sameWait && changed) context.revision += 1;
|
|
138
146
|
this.#pruneEarlyWaits();
|
|
139
|
-
const
|
|
147
|
+
const totalSeconds = this.#host.firstFrameSecondsOf?.(context.features) ?? null;
|
|
148
|
+
const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
|
|
140
149
|
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
141
|
-
return { version: 1, waitId, revision, remainingSeconds: seconds, ageMs: 0 };
|
|
150
|
+
return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
/**
|
|
@@ -149,35 +158,45 @@ export class ViewerRequests {
|
|
|
149
158
|
* @returns {object | null}
|
|
150
159
|
*/
|
|
151
160
|
waitEstimateForSource({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
|
|
152
|
-
stats = {}, linkMbps, linkSampleAgeMs }) {
|
|
161
|
+
stats = {}, linkMbps, linkSampleAgeMs, waitElapsedMs, processing = {} }) {
|
|
153
162
|
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return null;
|
|
154
163
|
const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
|
|
155
164
|
const key = `${consumerId}:${waitId}`;
|
|
156
165
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
166
|
+
const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
|
|
157
167
|
const features = {
|
|
158
168
|
extension,
|
|
159
169
|
fileLength: Number.isFinite(stats?.fileLength) && stats.fileLength > 0
|
|
160
170
|
? stats.fileLength : fileLength,
|
|
161
171
|
downloadSpeed: stats?.downloadSpeed,
|
|
162
|
-
linkMbps:
|
|
163
|
-
|
|
164
|
-
linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null,
|
|
172
|
+
linkMbps: link.mbps,
|
|
173
|
+
linkSampleAgeMs: link.ageMs,
|
|
165
174
|
preparedOutputBytes: Number.isSafeInteger(fileIndex) && fileIndex >= 0
|
|
166
175
|
? this.#preparedOutputBytes(sourceKey, fileIndex) : null,
|
|
167
176
|
fileAvailable: typeof stats?.fileAvailable === "boolean" ? stats.fileAvailable : null,
|
|
168
177
|
headerRemaining: Number.isFinite(stats?.headerBytes) && Number.isFinite(stats?.headerDownloadedBytes)
|
|
169
178
|
? Math.max(0, stats.headerBytes - stats.headerDownloadedBytes) : null,
|
|
170
|
-
peers: stats?.connectedPeers ?? stats?.numPeers
|
|
179
|
+
peers: stats?.connectedPeers ?? stats?.numPeers,
|
|
180
|
+
videoOperation: processing.videoOperation ?? null,
|
|
181
|
+
audioOperation: processing.audioOperation ?? null,
|
|
182
|
+
videoSpeedX: processing.videoSpeedX ?? null,
|
|
183
|
+
audioSpeedX: processing.audioSpeedX ?? null
|
|
171
184
|
};
|
|
172
185
|
const current = this.#earlyWaits.get(key);
|
|
173
|
-
const
|
|
186
|
+
const sameWait = current?.waitId === waitId;
|
|
187
|
+
const revision = sameWait ? current.revision : 0;
|
|
174
188
|
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
175
|
-
sourceObserved: true });
|
|
176
|
-
this.#recordEarlyWaitSnapshot(context, features);
|
|
189
|
+
sourceObserved: true, waitElapsedMs });
|
|
190
|
+
const changed = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
191
|
+
if (sameWait && changed) context.revision += 1;
|
|
177
192
|
this.#pruneEarlyWaits();
|
|
178
|
-
const
|
|
193
|
+
const measuredWorkSeconds = processing.firstRequiredSegmentsSeconds;
|
|
194
|
+
const totalSeconds = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0
|
|
195
|
+
? waitElapsedSince(context, waitElapsedMs) / 1000 + measuredWorkSeconds
|
|
196
|
+
: this.#host.firstFrameSecondsOf?.(context.features) ?? null;
|
|
197
|
+
const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
|
|
179
198
|
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
180
|
-
return { version: 1, waitId, revision, remainingSeconds: seconds, ageMs: 0 };
|
|
199
|
+
return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
/**
|
|
@@ -185,22 +204,27 @@ export class ViewerRequests {
|
|
|
185
204
|
* viewing. The saved source and link readings are the ones available at
|
|
186
205
|
* selection time, before the full track plan existed.
|
|
187
206
|
*
|
|
188
|
-
* @param {{ consumerId: string, generation: number, waitId: string, wait: number, elapsedMs: number
|
|
207
|
+
* @param {{ consumerId: string, generation: number, waitId: string, wait: number, elapsedMs: number,
|
|
208
|
+
* stages?: { totalMs: number, transportMs: number, planMs: number, prepareMs: number,
|
|
209
|
+
* prebufferMs: number, presentationMs: number } | null }} params
|
|
189
210
|
* @returns {void}
|
|
190
211
|
*/
|
|
191
|
-
noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs }) {
|
|
212
|
+
noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs, stages = null }) {
|
|
192
213
|
const key = `${consumerId}:${waitId}`;
|
|
193
214
|
const early = this.#earlyWaits.get(key);
|
|
194
215
|
if (!early || early.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || early.observedWaits.has(wait) ||
|
|
195
216
|
!Number.isFinite(elapsedMs) || elapsedMs < 0) return;
|
|
196
|
-
|
|
197
|
-
|
|
217
|
+
const initialFeatures = early.snapshots[0];
|
|
218
|
+
if (initialFeatures) {
|
|
219
|
+
this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: initialFeatures,
|
|
220
|
+
featureSnapshots: early.snapshots.slice(1), stages });
|
|
198
221
|
}
|
|
199
222
|
early.observedWaits.add(wait);
|
|
200
223
|
early.updatedAt = Date.now();
|
|
201
224
|
}
|
|
202
225
|
|
|
203
|
-
#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
226
|
+
#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
227
|
+
sourceObserved = false, waitElapsedMs }) {
|
|
204
228
|
const sameWait = current?.waitId === waitId;
|
|
205
229
|
const context = sameWait ? current : {
|
|
206
230
|
consumerId,
|
|
@@ -211,7 +235,17 @@ export class ViewerRequests {
|
|
|
211
235
|
hasSourceObservation: false,
|
|
212
236
|
observedWaits: new Set()
|
|
213
237
|
};
|
|
214
|
-
|
|
238
|
+
const measuredAt = performance.now();
|
|
239
|
+
const actionElapsedMs = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? waitElapsedMs : null;
|
|
240
|
+
const startedAt = actionElapsedMs === null
|
|
241
|
+
? Number.isFinite(context.startedAt) ? context.startedAt : measuredAt
|
|
242
|
+
: measuredAt - actionElapsedMs;
|
|
243
|
+
const ageElapsedMs = Number.isFinite(context.featuresMeasuredAt)
|
|
244
|
+
? Math.max(0, measuredAt - context.featuresMeasuredAt)
|
|
245
|
+
: 0;
|
|
246
|
+
const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
|
|
247
|
+
Object.assign(context, { consumerId, generation, fileIndex, waitId, features: mergedFeatures,
|
|
248
|
+
featuresMeasuredAt: measuredAt, startedAt, updatedAt: Date.now(), revision,
|
|
215
249
|
hasSourceObservation: context.hasSourceObservation || sourceObserved });
|
|
216
250
|
this.#earlyWaits.set(`${consumerId}:${waitId}`, context);
|
|
217
251
|
return context;
|
|
@@ -232,11 +266,21 @@ export class ViewerRequests {
|
|
|
232
266
|
if (!outputs?.values || !segmentStore?.sizesOf) return null;
|
|
233
267
|
let bytes = 0;
|
|
234
268
|
const seen = new Set();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
269
|
+
const liveOutputs = [...outputs.values()];
|
|
270
|
+
const keys = new Set([
|
|
271
|
+
...liveOutputs.map(output => output.outputKey),
|
|
272
|
+
...(segmentStore.addresses?.() ?? [])
|
|
273
|
+
].filter(key => typeof key === "string" && key.length > 0));
|
|
274
|
+
for (const outputKey of keys) {
|
|
275
|
+
const output = liveOutputs.find(candidate => candidate.outputKey === outputKey);
|
|
276
|
+
const spec = output ? null : OutputSpec.fromKey(outputKey);
|
|
277
|
+
const matchesFile = output
|
|
278
|
+
? output.file?.sourceKey === sourceKey && output.file?.fileIndex === fileIndex
|
|
279
|
+
: spec?.sourceKey === sourceKey &&
|
|
280
|
+
(spec.video?.fileIndex === fileIndex || spec.audio?.fileIndex === fileIndex);
|
|
281
|
+
if (!matchesFile || seen.has(outputKey)) continue;
|
|
282
|
+
seen.add(outputKey);
|
|
283
|
+
for (const size of segmentStore.sizesOf(outputKey).values()) {
|
|
240
284
|
if (Number.isFinite(size) && size > 0) bytes += size;
|
|
241
285
|
}
|
|
242
286
|
}
|
|
@@ -768,6 +812,7 @@ export class ViewerRequests {
|
|
|
768
812
|
|
|
769
813
|
const sourceMeasurements = new Map();
|
|
770
814
|
const sourceForecasts = new Map();
|
|
815
|
+
let primarySourceStats = null;
|
|
771
816
|
const tracks = [];
|
|
772
817
|
for (const output of outputs) {
|
|
773
818
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
@@ -803,6 +848,7 @@ export class ViewerRequests {
|
|
|
803
848
|
} catch {
|
|
804
849
|
stats = null;
|
|
805
850
|
}
|
|
851
|
+
if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
|
|
806
852
|
parts.statsMs += performance.now() - statsAt;
|
|
807
853
|
parts.workerForecastMs += Number(stats?.forecastMs) || 0;
|
|
808
854
|
if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
|
|
@@ -929,6 +975,29 @@ export class ViewerRequests {
|
|
|
929
975
|
outputCodec: output.spec.audio.transcode ? "aac" : null }] : [])
|
|
930
976
|
];
|
|
931
977
|
});
|
|
978
|
+
const generation = consumerId && viewer?.assignments
|
|
979
|
+
? this.#host.generationOfRequest(consumerId) : null;
|
|
980
|
+
const earlyWaitId = Number.isSafeInteger(generation)
|
|
981
|
+
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
982
|
+
if (earlyWaitId && this.#earlyWaits.has(`${consumerId}:${earlyWaitId}`) && primarySourceStats) {
|
|
983
|
+
const linkMeasuredAt = Number(viewerReading?.linkMeasuredAt);
|
|
984
|
+
const videoOperation = forecast.operations.find(operation => operation.track === "video");
|
|
985
|
+
const audioOperation = forecast.operations.find(operation => operation.track === "audio");
|
|
986
|
+
const waitEstimate = this.waitEstimateForSource({ consumerId, generation,
|
|
987
|
+
sourceKey: session.file.sourceKey, fileIndex: session.file.fileIndex,
|
|
988
|
+
fileName: session.file.relativePath ?? session.file.name ?? "",
|
|
989
|
+
fileLength: primarySourceStats.fileLength, stats: primarySourceStats,
|
|
990
|
+
linkMbps: viewerReading?.linkMbps,
|
|
991
|
+
linkSampleAgeMs: Number.isFinite(linkMeasuredAt) ? Math.max(0, Date.now() - linkMeasuredAt) : undefined,
|
|
992
|
+
processing: {
|
|
993
|
+
videoOperation: videoOperation?.operation,
|
|
994
|
+
audioOperation: audioOperation?.operation,
|
|
995
|
+
videoSpeedX: videoOperation?.speed,
|
|
996
|
+
audioSpeedX: audioOperation?.speed,
|
|
997
|
+
firstRequiredSegmentsSeconds: forecast.firstRequiredSegmentsSeconds
|
|
998
|
+
} });
|
|
999
|
+
if (waitEstimate) forecast.waitEstimate = waitEstimate;
|
|
1000
|
+
}
|
|
932
1001
|
if (measurement.forecastReason !== forecast.reason) {
|
|
933
1002
|
measurement.forecastReason = forecast.reason;
|
|
934
1003
|
logger.info(`playback readiness ${session.id} consumer=${consumerId} ${JSON.stringify({
|
|
@@ -1134,6 +1203,91 @@ export class ViewerRequests {
|
|
|
1134
1203
|
}
|
|
1135
1204
|
}
|
|
1136
1205
|
|
|
1206
|
+
/**
|
|
1207
|
+
* Keep a wait's measurements when a later response omits a field. Zero is a
|
|
1208
|
+
* measurement for rates, peers and byte counts; only null or an invalid value
|
|
1209
|
+
* means that this response did not measure the field.
|
|
1210
|
+
*
|
|
1211
|
+
* @param {object | null | undefined} previous
|
|
1212
|
+
* @param {object} incoming
|
|
1213
|
+
* @param {number} ageElapsedMs
|
|
1214
|
+
* @returns {object}
|
|
1215
|
+
*/
|
|
1216
|
+
function mergeWaitFeatures(previous, incoming, ageElapsedMs) {
|
|
1217
|
+
const merged = { ...(previous ?? {}) };
|
|
1218
|
+
for (const [key, value] of Object.entries(incoming ?? {})) {
|
|
1219
|
+
if (value === null || value === undefined) {
|
|
1220
|
+
if (!Object.hasOwn(merged, key)) merged[key] = null;
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
if (key === "extension") {
|
|
1224
|
+
if (typeof value === "string" && value.length > 0) merged[key] = value;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (key === "fileAvailable") {
|
|
1228
|
+
if (typeof value === "boolean") merged[key] = value;
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (key === "videoOperation" || key === "audioOperation") {
|
|
1232
|
+
if (value === "copy" || value === "encode") merged[key] = value;
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 ||
|
|
1236
|
+
((key === "fileLength" || key === "linkMbps") && value === 0)) continue;
|
|
1237
|
+
merged[key] = value;
|
|
1238
|
+
}
|
|
1239
|
+
const hasIncomingAge = Number.isFinite(incoming?.linkSampleAgeMs) && incoming.linkSampleAgeMs >= 0;
|
|
1240
|
+
if (Number.isFinite(merged.linkSampleAgeMs) && !hasIncomingAge) {
|
|
1241
|
+
merged.linkSampleAgeMs += Number.isFinite(ageElapsedMs) ? Math.max(0, ageElapsedMs) : 0;
|
|
1242
|
+
}
|
|
1243
|
+
return merged;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Read the separately measured viewer-to-proxy link and retain the age of the
|
|
1248
|
+
* sample when an older page does not send it with its source request.
|
|
1249
|
+
*
|
|
1250
|
+
* @param {object | null | undefined} viewer
|
|
1251
|
+
* @param {number | null | undefined} reportedMbps
|
|
1252
|
+
* @param {number | null | undefined} reportedAgeMs
|
|
1253
|
+
* @returns {{ mbps: number | null, ageMs: number | null }}
|
|
1254
|
+
*/
|
|
1255
|
+
function linkMeasurementOf(viewer, reportedMbps, reportedAgeMs) {
|
|
1256
|
+
const reading = viewer?.linkReading?.() ?? null;
|
|
1257
|
+
const mbps = Number.isFinite(reportedMbps) && reportedMbps > 0
|
|
1258
|
+
? reportedMbps
|
|
1259
|
+
: Number.isFinite(reading?.linkMbps) && reading.linkMbps > 0 ? reading.linkMbps : null;
|
|
1260
|
+
const ageMs = Number.isFinite(reportedAgeMs) && reportedAgeMs >= 0
|
|
1261
|
+
? reportedAgeMs
|
|
1262
|
+
: Number.isFinite(reading?.linkMeasuredAt) ? Math.max(0, Date.now() - reading.linkMeasuredAt) : null;
|
|
1263
|
+
return { mbps, ageMs };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Convert a learned full wait into the time still expected after this action.
|
|
1268
|
+
*
|
|
1269
|
+
* @param {number | null} totalSeconds
|
|
1270
|
+
* @param {number} elapsedMs
|
|
1271
|
+
* @returns {number | null}
|
|
1272
|
+
*/
|
|
1273
|
+
function earlyWaitRemainingSeconds(totalSeconds, elapsedMs) {
|
|
1274
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds < 0 || !Number.isFinite(elapsedMs) || elapsedMs < 0) return null;
|
|
1275
|
+
return Math.max(0, totalSeconds - elapsedMs / 1000);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Prefer the browser's monotonic age of the original action; retain a proxy
|
|
1280
|
+
* monotonic clock fallback for older pages that do not send it.
|
|
1281
|
+
*
|
|
1282
|
+
* @param {object} context
|
|
1283
|
+
* @param {number | undefined} reportedElapsedMs
|
|
1284
|
+
* @returns {number}
|
|
1285
|
+
*/
|
|
1286
|
+
function waitElapsedSince(context, reportedElapsedMs) {
|
|
1287
|
+
if (Number.isFinite(reportedElapsedMs) && reportedElapsedMs >= 0) return reportedElapsedMs;
|
|
1288
|
+
return Number.isFinite(context.startedAt) ? Math.max(0, performance.now() - context.startedAt) : 0;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1137
1291
|
/**
|
|
1138
1292
|
* The download service a source's forecast runs on: what the swarm carried
|
|
1139
1293
|
* while our selection held none of its connections back, but never less than
|
|
@@ -542,6 +542,15 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
542
542
|
...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
|
|
543
543
|
}
|
|
544
544
|
}
|
|
545
|
+
const firstRequiredTrackSeconds = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
|
|
546
|
+
const first = state.segments.find(segment => segment.endSeconds > position);
|
|
547
|
+
if (!first) return 0;
|
|
548
|
+
if (first.ranges.every(range => contains(state.clientRanges, range.start, range.end))) return 0;
|
|
549
|
+
const completion = completions.find(item => item.trackIndex === trackIndex && item.segment.index === first.index);
|
|
550
|
+
return completion?.at ?? null;
|
|
551
|
+
});
|
|
552
|
+
const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
|
|
553
|
+
? Math.max(0, ...firstRequiredTrackSeconds) : null;
|
|
545
554
|
const delay = Math.max(0, ...constraints);
|
|
546
555
|
const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
|
|
547
556
|
const ready = delay === 0 && originReady;
|
|
@@ -549,6 +558,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
549
558
|
Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
|
|
550
559
|
Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
|
|
551
560
|
return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
|
|
561
|
+
firstRequiredSegmentsSeconds,
|
|
552
562
|
// The piece whose `kind` — its production, or its delivery to the page —
|
|
553
563
|
// comes `atSeconds` from now while the media before it lasts
|
|
554
564
|
// `aheadSeconds`: the difference is the delay.
|