@torrent-tv/proxy 2.107.52 → 2.107.54

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.
@@ -107,6 +107,9 @@ import { describeProcessor, processorBetween, sampleProcessor } from "../storage
107
107
  import { ProcessorBudget } from "../encode/ProcessorBudget.js";
108
108
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
109
109
  import { secondsUntilClosed } from "../encode/piece-close-time.js";
110
+ import { ViewerPlaybackState } from "../viewer/ViewerPlaybackState.js";
111
+ import { OutputForecastGeometry } from "../viewer/OutputForecastGeometry.js";
112
+ import { ForecastDiagnostics } from "./ForecastDiagnostics.js";
110
113
 
111
114
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
112
115
  // and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
@@ -249,6 +252,14 @@ export function wireOutputs({
249
252
  // output key owns its record, and the graph releases it at the last session.
250
253
  const waits = new WaitLedger();
251
254
  const parts = {};
255
+ parts.viewerPlaybackState = new ViewerPlaybackState();
256
+ parts.outputForecastGeometry = new OutputForecastGeometry({
257
+ lookaheadSeconds: LOOKAHEAD_PAUSE_SECONDS,
258
+ finishedMediaRangesOf: (output, index) => parts.encodeRuns.finishedMediaRangesOf(output, index),
259
+ sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
260
+ endsWithinPlayerSeekTolerance
261
+ });
262
+ parts.forecastDiagnostics = new ForecastDiagnostics({ logger });
252
263
  const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
253
264
  sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
254
265
  }) ?? []).find((track) => track.fileIndex === output.spec.audio?.fileIndex &&
@@ -573,6 +584,9 @@ export function wireOutputs({
573
584
  planEncodersSoon: () => parts.encodeRuns.planEncodersSoon(),
574
585
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
575
586
  clearWaits: () => waits.clear(),
587
+ forgetForecastOutput: (output) => parts.viewerRequests.outputDisposed(output),
588
+ viewerDeparted: (viewer) => parts.viewerRequests.viewerDeparted(viewer),
589
+ outputViewerDeparted: (viewer, output) => parts.viewerRequests.outputViewerDeparted(viewer, output),
576
590
  segmentFormatOfKey,
577
591
  // Where the viewers of one output stand, in its segments: what the store
578
592
  // the output retention policy reads to give disk back in the right order.
@@ -632,6 +646,9 @@ export function wireOutputs({
632
646
  // What a viewer asks for: an output of a file, a position, a report of progress.
633
647
  parts.viewerRequests = new ViewerRequests({
634
648
  observeWait,
649
+ viewerPlaybackState: parts.viewerPlaybackState,
650
+ outputForecastGeometry: parts.outputForecastGeometry,
651
+ noteForecast: (...args) => parts.forecastDiagnostics.note(...args),
635
652
  // Whether this machine has a place for one more viewer on this output —
636
653
  // asked in the same synchronous stretch as they are put on it.
637
654
  admitsWatching: (output) => parts.admission.admitsWatching(output?.outputKey ?? ""),
@@ -42,6 +42,9 @@ import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../../storage/memory
42
42
  import { profileThisThread } from "../../storage/cpu-profile.js";
43
43
  import { forwardLogsTo, logger } from "../../../utils/logger.js";
44
44
 
45
+ const WORKER_CLOCK_EPOCH = performance.timeOrigin;
46
+ let sourceSnapshotRevision = 0;
47
+
45
48
  /**
46
49
  * Everything this thread logs goes to the main thread, which owns the file.
47
50
  *
@@ -492,6 +495,8 @@ async function runCommand(command, params, id) {
492
495
 
493
496
  case Command.FILE_STATS: {
494
497
  const statsStartedAt = performance.now();
498
+ const sourceCaptureStartedAt = statsStartedAt;
499
+ const sourceRevision = ++sourceSnapshotRevision;
495
500
  emitWaitTrace(params.sourceKey, "W10", "begin", { startedAt: statsStartedAt,
496
501
  fileIndex: params.fileIndex, forecast: params.forecast === true });
497
502
  const finishStatsTrace = (outcome, fields = {}) => {
@@ -504,7 +509,14 @@ async function runCommand(command, params, id) {
504
509
  if (!torrent) {
505
510
  const whole = wholeSources.fileOf(params.sourceKey, params.fileIndex);
506
511
  finishStatsTrace(whole ? "whole-file" : "pending");
507
- return whole ? wholeFileStats(whole.length) : { pending: true };
512
+ return {
513
+ ...(whole ? wholeFileStats(whole.length) : { pending: true }),
514
+ sourceCapture: {
515
+ owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
516
+ startedAt: sourceCaptureStartedAt, completedAt: performance.now(), revision: sourceRevision,
517
+ validity: whole ? "valid" : "unknown", unknownReason: whole ? null : "source-not-ready"
518
+ }
519
+ };
508
520
  }
509
521
  // What this file's own interruptions demand, measured by the reader in
510
522
  // this thread. It travels with the stats because the caller asking for
@@ -515,7 +527,8 @@ async function runCommand(command, params, id) {
515
527
  const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
516
528
  const forecastAt = performance.now();
517
529
  const forecast = file && params.forecast === true ? downloadForecastOf(torrent) : null;
518
- const forecastMs = params.forecast === true ? performance.now() - forecastAt : 0;
530
+ const forecastCompletedAt = performance.now();
531
+ const forecastMs = params.forecast === true ? forecastCompletedAt - forecastAt : 0;
519
532
  const stats = pool.getFileStats(torrent, params.fileIndex);
520
533
  const ranges = [];
521
534
  if (forecast) for (const [piece, availableAt] of forecast.arrivals) {
@@ -526,8 +539,19 @@ async function runCommand(command, params, id) {
526
539
  finishStatsTrace("ok", { forecast: params.forecast === true });
527
540
  return {
528
541
  ...stats,
542
+ sourceCapture: {
543
+ owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
544
+ startedAt: sourceCaptureStartedAt, completedAt: performance.now(), revision: sourceRevision,
545
+ validity: stats ? "valid" : "unknown", unknownReason: stats ? null : "source-stats-unavailable"
546
+ },
529
547
  ...(params.forecast === true ? { downloadForecast: {
530
- measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges
548
+ measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges,
549
+ capture: {
550
+ owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
551
+ startedAt: forecastAt, completedAt: forecastCompletedAt, revision: sourceRevision,
552
+ validity: forecast ? "valid" : "unknown",
553
+ unknownReason: forecast ? null : "source-arrival-forecast-unavailable"
554
+ }
531
555
  } } : {}),
532
556
  supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4),
533
557
  // How long this answer waited for the arrival forecast, which the
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Cached piece geometry for playback forecasts. It is keyed by a live output
3
+ * object and explicitly forgotten when that output is disposed.
4
+ */
5
+ export class OutputForecastGeometry {
6
+ #byOutput = new Map();
7
+ #host;
8
+
9
+ constructor(host) {
10
+ this.#host = host;
11
+ }
12
+
13
+ get(output) {
14
+ return this.#byOutput.get(output) ?? null;
15
+ }
16
+
17
+ forget(output) {
18
+ return this.#byOutput.delete(output);
19
+ }
20
+
21
+ size() {
22
+ return this.#byOutput.size;
23
+ }
24
+
25
+ segmentsOf(output, sessionInit, inventory, positionSeconds) {
26
+ const timeline = output.timeline;
27
+ const count = timeline?.segmentCount ?? 0;
28
+ let forecast = this.#byOutput.get(output);
29
+ if (!forecast) {
30
+ forecast = { pieces: [], geometry: {} };
31
+ this.#byOutput.set(output, forecast);
32
+ }
33
+ const kept = forecast.pieces;
34
+ const segments = [];
35
+ const lookahead = Number(this.#host.lookaheadSeconds);
36
+ const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
37
+ const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
38
+ let low = 0, high = count;
39
+ while (low < high) {
40
+ const middle = (low + high) >>> 1;
41
+ if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
42
+ else high = middle;
43
+ }
44
+ const first = Math.max(0, low - 1);
45
+ low = first;
46
+ high = count;
47
+ while (low < high) {
48
+ const middle = (low + high) >>> 1;
49
+ if (timeline.publishedStartOf(middle) < end) low = middle + 1;
50
+ else high = middle;
51
+ }
52
+ const last = low;
53
+ const indexes = new Set([0]);
54
+ for (let index = first; index < last; index++) indexes.add(index);
55
+ for (const index of indexes) {
56
+ const endSeconds = timeline.publishedStartOf(index + 1);
57
+ const ranges = inventory.has(index) ? this.#host.finishedMediaRangesOf(output, index) : undefined;
58
+ const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
59
+ output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
60
+ if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
61
+ Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) &&
62
+ !this.#host.endsWithinPlayerSeekTolerance(endSeconds, positionSeconds) &&
63
+ endSeconds < positionSeconds) {
64
+ continue;
65
+ }
66
+ const startSeconds = timeline.publishedStartOf(index);
67
+ const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
68
+ const previous = kept[index];
69
+ if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
70
+ previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
71
+ segments.push(previous);
72
+ continue;
73
+ }
74
+ const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
75
+ kept[index] = segment;
76
+ segments.push(segment);
77
+ }
78
+ if (kept.length > count) kept.length = count;
79
+ return segments;
80
+ }
81
+ }
82
+
83
+ function sameInputs(left, right) {
84
+ if (left === right) return true;
85
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
86
+ return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
87
+ }
@@ -53,6 +53,34 @@ function fragmentEventAges(value) {
53
53
  Number.isFinite(value?.[name]) && value[name] >= 0 ? value[name] : null]));
54
54
  }
55
55
 
56
+ function browserCaptureOf(report, sessionId) {
57
+ const clockEpoch = report.captureClockEpoch;
58
+ const startedAt = report.captureStartedAtBrowser;
59
+ const completedAt = report.captureCompletedAtBrowser;
60
+ const valid = typeof report.captureEpoch === "string" && report.captureEpoch.length > 0 &&
61
+ Number.isFinite(report.captureEpochStartedAtBrowser) && Number.isFinite(report.captureRevision) &&
62
+ Number.isSafeInteger(report.captureRevision) && report.captureRevision > 0 &&
63
+ Number.isFinite(clockEpoch) && Number.isFinite(startedAt) && Number.isFinite(completedAt) &&
64
+ report.captureEpochStartedAtBrowser >= clockEpoch && startedAt >= report.captureEpochStartedAtBrowser &&
65
+ completedAt >= startedAt &&
66
+ Number.isSafeInteger(report.generation) && report.generation >= 0;
67
+ return {
68
+ owner: "browser",
69
+ clock: "performance",
70
+ clockEpoch: Number.isFinite(clockEpoch) ? clockEpoch : null,
71
+ startedAt: Number.isFinite(startedAt) ? startedAt : null,
72
+ completedAt: Number.isFinite(completedAt) ? completedAt : null,
73
+ actionId: typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null,
74
+ generation: Number.isSafeInteger(report.generation) ? report.generation : null,
75
+ revision: Number.isSafeInteger(report.captureRevision) ? report.captureRevision : null,
76
+ epoch: typeof report.captureEpoch === "string" ? report.captureEpoch : null,
77
+ epochStartedAt: Number.isFinite(report.captureEpochStartedAtBrowser)
78
+ ? report.captureEpochStartedAtBrowser : null,
79
+ validity: valid ? "valid" : "unknown",
80
+ unknownReason: valid ? null : "missing-or-invalid-browser-capture"
81
+ };
82
+ }
83
+
56
84
  function appendServiceSnapshot(value) {
57
85
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
58
86
  const snapshot = {};
@@ -522,6 +550,18 @@ export class Viewer {
522
550
  appendEpoch = null,
523
551
  appendEpochStartedAt = null,
524
552
  appendSnapshotAt = null,
553
+ captureClockEpoch = null,
554
+ captureEpoch = null,
555
+ captureEpochStartedAtBrowser = null,
556
+ captureRevision = null,
557
+ captureStartedAtBrowser = null,
558
+ captureCompletedAtBrowser = null,
559
+ linkSampleStartedAtBrowser = null,
560
+ linkSampleCompletedAtBrowser = null,
561
+ linkSampleActionId = null,
562
+ linkSampleGeneration = null,
563
+ linkSampleClockEpoch = null,
564
+ linkSampleRevision = null,
525
565
  appendService: appendServiceReading = null,
526
566
  fragmentService: fragmentServiceReading = null,
527
567
  bufferedAheadSec,
@@ -564,6 +604,18 @@ export class Viewer {
564
604
  const sampleMbps = Number(linkSampleMbps);
565
605
  const sampleAt = Number(linkSampleAt);
566
606
  const previous = this.netReport;
607
+ const browserCapture = browserCaptureOf({ captureClockEpoch, captureEpoch, captureEpochStartedAtBrowser,
608
+ captureRevision, captureStartedAtBrowser, captureCompletedAtBrowser, generation }, appendSessionId);
609
+ const previousCapture = previous?.bufferCapture ?? null;
610
+ const captureOrderKnown = browserCapture.clockEpoch !== null && browserCapture.epoch !== null &&
611
+ browserCapture.epochStartedAt !== null && browserCapture.revision !== null && browserCapture.revision > 0;
612
+ const sameCaptureClock = captureOrderKnown && browserCapture.clockEpoch === previousCapture?.clockEpoch;
613
+ const sameCaptureEpoch = sameCaptureClock && browserCapture.epoch === previousCapture?.epoch;
614
+ const newerCapture = captureOrderKnown && (!previousCapture || !previousCapture.clockEpoch ||
615
+ !sameCaptureClock && browserCapture.epochStartedAt > (previousCapture.epochStartedAt ?? 0) ||
616
+ sameCaptureClock && (sameCaptureEpoch && browserCapture.revision > previousCapture.revision ||
617
+ !sameCaptureEpoch && browserCapture.epochStartedAt > (previousCapture.epochStartedAt ?? 0)));
618
+ const staleCapture = captureOrderKnown && previousCapture && !newerCapture;
567
619
  const incomingAppendService = typeof appendEpoch === "string" && appendEpoch.length > 0
568
620
  ? appendServiceSnapshot(appendServiceReading) : null;
569
621
  const incomingFragmentService = typeof appendEpoch === "string" && appendEpoch.length > 0
@@ -578,37 +630,85 @@ export class Viewer {
578
630
  (!previous?.appendService || appendServiceIsMonotonic(incomingAppendService, previous.appendService)) &&
579
631
  (!previous?.fragmentService || fragmentServiceIsMonotonic(incomingFragmentService, previous.fragmentService))
580
632
  : appendEpochStartedAt > (previous?.appendEpochStartedAt ?? 0));
633
+ const preserveBrowserService = sameAppendEpoch && incomingAppendService && incomingFragmentService;
581
634
  const hasPlaybackSample = Number.isFinite(sampleMbps) && sampleMbps > 0 &&
582
- Number.isFinite(sampleAt) && sampleAt > 0;
583
- const newerPlaybackSample = hasPlaybackSample && sampleAt > (this.netReport?.linkSampleAt ?? 0);
635
+ Number.isFinite(sampleAt) && sampleAt > 0 && Number.isFinite(captureClockEpoch) &&
636
+ Number.isFinite(linkSampleClockEpoch) && linkSampleClockEpoch === captureClockEpoch &&
637
+ Number.isSafeInteger(linkSampleRevision) && linkSampleRevision > 0 &&
638
+ Number.isFinite(linkSampleStartedAtBrowser) && Number.isFinite(linkSampleCompletedAtBrowser) &&
639
+ linkSampleStartedAtBrowser >= captureClockEpoch && linkSampleCompletedAtBrowser === sampleAt &&
640
+ linkSampleStartedAtBrowser <= linkSampleCompletedAtBrowser &&
641
+ Number.isFinite(captureCompletedAtBrowser) && sampleAt <= captureCompletedAtBrowser &&
642
+ Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0;
643
+ const oldLinkEpoch = this.netReport?.linkCapture?.epoch !== String(linkSampleClockEpoch) &&
644
+ Number.isFinite(this.netReport?.linkCapture?.epochStartedAt) &&
645
+ Number.isFinite(linkSampleClockEpoch) &&
646
+ linkSampleClockEpoch <= this.netReport.linkCapture.epochStartedAt;
647
+ const newerPlaybackSample = hasPlaybackSample && !oldLinkEpoch &&
648
+ (linkSampleClockEpoch !== this.netReport?.linkCapture?.clockEpoch ||
649
+ linkSampleRevision > (this.netReport?.linkCapture?.revision ?? 0));
650
+ const linkClockEpochChanged = Number.isFinite(captureClockEpoch) &&
651
+ Number.isFinite(this.netReport?.linkCapture?.clockEpoch) &&
652
+ captureClockEpoch !== this.netReport.linkCapture.clockEpoch;
653
+ const newerInvalidPlaybackSample = !stale && newerCapture && !hasPlaybackSample &&
654
+ (Number.isSafeInteger(linkSampleRevision) || linkSampleAt !== null && linkSampleAt !== undefined ||
655
+ linkClockEpochChanged);
656
+ const preserveCapture = stale || staleCapture;
657
+ const linkCapture = newerPlaybackSample ? {
658
+ owner: "browser", clock: "performance", clockEpoch: linkSampleClockEpoch,
659
+ startedAt: linkSampleStartedAtBrowser, completedAt: linkSampleCompletedAtBrowser,
660
+ actionId: typeof linkSampleActionId === "string" ? linkSampleActionId : null,
661
+ generation: Number.isSafeInteger(linkSampleGeneration) ? linkSampleGeneration : null,
662
+ epoch: String(linkSampleClockEpoch), epochStartedAt: linkSampleClockEpoch,
663
+ revision: linkSampleRevision, validity: "valid", unknownReason: null
664
+ } : newerInvalidPlaybackSample ? {
665
+ owner: "browser", clock: "performance", clockEpoch: Number.isFinite(captureClockEpoch) ? captureClockEpoch : null,
666
+ startedAt: null, completedAt: null, actionId: appendSessionId,
667
+ generation: Number.isSafeInteger(generation) ? generation : null,
668
+ epoch: Number.isFinite(captureClockEpoch) ? String(captureClockEpoch) : null,
669
+ epochStartedAt: captureClockEpoch,
670
+ revision: linkSampleRevision, validity: "invalid", unknownReason: "invalid-link-sample-capture"
671
+ } : this.netReport?.linkCapture ?? {
672
+ owner: "browser", clock: "performance", clockEpoch: null, startedAt: null, completedAt: null,
673
+ actionId: appendSessionId, generation: Number.isSafeInteger(generation) ? generation : null,
674
+ epoch: null, epochStartedAt: null, revision: null, validity: "unknown",
675
+ unknownReason: "missing-link-sample-capture"
676
+ };
677
+ const useCapture = !stale && !staleCapture && newerCapture && browserCapture.validity === "valid";
584
678
  this.netReport = {
585
679
  linkMbps: lastLinkMbps,
586
680
  linkMeasuredAt,
587
- linkSampleAt: newerPlaybackSample ? sampleAt : this.netReport?.linkSampleAt ?? null,
588
- linkSampleMbps: newerPlaybackSample ? sampleMbps : this.netReport?.linkSampleMbps ?? null,
589
- linkSampleMeasuredAt: newerPlaybackSample
590
- ? Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0
591
- ? now - linkSampleAgeMs
592
- : now
593
- : this.netReport?.linkSampleMeasuredAt ?? null,
681
+ linkSampleAt: newerPlaybackSample ? sampleAt : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleAt ?? null,
682
+ linkSampleMbps: newerPlaybackSample ? sampleMbps : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleMbps ?? null,
683
+ linkSampleMeasuredAt: newerPlaybackSample ? now : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleMeasuredAt ?? null,
684
+ linkCapture,
685
+ bufferCapture: preserveCapture ? previousCapture ?? browserCapture : useCapture ? browserCapture : {
686
+ ...browserCapture,
687
+ validity: "unknown",
688
+ unknownReason: browserCapture.unknownReason ?? "missing-or-invalid-browser-capture"
689
+ },
594
690
  appendSessionId: useBrowserService ? appendSessionId : previous?.appendSessionId ?? null,
595
- appendEpoch: useBrowserService ? appendEpoch : previous?.appendEpoch ?? null,
691
+ appendEpoch: useBrowserService ? appendEpoch : preserveBrowserService ? previous?.appendEpoch ?? null
692
+ : useCapture ? null : previous?.appendEpoch ?? null,
596
693
  appendEpochStartedAt: useBrowserService ? appendEpochStartedAt : previous?.appendEpochStartedAt ?? null,
597
694
  appendSnapshotAt: useBrowserService ? appendSnapshotAt : previous?.appendSnapshotAt ?? null,
598
- appendService: useBrowserService ? incomingAppendService : previous?.appendService ?? null,
599
- fragmentService: useBrowserService ? incomingFragmentService : previous?.fragmentService ?? null,
600
- appendServiceReceivedAt: useBrowserService ? now : previous?.appendServiceReceivedAt ?? null,
601
- bufferedAheadSec: held,
695
+ appendService: useBrowserService ? incomingAppendService : preserveBrowserService ? previous?.appendService ?? null
696
+ : useCapture ? null : previous?.appendService ?? null,
697
+ fragmentService: useBrowserService ? incomingFragmentService : preserveBrowserService ? previous?.fragmentService ?? null
698
+ : useCapture ? null : previous?.fragmentService ?? null,
699
+ appendServiceReceivedAt: useBrowserService ? now : preserveBrowserService ? previous?.appendServiceReceivedAt ?? null
700
+ : useCapture ? null : previous?.appendServiceReceivedAt ?? null,
701
+ bufferedAheadSec: preserveCapture ? previous?.bufferedAheadSec ?? 0 : useCapture ? held : 0,
602
702
  timestampOffsets: Object.fromEntries(["video", "audio"].filter((key) =>
603
703
  Number.isFinite(timestampOffsets?.[key])).map((key) => [key, timestampOffsets[key]])),
604
- bufferedRanges: bufferedRanges && typeof bufferedRanges === "object" ?
704
+ bufferedRanges: preserveCapture ? previous?.bufferedRanges ?? null : useCapture && bufferedRanges && typeof bufferedRanges === "object" ?
605
705
  Object.fromEntries(["video", "audio", "media"].filter((key) => Array.isArray(bufferedRanges[key]))
606
706
  .map((key) => [key, bufferedRanges[key].slice(0, 256).filter((range) =>
607
707
  Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.start >= 0 && range.end > range.start)
608
708
  .map(({ start, end }) => ({ start, end }))])) : null,
609
- bufferLimitSeconds: Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
709
+ bufferLimitSeconds: preserveCapture ? previous?.bufferLimitSeconds ?? null : useCapture && Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
610
710
  ? bufferLimitSeconds
611
- : this.netReport?.bufferLimitSeconds ?? null,
711
+ : useCapture ? null : this.netReport?.bufferLimitSeconds ?? null,
612
712
  positionSeconds:
613
713
  Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
614
714
  at: now
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Cross-request measurements owned by one live viewing. A viewer is the key,
3
+ * rather than its reusable wire id, so a later viewing with the same id starts
4
+ * with fresh measurements.
5
+ */
6
+ export class ViewerPlaybackState {
7
+ #byViewer = new Map();
8
+ #waitOrder = new Map();
9
+
10
+ #stateFor(viewer, create = false) {
11
+ if (!viewer || typeof viewer !== "object") return null;
12
+ let state = this.#byViewer.get(viewer);
13
+ if (!state && create) {
14
+ state = { ratesByOutput: new Map(), waits: new Map() };
15
+ this.#byViewer.set(viewer, state);
16
+ }
17
+ return state ?? null;
18
+ }
19
+
20
+ rateState(viewer, output, playbackReadiness, lookaheadSeconds) {
21
+ const owner = this.#stateFor(viewer, true);
22
+ let state = owner.ratesByOutput.get(output);
23
+ if (!state) {
24
+ state = { link: new playbackReadiness.RateTrend(lookaheadSeconds), sources: new Map() };
25
+ owner.ratesByOutput.set(output, state);
26
+ }
27
+ return state;
28
+ }
29
+
30
+ owns(viewer, output, state) {
31
+ return this.#stateFor(viewer)?.ratesByOutput.get(output) === state;
32
+ }
33
+
34
+ forgetOutput(output) {
35
+ for (const [viewer, owner] of this.#byViewer) {
36
+ owner.ratesByOutput.delete(output);
37
+ this.#deleteIfEmpty(viewer, owner);
38
+ }
39
+ }
40
+
41
+ forgetViewerOutput(viewer, output) {
42
+ const owner = this.#stateFor(viewer);
43
+ if (!owner) return false;
44
+ const removed = owner.ratesByOutput.delete(output);
45
+ this.#deleteIfEmpty(viewer, owner);
46
+ return removed;
47
+ }
48
+
49
+ addLinkReading(state, at, mbps, capture = null) {
50
+ state.link.add(at, mbps * 1_000_000, capture);
51
+ }
52
+
53
+ forgetLinkReadings(state, playbackReadiness, lookaheadSeconds) {
54
+ if (state) state.link = new playbackReadiness.RateTrend(lookaheadSeconds);
55
+ }
56
+
57
+ sourceRateReadings(state, id, stats, now, playbackReadiness, lookaheadSeconds, capture = null) {
58
+ let source = state.sources.get(id);
59
+ if (!source) {
60
+ const { RateTrend, SessionRate } = playbackReadiness;
61
+ source = { delivered: new RateTrend(lookaheadSeconds), free: new RateTrend(lookaheadSeconds),
62
+ deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
63
+ state.sources.set(id, source);
64
+ }
65
+ const readingCapture = capture ?? stats?.sourceCapture ?? null;
66
+ if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
67
+ source.delivered.add(now, stats.downloadSpeed, readingCapture);
68
+ source.deliveredAll.add(now, stats.downloadSpeed, readingCapture);
69
+ }
70
+ const limit = stats?.selectionLimit;
71
+ if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
72
+ const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
73
+ source.free.add(now, free, readingCapture);
74
+ source.freeAll.add(now, free, readingCapture);
75
+ }
76
+ if (limit) source.limit = limit;
77
+ return sourceService(source.free.snapshot(), source.delivered.snapshot(),
78
+ summary => playbackReadiness.forecastRate(summary, now, 0, "time"));
79
+ }
80
+
81
+ sourceTailRateReadings(state, id, now, playbackReadiness) {
82
+ const source = state.sources.get(id);
83
+ if (!source) return null;
84
+ return sourceService(source.freeAll.snapshot(), source.deliveredAll.snapshot(),
85
+ summary => playbackReadiness.forecastRate(summary, now, 0, "time"));
86
+ }
87
+
88
+ sourceRateSummaries(state, id) {
89
+ const source = state.sources.get(id);
90
+ return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
91
+ }
92
+
93
+ sourceCaptureSnapshot(state, id) {
94
+ const source = state.sources.get(id);
95
+ if (!source) return null;
96
+ return {
97
+ recent: { delivered: source.delivered.captureSnapshot(), free: source.free.captureSnapshot() },
98
+ tail: { delivered: source.deliveredAll.captureSnapshot(), free: source.freeAll.captureSnapshot() }
99
+ };
100
+ }
101
+
102
+ forgetSourceReadings(state, id) {
103
+ state?.sources.delete(id);
104
+ }
105
+
106
+ getWait(viewer, waitId) {
107
+ return this.#stateFor(viewer)?.waits.get(waitId) ?? null;
108
+ }
109
+
110
+ hasWait(viewer, waitId) {
111
+ return this.#stateFor(viewer)?.waits.has(waitId) ?? false;
112
+ }
113
+
114
+ recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features }) {
115
+ const owner = this.#stateFor(viewer, true);
116
+ const current = owner.waits.get(waitId);
117
+ const context = current?.waitId === waitId ? current : {
118
+ consumerId, generation, sourceKey, fileIndex, waitId, snapshots: [], observedWaits: new Set()
119
+ };
120
+ const measuredAt = performance.now();
121
+ const ageElapsedMs = Number.isFinite(context.featuresMeasuredAt)
122
+ ? Math.max(0, measuredAt - context.featuresMeasuredAt) : 0;
123
+ const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
124
+ Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
125
+ featuresMeasuredAt: measuredAt, updatedAt: Date.now() });
126
+ const previous = context.snapshots.at(-1);
127
+ if (!previous || JSON.stringify(previous) !== JSON.stringify(mergedFeatures)) {
128
+ const snapshot = { ...mergedFeatures };
129
+ if (context.snapshots.length < 2) context.snapshots.push(snapshot);
130
+ else context.snapshots[1] = snapshot;
131
+ }
132
+ owner.waits.set(waitId, context);
133
+ if (!this.#waitOrder.has(context)) this.#waitOrder.set(context, { viewer, waitId });
134
+ return context;
135
+ }
136
+
137
+ noteFirstFrame({ viewer, generation, waitId, wait, elapsedMs, stages, record }) {
138
+ const observation = this.getWait(viewer, waitId);
139
+ if (!observation || observation.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 ||
140
+ observation.observedWaits.has(wait) || !Number.isFinite(elapsedMs) || elapsedMs < 0) return false;
141
+ const initialFeatures = observation.snapshots[0];
142
+ if (initialFeatures) record({ seconds: elapsedMs / 1000, features: initialFeatures,
143
+ featureSnapshots: observation.snapshots.slice(1), stages });
144
+ observation.observedWaits.add(wait);
145
+ observation.updatedAt = Date.now();
146
+ return true;
147
+ }
148
+
149
+ deleteWait(viewer, waitId) {
150
+ const owner = this.#stateFor(viewer);
151
+ if (!owner) return false;
152
+ const context = owner.waits.get(waitId);
153
+ const removed = owner.waits.delete(waitId);
154
+ if (context) this.#waitOrder.delete(context);
155
+ this.#deleteIfEmpty(viewer, owner);
156
+ return removed;
157
+ }
158
+
159
+ pruneWaits(now = Date.now(), maxObservations = 1000) {
160
+ const cutoff = now - 30 * 60_000;
161
+ for (const [viewer, owner] of this.#byViewer) {
162
+ for (const [waitId, value] of owner.waits) {
163
+ if (value.updatedAt < cutoff) {
164
+ owner.waits.delete(waitId);
165
+ this.#waitOrder.delete(value);
166
+ }
167
+ }
168
+ if (owner) this.#deleteIfEmpty(viewer, owner);
169
+ }
170
+ while (this.#waitOrder.size > maxObservations) {
171
+ const [context, { viewer, waitId }] = this.#waitOrder.entries().next().value;
172
+ const owner = this.#stateFor(viewer);
173
+ owner?.waits.delete(waitId);
174
+ this.#waitOrder.delete(context);
175
+ if (owner) this.#deleteIfEmpty(viewer, owner);
176
+ }
177
+ }
178
+
179
+ forgetViewer(viewer) {
180
+ const owner = this.#stateFor(viewer);
181
+ if (!owner) return false;
182
+ for (const context of owner.waits.values()) this.#waitOrder.delete(context);
183
+ return this.#byViewer.delete(viewer);
184
+ }
185
+
186
+ viewerCount() {
187
+ return this.#byViewer.size;
188
+ }
189
+
190
+ waitCount(viewer) {
191
+ return this.#stateFor(viewer)?.waits.size ?? 0;
192
+ }
193
+
194
+ #deleteIfEmpty(viewer, owner) {
195
+ if (owner.ratesByOutput.size === 0 && owner.waits.size === 0) this.#byViewer.delete(viewer);
196
+ }
197
+ }
198
+
199
+ export function sourceService(free, delivered, rateOf) {
200
+ if (!free) return delivered;
201
+ if (!delivered) return free;
202
+ return (rateOf(delivered) ?? 0) > (rateOf(free) ?? 0) ? delivered : free;
203
+ }
204
+
205
+ function mergeWaitFeatures(previous, incoming, ageElapsedMs) {
206
+ const merged = { ...(previous ?? {}) };
207
+ for (const [key, value] of Object.entries(incoming ?? {})) {
208
+ if (value === null || value === undefined) {
209
+ if (!Object.hasOwn(merged, key)) merged[key] = null;
210
+ continue;
211
+ }
212
+ if (key === "extension") {
213
+ if (typeof value === "string" && value.length > 0) merged[key] = value;
214
+ continue;
215
+ }
216
+ if (key === "fileAvailable") {
217
+ if (typeof value === "boolean") merged[key] = value;
218
+ continue;
219
+ }
220
+ if (key === "videoOperation" || key === "audioOperation") {
221
+ if (value === "copy" || value === "encode") merged[key] = value;
222
+ continue;
223
+ }
224
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 ||
225
+ ((key === "fileLength" || key === "linkMbps") && value === 0)) continue;
226
+ merged[key] = value;
227
+ }
228
+ const hasIncomingAge = Number.isFinite(incoming?.linkSampleAgeMs) && incoming.linkSampleAgeMs >= 0;
229
+ if (Number.isFinite(merged.linkSampleAgeMs) && !hasIncomingAge) {
230
+ merged.linkSampleAgeMs += Number.isFinite(ageElapsedMs) ? Math.max(0, ageElapsedMs) : 0;
231
+ }
232
+ return merged;
233
+ }