@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.28",
3
+ "version": "2.101.30",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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.]+)$/.exec(message);
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, ...earlyLink }) ?? null;
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, stats, ...earlyLink }) ?? null
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 three of those figures more
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
- * Keep a browser-measured wait outcome with the source and link readings
268
- * visible before the container was inspected. These readings are useful
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
- seconds,
279
- extension: typeof features.extension === "string" ? features.extension : "",
280
- fileLength: positive(features.fileLength),
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 this host's first-frame wait from the closest measured start with
296
- * the same media suffix. Distance uses squared log-ratios for numeric
297
- * inputs and exact-state distance for categorical inputs; it does not mix
298
- * bytes, bit rates and peers in their raw units. Missing data contributes
299
- * distance only when the other observation measured that input.
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 readings = this.#entries.firstFrame[this.#firstFrameKey()] ?? [];
307
- const candidates = readings.filter(reading => reading.extension === (features.extension ?? ""));
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 scales = new Map(dimensions.map(key => [key, median(candidates
312
- .map(reading => key === "fileAvailable" ? null : nonNegative(reading[key]))
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
- let best = null;
314
+ const distances = [];
315
315
  for (const reading of candidates) {
316
- let distance = 0;
317
- let used = 0;
318
- let shared = 0;
319
- for (const key of dimensions) {
320
- const current = key === "fileAvailable"
321
- ? (typeof features[key] === "boolean" ? features[key] : null)
322
- : nonNegative(features[key]);
323
- const previous = key === "fileAvailable"
324
- ? (typeof reading[key] === "boolean" ? reading[key] : null)
325
- : nonNegative(reading[key]);
326
- if (current === null && previous === null) continue;
327
- if (current === null || previous === null) {
328
- distance += 1;
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 += 1;
333
- const scale = scales.get(key);
334
- const difference = typeof current === "boolean" && typeof previous === "boolean"
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
- return best?.reading.seconds ?? null;
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: Number.isFinite(linkMbps) && linkMbps > 0
120
- ? linkMbps : this.#host.viewers.get(consumerId)?.linkReading()?.linkMbps,
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 seconds = this.#host.firstFrameSecondsOf?.(current.features) ?? null;
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: current.revision, remainingSeconds: seconds, ageMs: 0 };
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
- const changed = this.#recordEarlyWaitSnapshot(context, features);
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 seconds = this.#host.firstFrameSecondsOf?.(features) ?? null;
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: Number.isFinite(linkMbps) && linkMbps > 0
163
- ? linkMbps : this.#host.viewers.get(consumerId)?.linkReading()?.linkMbps,
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 revision = current?.waitId === waitId ? current.revision + 1 : 0;
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 seconds = this.#host.firstFrameSecondsOf?.(features) ?? null;
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 }} params
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
- for (const snapshot of early.snapshots) {
197
- this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: snapshot });
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, sourceObserved = false }) {
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
- Object.assign(context, { consumerId, generation, fileIndex, waitId, features, updatedAt: Date.now(), revision,
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
- for (const output of outputs.values()) {
236
- if (output.file?.sourceKey !== sourceKey || output.file?.fileIndex !== fileIndex || !output.outputKey) continue;
237
- if (seen.has(output.outputKey)) continue;
238
- seen.add(output.outputKey);
239
- for (const size of segmentStore.sizesOf(output.outputKey).values()) {
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.