@torrent-tv/proxy 2.107.53 → 2.107.55
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/sources/stats/get.js +4 -4
- package/routes/api/transcode-sessions/net-report/post.js +18 -0
- package/routes/api/transcode-sessions/progress/get.js +9 -1
- package/services/encode/EncodeRun.js +13 -3
- package/services/encode/RunProgress.js +11 -2
- package/services/encode/SpeedReadings.js +10 -2
- package/services/server/ViewerRequests.js +172 -44
- package/services/torrent/worker/worker.js +27 -3
- package/services/viewer/OutputForecastGeometry.js +19 -1
- package/services/viewer/Viewer.js +117 -17
- package/services/viewer/ViewerPlaybackState.js +25 -7
- package/services/viewer/playback-readiness.js +253 -32
package/package.json
CHANGED
|
@@ -70,12 +70,10 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
70
70
|
// local lookup. Without the await the reply was the pending promise itself,
|
|
71
71
|
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
72
72
|
const statsStartedAt = performance.now();
|
|
73
|
-
let statsCompletedAt;
|
|
74
73
|
let stats;
|
|
75
74
|
try {
|
|
76
75
|
stats = await torrentPool.getFileStats(torrent, fileIndex);
|
|
77
76
|
} finally {
|
|
78
|
-
statsCompletedAt = performance.now();
|
|
79
77
|
logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
|
|
80
78
|
}
|
|
81
79
|
if (stats?.pending || !stats) {
|
|
@@ -161,9 +159,11 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
161
159
|
}
|
|
162
160
|
const responsePreparedAtProxyMs = performance.now();
|
|
163
161
|
return reply.send({ ...stats, sourceSnapshotTransport: {
|
|
162
|
+
clockEpoch: performance.timeOrigin,
|
|
163
|
+
snapshotClockEpoch: stats.sourceCapture?.clockEpoch ?? null,
|
|
164
164
|
requestReceivedAtProxyMs,
|
|
165
|
-
snapshotStartedAtProxyMs:
|
|
166
|
-
snapshotCompletedAtProxyMs:
|
|
165
|
+
snapshotStartedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.startedAt : null,
|
|
166
|
+
snapshotCompletedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.completedAt : null,
|
|
167
167
|
responsePreparedAtProxyMs
|
|
168
168
|
} });
|
|
169
169
|
}
|
|
@@ -117,12 +117,30 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
|
|
|
117
117
|
linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
|
|
118
118
|
linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
|
|
119
119
|
linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
|
|
120
|
+
linkSampleAgeMs: Number.isFinite(body.linkSampleAgeMs) && body.linkSampleAgeMs >= 0
|
|
121
|
+
? body.linkSampleAgeMs : undefined,
|
|
120
122
|
appendSessionId: sessionId,
|
|
121
123
|
appendEpoch: typeof body.appendEpoch === "string" ? body.appendEpoch : undefined,
|
|
122
124
|
appendEpochStartedAt: Number.isFinite(body.appendEpochStartedAt) && body.appendEpochStartedAt > 0
|
|
123
125
|
? body.appendEpochStartedAt : undefined,
|
|
124
126
|
appendSnapshotAt: Number.isFinite(body.appendSnapshotAt) && body.appendSnapshotAt > 0
|
|
125
127
|
? body.appendSnapshotAt : undefined,
|
|
128
|
+
captureClockEpoch: Number.isFinite(body.captureClockEpoch) ? body.captureClockEpoch : undefined,
|
|
129
|
+
captureEpoch: typeof body.captureEpoch === "string" ? body.captureEpoch : undefined,
|
|
130
|
+
captureEpochStartedAtBrowser: Number.isFinite(body.captureEpochStartedAtBrowser)
|
|
131
|
+
? body.captureEpochStartedAtBrowser : undefined,
|
|
132
|
+
captureRevision: Number.isSafeInteger(body.captureRevision) ? body.captureRevision : undefined,
|
|
133
|
+
captureStartedAtBrowser: Number.isFinite(body.captureStartedAtBrowser) ? body.captureStartedAtBrowser : undefined,
|
|
134
|
+
captureCompletedAtBrowser: Number.isFinite(body.captureCompletedAtBrowser)
|
|
135
|
+
? body.captureCompletedAtBrowser : undefined,
|
|
136
|
+
linkSampleStartedAtBrowser: Number.isFinite(body.linkSampleStartedAtBrowser)
|
|
137
|
+
? body.linkSampleStartedAtBrowser : undefined,
|
|
138
|
+
linkSampleCompletedAtBrowser: Number.isFinite(body.linkSampleCompletedAtBrowser)
|
|
139
|
+
? body.linkSampleCompletedAtBrowser : undefined,
|
|
140
|
+
linkSampleActionId: typeof body.linkSampleActionId === "string" ? body.linkSampleActionId : undefined,
|
|
141
|
+
linkSampleGeneration: Number.isSafeInteger(body.linkSampleGeneration) ? body.linkSampleGeneration : undefined,
|
|
142
|
+
linkSampleClockEpoch: Number.isFinite(body.linkSampleClockEpoch) ? body.linkSampleClockEpoch : undefined,
|
|
143
|
+
linkSampleRevision: Number.isSafeInteger(body.linkSampleRevision) ? body.linkSampleRevision : undefined,
|
|
126
144
|
appendService: body.appendService && typeof body.appendService === "object" ? body.appendService : undefined,
|
|
127
145
|
fragmentService: body.fragmentService && typeof body.fragmentService === "object" ? body.fragmentService : undefined,
|
|
128
146
|
bufferedAheadSec,
|
|
@@ -19,13 +19,21 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewer
|
|
|
19
19
|
// quality change the stream on screen is another session — a different one
|
|
20
20
|
// for each viewer who changed.
|
|
21
21
|
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
22
|
-
const
|
|
22
|
+
const stated = Number(req.query?.generation);
|
|
23
|
+
const generation = Number.isSafeInteger(stated) && stated >= 0 ? stated : Number.NaN;
|
|
24
|
+
const progress = await viewerRequests.getSessionProgress(sessionId, consumerId, generation);
|
|
25
|
+
if (progress?.superseded === true) {
|
|
26
|
+
return reply.code(409).send({ error: "Viewer action was superseded." });
|
|
27
|
+
}
|
|
23
28
|
if (!progress) {
|
|
24
29
|
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
const responsePreparedAtProxyMs = performance.now();
|
|
28
33
|
progress.snapshotTransport = {
|
|
34
|
+
clockEpoch: performance.timeOrigin,
|
|
35
|
+
snapshotClockEpoch: progress.playbackReadiness?.snapshotClockEpoch ?? null,
|
|
36
|
+
generation: Number.isSafeInteger(generation) ? generation : null,
|
|
29
37
|
requestReceivedAtProxyMs,
|
|
30
38
|
snapshotAtProxyMs: progress.playbackReadiness?.snapshotAtProxyMs ?? null,
|
|
31
39
|
responsePreparedAtProxyMs
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* owns the output. This carries a decision out and reports what happened.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import { performance } from "node:perf_hooks";
|
|
41
|
+
|
|
40
42
|
import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
|
|
41
43
|
import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
|
|
42
44
|
import { workDeltaBetween } from "./encoder-readings.js";
|
|
@@ -228,6 +230,7 @@ export class EncodeRun {
|
|
|
228
230
|
* @param {(args: string[]) => import("node:child_process").ChildProcess} params.spawn
|
|
229
231
|
* @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
|
|
230
232
|
* @param {() => number} [params.now]
|
|
233
|
+
* @param {() => number} [params.monotonicNow]
|
|
231
234
|
* @param {(ended: RunEnded) => void} [params.onEnded]
|
|
232
235
|
* @param {(reading: { speed: number, at: number, workSeconds: number, workingSeconds: number }) => void} [params.onSpeedMeasured] -
|
|
233
236
|
* Told each time this run has measured its processing speed, once per
|
|
@@ -291,6 +294,7 @@ export class EncodeRun {
|
|
|
291
294
|
indexOfName,
|
|
292
295
|
startSeconds = 0,
|
|
293
296
|
totalSeconds = null,
|
|
297
|
+
monotonicNow,
|
|
294
298
|
because = "no reason was given"
|
|
295
299
|
}) {
|
|
296
300
|
this.address = address;
|
|
@@ -302,10 +306,11 @@ export class EncodeRun {
|
|
|
302
306
|
this.spawnProcess = spawn;
|
|
303
307
|
this.logger = logger;
|
|
304
308
|
this.now = typeof now === "function" ? now : Date.now;
|
|
305
|
-
this.
|
|
309
|
+
this.monotonicNow = typeof monotonicNow === "function" ? monotonicNow : () => performance.now();
|
|
310
|
+
this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now, monotonicNow: this.monotonicNow });
|
|
306
311
|
/** Where this run's time went: its own work, or waiting, or stopped. */
|
|
307
312
|
this.clock = new RunClock({ now: this.now });
|
|
308
|
-
this.#pieceSample = { at: this.now(), producedSeconds: 0, workingMs: 0 };
|
|
313
|
+
this.#pieceSample = { at: this.now(), monotonicAt: this.monotonicNow(), producedSeconds: 0, workingMs: 0 };
|
|
309
314
|
this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
|
|
310
315
|
this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
|
|
311
316
|
this.onProduced = typeof onProduced === "function" ? onProduced : () => {};
|
|
@@ -463,6 +468,7 @@ export class EncodeRun {
|
|
|
463
468
|
this.#pieceSample = {
|
|
464
469
|
...this.#workSample,
|
|
465
470
|
at: this.now(),
|
|
471
|
+
monotonicAt: this.monotonicNow(),
|
|
466
472
|
workingMs: this.clock.workingMs()
|
|
467
473
|
};
|
|
468
474
|
}
|
|
@@ -600,6 +606,7 @@ export class EncodeRun {
|
|
|
600
606
|
const at = this.now();
|
|
601
607
|
this.#workSample = {
|
|
602
608
|
at,
|
|
609
|
+
monotonicAt: this.progress.updatedAtMonotonic,
|
|
603
610
|
producedSeconds: Math.max(0, this.progress.processedSeconds - this.progress.startPositionSeconds),
|
|
604
611
|
workingMs: this.clock.workingMs(at)
|
|
605
612
|
};
|
|
@@ -766,7 +773,10 @@ export class EncodeRun {
|
|
|
766
773
|
}
|
|
767
774
|
const work = workDeltaBetween(this.#pieceSample, sample);
|
|
768
775
|
if (work !== null) {
|
|
769
|
-
this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work
|
|
776
|
+
this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work,
|
|
777
|
+
captureOwner: "proxy-encoder", captureClock: "performance", captureClockEpoch: performance.timeOrigin,
|
|
778
|
+
captureStartedAtMonotonic: this.#pieceSample.monotonicAt,
|
|
779
|
+
captureCompletedAtMonotonic: sample.monotonicAt };
|
|
770
780
|
this.#pieceSample = sample;
|
|
771
781
|
this.#speedMeasurementPending = false;
|
|
772
782
|
this.onSpeedMeasured(this.speedReading);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @file Progress reported by one encoder run, placed on the source timeline.
|
|
3
5
|
*
|
|
@@ -22,15 +24,18 @@ function parseTimestamp(value) {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export class RunProgress {
|
|
25
|
-
constructor({ startSeconds = 0, totalSeconds = null, now = Date.now } = {}) {
|
|
27
|
+
constructor({ startSeconds = 0, totalSeconds = null, now = Date.now, monotonicNow = () => performance.now() } = {}) {
|
|
26
28
|
this.startPositionSeconds = Number.isFinite(startSeconds) ? Math.max(0, startSeconds) : 0;
|
|
27
29
|
this.processedSeconds = this.startPositionSeconds;
|
|
28
30
|
this.totalSeconds = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : null;
|
|
29
31
|
this.percent = null;
|
|
30
32
|
this.remainingSeconds = this.totalSeconds;
|
|
31
33
|
this.updatedAt = now();
|
|
34
|
+
this.updatedAtMonotonic = monotonicNow();
|
|
35
|
+
this.clockEpoch = performance.timeOrigin;
|
|
32
36
|
this.lastLoggedAt = 0;
|
|
33
37
|
this.now = now;
|
|
38
|
+
this.monotonicNow = monotonicNow;
|
|
34
39
|
this.#derive();
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -42,6 +47,7 @@ export class RunProgress {
|
|
|
42
47
|
this.processedSeconds = this.startPositionSeconds + Math.max(0, relative);
|
|
43
48
|
}
|
|
44
49
|
this.updatedAt = this.now();
|
|
50
|
+
this.updatedAtMonotonic = this.monotonicNow();
|
|
45
51
|
this.#derive();
|
|
46
52
|
return this.snapshot();
|
|
47
53
|
}
|
|
@@ -61,7 +67,9 @@ export class RunProgress {
|
|
|
61
67
|
totalSeconds: this.totalSeconds,
|
|
62
68
|
percent: this.percent,
|
|
63
69
|
remainingSeconds: this.remainingSeconds,
|
|
64
|
-
updatedAt: this.updatedAt
|
|
70
|
+
updatedAt: this.updatedAt,
|
|
71
|
+
updatedAtMonotonic: this.updatedAtMonotonic,
|
|
72
|
+
clockEpoch: this.clockEpoch
|
|
65
73
|
};
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -77,3 +85,4 @@ export class RunProgress {
|
|
|
77
85
|
this.remainingSeconds = Math.max(0, this.totalSeconds - this.processedSeconds);
|
|
78
86
|
}
|
|
79
87
|
}
|
|
88
|
+
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export class SpeedReadings {
|
|
17
|
-
/** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number
|
|
17
|
+
/** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
|
|
18
|
+
* captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
|
|
19
|
+
* captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number }>>} */
|
|
18
20
|
#byOutput = new WeakMap();
|
|
19
21
|
#keepMs;
|
|
20
22
|
|
|
@@ -25,7 +27,9 @@ export class SpeedReadings {
|
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* @param {object} output
|
|
28
|
-
* @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number
|
|
30
|
+
* @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
|
|
31
|
+
* captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
|
|
32
|
+
* captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number } | null} reading
|
|
29
33
|
*/
|
|
30
34
|
note(output, reading) {
|
|
31
35
|
if (!output || !(reading?.speed > 0) || !Number.isFinite(reading.at)) return;
|
|
@@ -34,6 +38,10 @@ export class SpeedReadings {
|
|
|
34
38
|
let place = readings.length;
|
|
35
39
|
while (place > 0 && readings[place - 1].at > reading.at) place -= 1;
|
|
36
40
|
const sample = { speed: reading.speed, at: reading.at };
|
|
41
|
+
for (const key of ["captureOwner", "captureClock", "captureClockEpoch", "captureStartedAtMonotonic",
|
|
42
|
+
"captureCompletedAtMonotonic"]) {
|
|
43
|
+
if (reading[key] !== undefined) sample[key] = reading[key];
|
|
44
|
+
}
|
|
37
45
|
if (Number.isFinite(reading.workSeconds) && reading.workSeconds > 0 &&
|
|
38
46
|
Number.isFinite(reading.workingSeconds) && reading.workingSeconds > 0) {
|
|
39
47
|
Object.assign(sample, { workSeconds: reading.workSeconds, workingSeconds: reading.workingSeconds });
|
|
@@ -87,6 +87,49 @@ function forecastInputIdentity(state) {
|
|
|
87
87
|
return state && JSON.stringify([state.sourceId, state.trackId, state.segmentIndex, state.phase]);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function unknownCapture(owner, actionId, generation, reason) {
|
|
91
|
+
return { owner, clock: "performance", clockEpoch: null, startedAt: null, completedAt: null,
|
|
92
|
+
actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null,
|
|
93
|
+
revision: null, validity: "unknown", unknownReason: reason };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sourceCaptureOf(stats, actionId, generation) {
|
|
97
|
+
const source = stats?.sourceCapture;
|
|
98
|
+
if (!source || source.owner !== "torrent-worker" || source.clock !== "performance" ||
|
|
99
|
+
source.validity !== "valid" || !Number.isFinite(source.clockEpoch) ||
|
|
100
|
+
source.clockEpoch !== performance.timeOrigin ||
|
|
101
|
+
!Number.isFinite(source.startedAt) || !Number.isFinite(source.completedAt) ||
|
|
102
|
+
source.completedAt < source.startedAt || !Number.isSafeInteger(source.revision)) {
|
|
103
|
+
return { ...unknownCapture("torrent-worker", actionId, generation,
|
|
104
|
+
source?.unknownReason ?? "source-capture-unavailable"),
|
|
105
|
+
...(Number.isFinite(source?.clockEpoch) ? { clockEpoch: source.clockEpoch } : {}),
|
|
106
|
+
...(Number.isSafeInteger(source?.revision) ? { revision: source.revision } : {}) };
|
|
107
|
+
}
|
|
108
|
+
return { ...source, actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
|
|
112
|
+
const runCaptures = (liveRuns ?? []).map(run => ({ owner: "proxy-encoder", clock: "performance",
|
|
113
|
+
clockEpoch: run.progress?.clockEpoch, startedAt: run.progress?.updatedAtMonotonic,
|
|
114
|
+
completedAt: run.progress?.updatedAtMonotonic, revision: run.progress?.updatedAtMonotonic,
|
|
115
|
+
validity: Number.isFinite(run.progress?.clockEpoch) && Number.isFinite(run.progress?.updatedAtMonotonic)
|
|
116
|
+
? "valid" : "unknown", unknownReason: Number.isFinite(run.progress?.clockEpoch) &&
|
|
117
|
+
Number.isFinite(run.progress?.updatedAtMonotonic) ? null : "encoder-progress-capture-unavailable" }));
|
|
118
|
+
const captures = [...(Array.isArray(sampleCaptures) ? sampleCaptures : []), ...runCaptures];
|
|
119
|
+
const epoch = captures[0]?.clockEpoch;
|
|
120
|
+
const valid = captures.length > 0 && epoch === performance.timeOrigin && captures.every(capture =>
|
|
121
|
+
capture.owner === "proxy-encoder" && capture.clock === "performance" &&
|
|
122
|
+
Number.isFinite(capture.clockEpoch) && capture.clockEpoch === epoch && Number.isFinite(capture.startedAt) &&
|
|
123
|
+
Number.isFinite(capture.completedAt) && capture.completedAt >= capture.startedAt &&
|
|
124
|
+
Number.isFinite(capture.revision));
|
|
125
|
+
if (!valid) return unknownCapture("proxy-encoder", actionId, generation, "encoder-capture-unavailable");
|
|
126
|
+
return { owner: "proxy-encoder", clock: "performance", clockEpoch: epoch,
|
|
127
|
+
startedAt: Math.min(...captures.map(capture => capture.startedAt)),
|
|
128
|
+
completedAt: Math.max(...captures.map(capture => capture.completedAt)), actionId: actionId ?? null,
|
|
129
|
+
generation: Number.isSafeInteger(generation) ? generation : null,
|
|
130
|
+
revision: captures.at(-1).revision ?? null, samples: captures, validity: "valid", unknownReason: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
90
133
|
function observeForecastInput(observeWait, sourceKey, state, event) {
|
|
91
134
|
if (!state) return;
|
|
92
135
|
observeWait?.(sourceKey, "process", {
|
|
@@ -612,7 +655,7 @@ export class ViewerRequests {
|
|
|
612
655
|
return this.#host.viewerSecondsOn(session, consumerId);
|
|
613
656
|
}
|
|
614
657
|
|
|
615
|
-
async getSessionProgress(sessionId, consumerId = "") {
|
|
658
|
+
async getSessionProgress(sessionId, consumerId = "", statedGeneration = Number.NaN) {
|
|
616
659
|
if (!isOutputName(sessionId)) {
|
|
617
660
|
return null;
|
|
618
661
|
}
|
|
@@ -628,6 +671,12 @@ export class ViewerRequests {
|
|
|
628
671
|
const session = this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs });
|
|
629
672
|
this.#host.outputs.touch(session);
|
|
630
673
|
const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
674
|
+
const requestGeneration = consumerId && viewer?.assignments
|
|
675
|
+
? this.#host.generationOfRequest(consumerId) : null;
|
|
676
|
+
const requestGenerationIsStated = Number.isSafeInteger(statedGeneration);
|
|
677
|
+
if (requestGenerationIsStated && requestGeneration !== statedGeneration) return { superseded: true };
|
|
678
|
+
const requestGenerationIsCurrent = () => !Number.isSafeInteger(requestGeneration) ||
|
|
679
|
+
this.#host.generationOfRequest(consumerId) === requestGeneration;
|
|
631
680
|
const stillOwnsProgress = () => this.#stillOwnsProgress(named, session, consumerId, viewer);
|
|
632
681
|
const audio = this.#host.renditions.servesAudioSeparately(session)
|
|
633
682
|
? this.#host.renditions.playbackAudioOutputFor(session, consumerId) : null;
|
|
@@ -648,10 +697,12 @@ export class ViewerRequests {
|
|
|
648
697
|
// Observed output bitrate (Mbit/s) from recently completed segments. The
|
|
649
698
|
// proxy uses it for the viewer-link budget and the readiness forecast.
|
|
650
699
|
const outputMbps = await this.#host.quality.observedStreamMbps(session);
|
|
700
|
+
if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
|
|
651
701
|
if (!stillOwnsProgress()) return null;
|
|
652
702
|
const progress = this.#host.encodeRuns.progressOf(session);
|
|
653
703
|
const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps,
|
|
654
704
|
viewer, stillOwnsProgress);
|
|
705
|
+
if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
|
|
655
706
|
if (!stillOwnsProgress()) return null;
|
|
656
707
|
return {
|
|
657
708
|
// The id the caller asked about, not the variant it was answered from —
|
|
@@ -758,13 +809,31 @@ export class ViewerRequests {
|
|
|
758
809
|
// the answer crossed the connection in 21 ms (r50, torrent-tv/meta#178).
|
|
759
810
|
const parts = { enteredAt: performance.now(), statsMs: 0, workerForecastMs: 0, qualityMs: 0 };
|
|
760
811
|
const viewerReading = viewer?.linkReading() ?? null;
|
|
812
|
+
const generation = consumerId && viewer?.assignments
|
|
813
|
+
? this.#host.generationOfRequest(consumerId) : null;
|
|
814
|
+
const actionIsCurrent = () => !Number.isSafeInteger(generation) ||
|
|
815
|
+
this.#host.generationOfRequest(consumerId) === generation;
|
|
816
|
+
const actionId = Number.isSafeInteger(generation)
|
|
817
|
+
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
761
818
|
const measurement = viewer
|
|
762
819
|
? this.#host.viewerPlaybackState.rateState(viewer, session, this.#host.playbackReadiness, this.#host.lookaheadSeconds)
|
|
763
820
|
: { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
|
|
821
|
+
const existingLinkCaptures = measurement.link.captureSnapshot();
|
|
822
|
+
if (viewer && (viewerReading?.linkCapture?.validity !== "valid" || existingLinkCaptures.some(capture =>
|
|
823
|
+
!capture || capture.clockEpoch !== viewerReading.linkCapture.clockEpoch))) {
|
|
824
|
+
this.#host.viewerPlaybackState.forgetLinkReadings(measurement, this.#host.playbackReadiness,
|
|
825
|
+
this.#host.lookaheadSeconds);
|
|
826
|
+
}
|
|
764
827
|
const linkAt = Number(viewerReading?.linkSampleMeasuredAt);
|
|
765
|
-
if (
|
|
766
|
-
|
|
828
|
+
if (viewerReading?.linkCapture?.validity === "valid" && Number.isFinite(viewerReading?.linkSampleMbps) &&
|
|
829
|
+
viewerReading.linkSampleMbps > 0 && Number.isFinite(linkAt)) {
|
|
830
|
+
this.#host.viewerPlaybackState.addLinkReading(measurement, linkAt, viewerReading.linkSampleMbps,
|
|
831
|
+
viewerReading.linkCapture);
|
|
767
832
|
}
|
|
833
|
+
const rawBufferCapture = viewerReading?.bufferCapture ?? unknownCapture("browser", actionId, generation,
|
|
834
|
+
"browser-buffer-capture-unavailable");
|
|
835
|
+
const bufferCaptureValid = rawBufferCapture.validity === "valid" && rawBufferCapture.actionId === session.id &&
|
|
836
|
+
rawBufferCapture.generation === generation;
|
|
768
837
|
|
|
769
838
|
const outputs = [session];
|
|
770
839
|
const requiresSeparateAudio = this.#host.renditions.servesAudioSeparately(session);
|
|
@@ -780,21 +849,25 @@ export class ViewerRequests {
|
|
|
780
849
|
let primarySourceStats = null;
|
|
781
850
|
const tracks = [];
|
|
782
851
|
for (const output of outputs) {
|
|
783
|
-
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
852
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
784
853
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
785
|
-
const
|
|
854
|
+
const trackRate = this.#trackRateReadings(output, now);
|
|
855
|
+
const trackRates = trackRate?.summary ?? null;
|
|
786
856
|
const trackName = output === session ? "video" : "audio";
|
|
787
857
|
// The page states the offset its player applied to this track, and the
|
|
788
858
|
// init it applied it to is the session's. Until both are known, the
|
|
789
859
|
// pieces stay on their own presentation timeline: there is nothing yet
|
|
790
860
|
// to place them by.
|
|
791
|
-
const playerOffset = viewerReading?.timestampOffsets?.[trackName];
|
|
861
|
+
const playerOffset = bufferCaptureValid ? viewerReading?.timestampOffsets?.[trackName] : null;
|
|
862
|
+
const reportedPosition = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
|
|
863
|
+
? viewerReading.positionSeconds : null;
|
|
792
864
|
const servedInit = Number.isFinite(playerOffset) ? this.#host.segmentStore.initOf(output.outputKey) : null;
|
|
793
865
|
const placedByPlayer = Boolean(servedInit?.length);
|
|
794
866
|
const sessionInit = placedByPlayer ? servedInit : null;
|
|
795
867
|
const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
|
|
796
|
-
const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory,
|
|
797
|
-
?
|
|
868
|
+
const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory,
|
|
869
|
+
Number.isFinite(reportedPosition) ? reportedPosition : this.#host.viewerSecondsOn(session, consumerId, now));
|
|
870
|
+
const forecastGeometry = this.#host.outputForecastGeometry.get(output);
|
|
798
871
|
const sourceIndexes = [...output.spec.sourceFileIndexes];
|
|
799
872
|
if (sourceIndexes.length === 0) {
|
|
800
873
|
sourceIndexes.push(output.file.fileIndex);
|
|
@@ -814,36 +887,62 @@ export class ViewerRequests {
|
|
|
814
887
|
} catch {
|
|
815
888
|
stats = null;
|
|
816
889
|
}
|
|
817
|
-
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
890
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
818
891
|
if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
|
|
819
892
|
parts.statsMs += performance.now() - statsAt;
|
|
820
893
|
parts.workerForecastMs += Number(stats?.forecastMs) || 0;
|
|
894
|
+
const sourceCapture = sourceCaptureOf(stats, actionId, generation);
|
|
821
895
|
if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
|
|
822
896
|
const sharedForecast = sourceForecasts.get(output.file.sourceKey);
|
|
823
|
-
const
|
|
897
|
+
const capturedForecast = sourceCaptureOf({ sourceCapture: sharedForecast?.capture }, actionId, generation);
|
|
898
|
+
const forecastCapture = sharedForecast?.global === true && !Array.isArray(sharedForecast.ranges)
|
|
899
|
+
? { ...capturedForecast, validity: "unknown", unknownReason: "source-arrival-ranges-unavailable" }
|
|
900
|
+
: capturedForecast;
|
|
901
|
+
const downloadForecast = sourceCapture.validity !== "valid" || forecastCapture.validity !== "valid"
|
|
902
|
+
? { ranges: [] } : sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
|
|
824
903
|
ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
|
|
825
904
|
start: Math.max(0, range.start - stats.fileOffset),
|
|
826
905
|
end: Math.min(stats.fileLength, range.end - stats.fileOffset)
|
|
827
906
|
})).filter(range => range.end > range.start) } : sharedForecast;
|
|
907
|
+
const sourceFactsValid = sourceCapture.validity === "valid";
|
|
908
|
+
const ownsSourceState = viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement);
|
|
909
|
+
const sourceReadingsValid = sourceFactsValid && Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0;
|
|
910
|
+
const existingSourceCaptures = ownsSourceState
|
|
911
|
+
? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
|
|
912
|
+
const sourceEpochChanged = sourceReadingsValid && existingSourceCaptures &&
|
|
913
|
+
[existingSourceCaptures.recent?.delivered, existingSourceCaptures.recent?.free,
|
|
914
|
+
existingSourceCaptures.tail?.delivered, existingSourceCaptures.tail?.free].some(capture =>
|
|
915
|
+
capture?.validity === "valid" && capture.clockEpoch !== sourceCapture.clockEpoch);
|
|
916
|
+
if (ownsSourceState && (!sourceReadingsValid || sourceEpochChanged)) {
|
|
917
|
+
this.#host.viewerPlaybackState.forgetSourceReadings(measurement, output.file.sourceKey);
|
|
918
|
+
}
|
|
919
|
+
const downloadRateReadings = ownsSourceState && sourceReadingsValid
|
|
920
|
+
? this.#host.viewerPlaybackState.sourceRateReadings(measurement, output.file.sourceKey, stats, now,
|
|
921
|
+
this.#host.playbackReadiness, this.#host.lookaheadSeconds, sourceCapture) : null;
|
|
922
|
+
const tailRateReadings = ownsSourceState && sourceReadingsValid
|
|
923
|
+
? this.#host.viewerPlaybackState.sourceTailRateReadings(measurement, output.file.sourceKey, now,
|
|
924
|
+
this.#host.playbackReadiness) : null;
|
|
925
|
+
const downloadRateSummaries = ownsSourceState && sourceReadingsValid
|
|
926
|
+
? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null;
|
|
927
|
+
const rateCapture = ownsSourceState && sourceReadingsValid
|
|
928
|
+
? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
|
|
828
929
|
sourceMeasurements.set(sourceId, {
|
|
829
930
|
id: sourceId,
|
|
830
931
|
serviceKey: output.file.sourceKey,
|
|
831
|
-
complete: stats?.fileAvailable === true,
|
|
832
|
-
residence: stats?.residence,
|
|
833
|
-
fileOffset: stats?.fileOffset,
|
|
834
|
-
fileLength: stats?.fileLength,
|
|
835
|
-
bytesPerMediaSecond: Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
|
|
932
|
+
complete: sourceFactsValid ? stats?.fileAvailable === true : null,
|
|
933
|
+
residence: sourceFactsValid ? stats?.residence : null,
|
|
934
|
+
fileOffset: sourceFactsValid ? stats?.fileOffset : null,
|
|
935
|
+
fileLength: sourceFactsValid ? stats?.fileLength : null,
|
|
936
|
+
bytesPerMediaSecond: sourceFactsValid && Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
|
|
836
937
|
Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
|
|
837
938
|
? stats.fileLength / session.file.durationSeconds : null,
|
|
838
|
-
pieceLength: stats?.pieceLength,
|
|
839
|
-
downloadRateReadings
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
downloadRateSummaries: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
|
|
846
|
-
? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null,
|
|
939
|
+
pieceLength: sourceFactsValid ? stats?.pieceLength : null,
|
|
940
|
+
downloadRateReadings,
|
|
941
|
+
tailRateReadings,
|
|
942
|
+
downloadRateSummaries,
|
|
943
|
+
capture: { ...sourceCapture, rateReadings: rateCapture ?? {
|
|
944
|
+
validity: "unknown", unknownReason: "source-rate-capture-unavailable" },
|
|
945
|
+
downloadForecast: forecastCapture },
|
|
847
946
|
downloadForecast,
|
|
848
947
|
});
|
|
849
948
|
}
|
|
@@ -852,36 +951,38 @@ export class ViewerRequests {
|
|
|
852
951
|
const observedMbps = output === session
|
|
853
952
|
? outputMbps
|
|
854
953
|
: await this.#host.quality.observedStreamMbps(output);
|
|
855
|
-
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
954
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
856
955
|
parts.qualityMs += performance.now() - qualityAt;
|
|
857
956
|
const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
|
|
957
|
+
const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
|
|
958
|
+
const encoderCaptureValid = encoderCapture.validity === "valid";
|
|
858
959
|
const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
|
|
859
960
|
const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
|
|
860
961
|
tracks.push({
|
|
861
962
|
id: output.outputKey,
|
|
862
963
|
clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
|
|
863
|
-
clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
864
|
-
viewerReading?.bufferedRanges?.media,
|
|
964
|
+
clientRanges: bufferCaptureValid ? viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
965
|
+
viewerReading?.bufferedRanges?.media : null,
|
|
865
966
|
// Browser-observed append and fragment-parse samples are retained for
|
|
866
967
|
// the full model's service schedule; they are not yet used as numeric
|
|
867
968
|
// predecessors in the partial continuity calculation.
|
|
868
|
-
appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
|
|
969
|
+
appendService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
|
|
869
970
|
? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
|
|
870
971
|
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
871
972
|
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
872
973
|
: null,
|
|
873
|
-
fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
|
|
974
|
+
fragmentService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
|
|
874
975
|
? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
|
|
875
976
|
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
876
977
|
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
877
978
|
: null,
|
|
878
979
|
sourceIds,
|
|
879
|
-
processedSeconds: trackProgress?.processedSeconds,
|
|
880
|
-
startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
|
|
980
|
+
processedSeconds: encoderCaptureValid ? trackProgress?.processedSeconds : null,
|
|
981
|
+
startupRemainingSeconds: !encoderCaptureValid ? null : processingStarted ? 0 : Math.max(0,
|
|
881
982
|
(this.#host.processingStartupSecondsOf?.(output) ?? 0) - Math.max(0, (now - startedAt) / 1000)),
|
|
882
|
-
processingRanges: liveRuns.map(({ progress }) => ({
|
|
983
|
+
processingRanges: encoderCaptureValid ? liveRuns.map(({ progress }) => ({
|
|
883
984
|
start: progress.startPositionSeconds, end: progress.processedSeconds
|
|
884
|
-
})),
|
|
985
|
+
})) : [],
|
|
885
986
|
// What a run's start costs this output, and the stretches its live
|
|
886
987
|
// runs were given: the forecast starts a run wherever production meets
|
|
887
988
|
// bytes that were not held when the run before was admitted.
|
|
@@ -894,18 +995,20 @@ export class ViewerRequests {
|
|
|
894
995
|
this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
|
|
895
996
|
durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
|
|
896
997
|
segmentCount: output.timeline?.segmentCount ?? segments.length,
|
|
897
|
-
readings: trackRates,
|
|
998
|
+
readings: encoderCaptureValid ? trackRates : [],
|
|
999
|
+
capture: encoderCapture,
|
|
898
1000
|
segments,
|
|
899
|
-
|
|
1001
|
+
tailSegments: forecastGeometry?.tailSegments ?? [],
|
|
1002
|
+
geometry: forecastGeometry?.geometry,
|
|
900
1003
|
readySegmentIndices: [...inventory.keys()],
|
|
901
1004
|
segmentSizesBytes: inventory
|
|
902
1005
|
});
|
|
903
1006
|
}
|
|
904
1007
|
|
|
905
|
-
if (!stillOwnsProgress()) return null;
|
|
1008
|
+
if (!stillOwnsProgress() || !actionIsCurrent()) return null;
|
|
906
1009
|
|
|
907
|
-
const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
|
|
908
|
-
const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
|
|
1010
|
+
const bufferedAheadSeconds = bufferCaptureValid ? Number(viewerReading?.bufferedAheadSec) : null;
|
|
1011
|
+
const positionSeconds = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
|
|
909
1012
|
? viewerReading.positionSeconds
|
|
910
1013
|
: this.#host.viewerSecondsOn(session, consumerId, now);
|
|
911
1014
|
// Both separately delivered tracks can run short independently. Use the
|
|
@@ -932,7 +1035,7 @@ export class ViewerRequests {
|
|
|
932
1035
|
positionSeconds,
|
|
933
1036
|
durationSeconds: session.file.durationSeconds,
|
|
934
1037
|
bufferedAheadSeconds,
|
|
935
|
-
bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
|
|
1038
|
+
bufferLimitSeconds: bufferCaptureValid ? viewerReading?.bufferLimitSeconds : null,
|
|
936
1039
|
reserveSeconds,
|
|
937
1040
|
lookaheadSeconds: this.#host.lookaheadSeconds,
|
|
938
1041
|
// Disk room remaining after stock reserved for other active outputs.
|
|
@@ -940,7 +1043,7 @@ export class ViewerRequests {
|
|
|
940
1043
|
requiredAudio: requiresSeparateAudio,
|
|
941
1044
|
sources: [...sourceMeasurements.values()],
|
|
942
1045
|
tracks,
|
|
943
|
-
linkReadings: measurement.link.snapshot()
|
|
1046
|
+
linkReadings: viewerReading?.linkCapture?.validity === "valid" ? measurement.link.snapshot() : null
|
|
944
1047
|
};
|
|
945
1048
|
// A subtitle track chosen inside THIS file is the last input the forecast
|
|
946
1049
|
// waits for. A subtitle FILE beside it is named by its own index and is
|
|
@@ -960,6 +1063,18 @@ export class ViewerRequests {
|
|
|
960
1063
|
const forecast = withSubtitleReadiness(predicted, subtitle,
|
|
961
1064
|
subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
|
|
962
1065
|
forecast.snapshotAtProxyMs = startedAt;
|
|
1066
|
+
forecast.snapshotClockEpoch = performance.timeOrigin;
|
|
1067
|
+
forecast.inputCaptures = {
|
|
1068
|
+
source: [...sourceMeasurements].map(([id, source]) => ({ id, capture: source.capture })),
|
|
1069
|
+
link: { ...(viewerReading?.linkCapture ?? unknownCapture("browser", actionId, generation,
|
|
1070
|
+
"link-capture-unavailable")), rateReadings: measurement.link.captureSnapshot() },
|
|
1071
|
+
encoder: tracks.map(track => ({ id: track.id, capture: track.capture })),
|
|
1072
|
+
buffer: bufferCaptureValid || rawBufferCapture.validity === "unknown" ? rawBufferCapture : {
|
|
1073
|
+
...rawBufferCapture, validity: "invalid",
|
|
1074
|
+
unknownReason: rawBufferCapture.generation !== generation ? "seek-generation-replaced" :
|
|
1075
|
+
rawBufferCapture.actionId !== session.id ? "viewer-session-replaced" : "viewer-output-cancelled"
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
963
1078
|
forecast.operations = outputs.flatMap((output, index) => {
|
|
964
1079
|
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
|
|
965
1080
|
processedSeconds: tracks[index].processedSeconds,
|
|
@@ -976,8 +1091,6 @@ export class ViewerRequests {
|
|
|
976
1091
|
outputCodec: output.spec.audio.transcode ? "aac" : null }] : [])
|
|
977
1092
|
];
|
|
978
1093
|
});
|
|
979
|
-
const generation = consumerId && viewer?.assignments
|
|
980
|
-
? this.#host.generationOfRequest(consumerId) : null;
|
|
981
1094
|
const earlyWaitId = Number.isSafeInteger(generation)
|
|
982
1095
|
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
983
1096
|
if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
|
|
@@ -1136,7 +1249,20 @@ export class ViewerRequests {
|
|
|
1136
1249
|
#trackRateReadings(output, now) {
|
|
1137
1250
|
const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
|
|
1138
1251
|
const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
|
|
1139
|
-
if (summary) return summary
|
|
1252
|
+
if (summary) return { summary, captures: readings.map(reading => ({ owner: reading.captureOwner,
|
|
1253
|
+
clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
|
|
1254
|
+
startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
|
|
1255
|
+
revision: reading.at,
|
|
1256
|
+
validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
1257
|
+
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
1258
|
+
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
1259
|
+
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
1260
|
+
? "valid" : "unknown",
|
|
1261
|
+
unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
1262
|
+
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
1263
|
+
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
1264
|
+
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
1265
|
+
? null : "encoder-sample-capture-unavailable" })) };
|
|
1140
1266
|
// ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
|
|
1141
1267
|
// encoder, and it is not kept among the readings. Added on every poll
|
|
1142
1268
|
// between runs, it put the machine's share of that moment — 1 % at a
|
|
@@ -1146,7 +1272,9 @@ export class ViewerRequests {
|
|
|
1146
1272
|
if (Number.isFinite(projected) && projected > 0) {
|
|
1147
1273
|
const seed = new this.#host.playbackReadiness.RateTrend();
|
|
1148
1274
|
seed.add(now, projected);
|
|
1149
|
-
return seed.snapshot()
|
|
1275
|
+
return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
|
|
1276
|
+
clockEpoch: performance.timeOrigin, startedAt: now, completedAt: now, revision: now,
|
|
1277
|
+
validity: "valid", unknownReason: null }] };
|
|
1150
1278
|
}
|
|
1151
1279
|
// Progress over wall time includes source waits, which the forecast prices
|
|
1152
1280
|
// separately. Keep the rate unknown until a measured or calibrated
|