@camstack/addon-pipeline 1.2.77 → 1.2.79

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.
Files changed (31) hide show
  1. package/dist/{addon-utils-bZcwLaVW.js → addon-utils-BZQxAJ1G.js} +1 -1
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +1 -1
  4. package/dist/detection-pipeline/index.js +4 -4
  5. package/dist/detection-pipeline/index.mjs +2 -2
  6. package/dist/{dist-DQZzL0Ri.js → dist-BUIpqu3E.js} +125 -7
  7. package/dist/{dist-HhcoV-Ky.mjs → dist-BfTf6doa.mjs} +125 -7
  8. package/dist/{event-loop-stall-monitor-CBOo1oh_.js → event-loop-stall-monitor-CGLQP0by.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-CqBEWGSZ.mjs → event-loop-stall-monitor-CmrHj0i9.mjs} +1 -1
  10. package/dist/{lazy-sharp-DIC1rpea.js → lazy-sharp-BZVhL9Lw.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/recorder/index.js +293 -34
  16. package/dist/recorder/index.mjs +292 -33
  17. package/dist/session-decode/decode-worker-child.js +2 -2
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-De_xzvjU.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BohtrVou.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-y8ehJPoY.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dxr14lIu.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-CDS5frmk.mjs → hostInit-pDANS0x0.mjs} +2 -2
  24. package/dist/stream-broker/index.js +347 -42
  25. package/dist/stream-broker/index.mjs +347 -42
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DZVGVAPU.js → worker-protocol-BaLcYcOo.js} +1 -1
  28. package/dist/{worker-protocol-BNlLGLBV.mjs → worker-protocol-BdOv9Ujl.mjs} +1 -1
  29. package/package.json +1 -1
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DHJSxHH1.mjs +0 -26
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dg08SxUW.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DQZzL0Ri.js");
5
+ const require_dist = require("../dist-BUIpqu3E.js");
6
6
  const require_remote_restream = require("../remote-restream-BYbAsgUf.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  node_crypto = require_dist.__toESM(node_crypto, 1);
@@ -22539,11 +22539,31 @@ var MonotonicClock = class {
22539
22539
  this.rate = rate;
22540
22540
  }
22541
22541
  };
22542
+ var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
22543
+ var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
22544
+ /** Demuxed bytes an entry holds — what the byte bound actually counts. */
22545
+ function fragmentBytes(frag) {
22546
+ let total = 0;
22547
+ for (const au of frag.aus) total += au.data.length;
22548
+ return total;
22549
+ }
22542
22550
  var ScrubFragmentCache = class {
22543
- max;
22551
+ maxBytes;
22552
+ otherMaxBytes;
22544
22553
  map = /* @__PURE__ */ new Map();
22545
- constructor(max = 8) {
22546
- this.max = max;
22554
+ /**
22555
+ * Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
22556
+ * `high` entry must never be able to evict the `low` segments the drag is
22557
+ * running on, and the reverse would be just as wrong.
22558
+ */
22559
+ bytesByProfile = /* @__PURE__ */ new Map();
22560
+ constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
22561
+ this.maxBytes = maxBytes;
22562
+ this.otherMaxBytes = otherMaxBytes;
22563
+ }
22564
+ /** The byte budget for a profile — the scrub profile gets the real one. */
22565
+ budgetFor(profile) {
22566
+ return profile === "low" ? this.maxBytes : this.otherMaxBytes;
22547
22567
  }
22548
22568
  key(profile, startMs) {
22549
22569
  return `${profile}|${startMs}`;
@@ -22574,17 +22594,45 @@ var ScrubFragmentCache = class {
22574
22594
  }
22575
22595
  return null;
22576
22596
  }
22577
- /** Insert (or refresh) a fragment, evicting the oldest past the bound. */
22597
+ /** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
22578
22598
  put(profile, frag) {
22579
22599
  const k = this.key(profile, frag.startMs);
22600
+ const held = this.map.get(k);
22601
+ let bytes = this.bytesByProfile.get(profile) ?? 0;
22602
+ if (held) bytes -= fragmentBytes(held);
22580
22603
  this.map.delete(k);
22581
22604
  this.map.set(k, frag);
22582
- while (this.map.size > this.max) {
22583
- const oldest = this.map.keys().next().value;
22605
+ bytes += fragmentBytes(frag);
22606
+ this.bytesByProfile.set(profile, bytes);
22607
+ const budget = this.budgetFor(profile);
22608
+ const prefix = `${profile}|`;
22609
+ while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
22610
+ const oldest = this.oldestKeyWithPrefix(prefix);
22584
22611
  if (oldest === void 0) break;
22612
+ const evicted = this.map.get(oldest);
22585
22613
  this.map.delete(oldest);
22614
+ if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
22586
22615
  }
22587
22616
  }
22617
+ oldestKeyWithPrefix(prefix) {
22618
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
22619
+ }
22620
+ entriesFor(profile) {
22621
+ const prefix = `${profile}|`;
22622
+ let n = 0;
22623
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
22624
+ return n;
22625
+ }
22626
+ /** Demuxed bytes currently held across every profile. */
22627
+ get byteSize() {
22628
+ let total = 0;
22629
+ for (const bytes of this.bytesByProfile.values()) total += bytes;
22630
+ return total;
22631
+ }
22632
+ /** Demuxed bytes held for one profile — the number its MB bound governs. */
22633
+ byteSizeFor(profile) {
22634
+ return this.bytesByProfile.get(profile) ?? 0;
22635
+ }
22588
22636
  get size() {
22589
22637
  return this.map.size;
22590
22638
  }
@@ -22650,6 +22698,10 @@ var ScrubDragStats = class {
22650
22698
  wholeReads = 0;
22651
22699
  readBytes = 0;
22652
22700
  parks = 0;
22701
+ prefetchReads = 0;
22702
+ prefetchBytes = 0;
22703
+ prefetchHits = 0;
22704
+ prefetchSuppressed = 0;
22653
22705
  ackTimeouts = 0;
22654
22706
  failures = 0;
22655
22707
  firstPushMs = null;
@@ -22697,6 +22749,19 @@ var ScrubDragStats = class {
22697
22749
  recordPark() {
22698
22750
  this.parks += 1;
22699
22751
  }
22752
+ /** One speculative GOP read and what it cost the disk. */
22753
+ recordPrefetchRead(bytes) {
22754
+ this.prefetchReads += 1;
22755
+ this.prefetchBytes += bytes;
22756
+ }
22757
+ /** Speculation declined: the disk is already the constraint. */
22758
+ recordPrefetchSuppressed() {
22759
+ this.prefetchSuppressed += 1;
22760
+ }
22761
+ /** A tick served by a fragment speculation had already landed. */
22762
+ recordPrefetchHit() {
22763
+ this.prefetchHits += 1;
22764
+ }
22700
22765
  /** Pushes written off after the ack timeout — dropped work, never silent. */
22701
22766
  recordAckTimeout(pushes) {
22702
22767
  this.ackTimeouts += pushes;
@@ -22750,6 +22815,10 @@ var ScrubDragStats = class {
22750
22815
  travel: this.travel,
22751
22816
  bytes: this.bytes,
22752
22817
  parks: this.parks,
22818
+ prefetchReads: this.prefetchReads,
22819
+ prefetchBytes: this.prefetchBytes,
22820
+ prefetchHits: this.prefetchHits,
22821
+ prefetchSuppressed: this.prefetchSuppressed,
22753
22822
  ackTimeouts: this.ackTimeouts,
22754
22823
  failures: this.failures,
22755
22824
  creditEndedAt: this.creditEndedAt,
@@ -22845,6 +22914,73 @@ var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
22845
22914
  */
22846
22915
  var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
22847
22916
  /**
22917
+ * How many speculative GOP reads may be in flight at once, i.e. how many drag
22918
+ * steps ahead the feeder reads.
22919
+ *
22920
+ * The scrub tick IS the read: measured on salone 590 over `low`,
22921
+ * `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
22922
+ * the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
22923
+ * hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
22924
+ * tens of seconds apart revisits nothing. Reading N steps ahead is the only
22925
+ * thing that converts wall-clock the drag is ALREADY spending into pushes.
22926
+ *
22927
+ * 2 and not more: these reads land on the same spindles the recorder is writing
22928
+ * every camera to, and past a small number concurrent random reads stop
22929
+ * overlapping and start thrashing. Raise it only against a measured
22930
+ * `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
22931
+ * both halves of the evidence.
22932
+ */
22933
+ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
22934
+ /**
22935
+ * The profile whose scrub read unit is the WHOLE SEGMENT.
22936
+ *
22937
+ * Every drag is pinned to `low` by the client, and a `low` segment is ~230 KB
22938
+ * for 10 s of timeline. Reading it whole is ONE sequential read; the GOP
22939
+ * byte-range it replaces was three random seeks (a 2 KB tail probe for the
22940
+ * `mfra` index, the `ftyp`+`moov` head, then the fragment) for ~135 KB of those
22941
+ * ~230 KB — and it bought nothing reusable, because the next tick inside the
22942
+ * same segment paid all three again. On a spindle that is also absorbing every
22943
+ * camera's writes, seeks are the cost and bytes are nearly free.
22944
+ *
22945
+ * The win is not the single read, it is what the read leaves behind: ten
22946
+ * seconds of demuxed access units. A drag inside one segment then costs zero
22947
+ * I/O and zero demux — which is the regime the operator spends most of a
22948
+ * careful gesture in.
22949
+ */
22950
+ var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
22951
+ /**
22952
+ * Safety ceiling on the whole-segment read. `low` is ~230 KB per 10 s, so this
22953
+ * is ~6× headroom for a camera whose substream is unusually fat — but a
22954
+ * segment past it (a mis-pinned `high` at ~5 MB) falls back to the GOP range,
22955
+ * because reading 5 MB to push one keyframe is the trade this change exists to
22956
+ * avoid, in the other direction.
22957
+ */
22958
+ var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
22959
+ /**
22960
+ * How far the entry warm-up reaches, backward and forward from the playhead the
22961
+ * drag starts on.
22962
+ *
22963
+ * Asymmetric because the gesture is: a drag that begins at the live edge — or
22964
+ * at a notification's moment — is looking for what ALREADY happened. 90 s back
22965
+ * against 30 s forward is 12 segments ≈ 2.8 MB at `low`, read sequentially in
22966
+ * the background while the operator is still deciding where to drag.
22967
+ */
22968
+ var SCRUB_WARM_BACK_MS = 9e4;
22969
+ var SCRUB_WARM_FWD_MS = 3e4;
22970
+ /** Hard bound on warm reads, whatever the spans above resolve to. */
22971
+ var SCRUB_WARM_MAX_SEGMENTS = 14;
22972
+ /**
22973
+ * Median recent read time at or above which the feeder STOPS speculating.
22974
+ *
22975
+ * Speculation is a bet that spare I/O exists. When reads are this slow there is
22976
+ * no spare I/O — the array is saturated — and a prefetch is not free work, it
22977
+ * is another random seek queued ahead of the read the operator is waiting on.
22978
+ * The field regime that forced this: 54 prefetch reads for 9 hits with the disk
22979
+ * at 98% busy. Suppression is counted (`prefetchSuppressed`) so the decision is
22980
+ * visible, and it lifts by itself as soon as reads come back.
22981
+ */
22982
+ var SCRUB_SLOW_READ_MS = 600;
22983
+ /**
22848
22984
  * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
22849
22985
  * that SWEEPS predicted frames instead of jumping. A steady forward drag is
22850
22986
  * playback with a moving target: once a keyframe decoded, the predicted frames
@@ -23063,12 +23199,29 @@ var RecordedFeeder = class {
23063
23199
  */
23064
23200
  scrubDirRun = 0;
23065
23201
  /**
23066
- * The SECOND bounded concurrency slot: `true` while a direction-aware prefetch
23067
- * demux is in flight. At most one prefetch runs at a time and it only fills
23068
- * `scrubCache` — it never pushes, never touches the pending/timer/latest-wins
23069
- * state, and never blocks the live coalesced tick.
23070
- */
23071
- scrubPrefetchInFlight = false;
23202
+ * Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
23203
+ * these are the SPECULATIVE concurrency slots, and they only fill
23204
+ * `scrubCache` — they never push, never touch the pending/timer/latest-wins
23205
+ * state, and never block the live coalesced tick.
23206
+ *
23207
+ * More than one because the read IS the tick (263 of 270 ms measured): with a
23208
+ * single slot the drag can never go faster than one cold read, however well
23209
+ * the speculation guesses.
23210
+ */
23211
+ scrubPrefetchInFlight = 0;
23212
+ /** Probe instants currently being read, so two horizons never duplicate one. */
23213
+ scrubPrefetchProbes = /* @__PURE__ */ new Set();
23214
+ /**
23215
+ * Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
23216
+ * hits one of them is what makes speculation provably worth its I/O
23217
+ * (`prefetchHits`) — without this the feeder could double the load on the disk
23218
+ * that is the bottleneck and the census would look calm.
23219
+ */
23220
+ scrubPrefetched = /* @__PURE__ */ new Set();
23221
+ /** Recent read durations — the evidence speculation is suspended on. */
23222
+ scrubReadMsWindow = [];
23223
+ /** Warm-up runs once per drag, from the playhead the finger went down on. */
23224
+ scrubWarmStarted = false;
23072
23225
  constructor(deps) {
23073
23226
  this.deps = deps;
23074
23227
  this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
@@ -23358,6 +23511,7 @@ var RecordedFeeder = class {
23358
23511
  this.scrubCredit = SCRUB_CREDIT_CAP;
23359
23512
  this.scrubPrevTarget = null;
23360
23513
  this.scrubDirRun = 0;
23514
+ this.scrubPrefetched = /* @__PURE__ */ new Set();
23361
23515
  this.scrubCursor = null;
23362
23516
  this.scrubNoFootageLogged = false;
23363
23517
  this.scrubParkLogged = false;
@@ -23375,6 +23529,88 @@ var RecordedFeeder = class {
23375
23529
  this.scrubTimer = null;
23376
23530
  }
23377
23531
  this.stopPositionReports();
23532
+ this.scrubWarmStarted = false;
23533
+ this.scrubReadMsWindow = [];
23534
+ if (this.cursorMs > 0 && this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) this.startScrubWarm(this.cursorMs, this.scrubToken);
23535
+ }
23536
+ /**
23537
+ * Warm the window the drag is about to move through: {@link SCRUB_WARM_BACK_MS}
23538
+ * back and {@link SCRUB_WARM_FWD_MS} forward from the entry playhead, one
23539
+ * segment at a time, backward first.
23540
+ *
23541
+ * Once per drag, always in the background, and never on the tick's path — a
23542
+ * tick that arrives against a cold cache pays its own read exactly as before.
23543
+ * It walks segment to segment through `locate` rather than assuming a segment
23544
+ * length, so a recorder configured with a different `segmentSeconds` warms the
23545
+ * same SPAN rather than the same count.
23546
+ */
23547
+ startScrubWarm(centreMs, gen) {
23548
+ if (this.scrubWarmStarted) return;
23549
+ this.scrubWarmStarted = true;
23550
+ this.warmScrubWindow(centreMs, gen);
23551
+ }
23552
+ async warmScrubWindow(centreMs, gen) {
23553
+ const spent = await this.warmScrubDirection(centreMs, -1, SCRUB_WARM_BACK_MS, 0, gen);
23554
+ await this.warmScrubDirection(centreMs, 1, SCRUB_WARM_FWD_MS, spent, gen);
23555
+ }
23556
+ /**
23557
+ * Walk `dir` from `fromMs` until `spanMs` of timeline is warm (or the shared
23558
+ * bound is hit), reading each segment that is not already cached. Returns the
23559
+ * running total of segments read, so the two directions share one budget.
23560
+ */
23561
+ async warmScrubDirection(fromMs, dir, spanMs, spent, gen) {
23562
+ let read = spent;
23563
+ let probeMs = fromMs;
23564
+ for (let i = 0; i < SCRUB_WARM_MAX_SEGMENTS; i++) {
23565
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return read;
23566
+ if (read >= SCRUB_WARM_MAX_SEGMENTS) return read;
23567
+ if (Math.abs(probeMs - fromMs) > spanMs) return read;
23568
+ if (this.scrubReadsAreSlow()) {
23569
+ this.scrubStats?.recordPrefetchSuppressed();
23570
+ return read;
23571
+ }
23572
+ const seg = await this.warmOneScrubSegment(probeMs, gen);
23573
+ if (seg === null) return read;
23574
+ if (seg.fetched) read += 1;
23575
+ probeMs = dir === -1 ? seg.startMs - 1 : seg.startMs + seg.durMs + 1;
23576
+ if (probeMs < 0) return read;
23577
+ }
23578
+ return read;
23579
+ }
23580
+ /**
23581
+ * One warm step: locate the segment covering `probeMs` and, unless it is
23582
+ * already cached, read and demux it into the cache. Returns the segment's span
23583
+ * so the walk can step to its neighbour, or null when the walk must stop (a
23584
+ * gap, a failure, or teardown).
23585
+ */
23586
+ async warmOneScrubSegment(probeMs, gen) {
23587
+ try {
23588
+ const warm = this.scrubCache.covering(this.deps.profile, probeMs);
23589
+ if (warm) return {
23590
+ startMs: warm.startMs,
23591
+ durMs: warm.durMs,
23592
+ fetched: false
23593
+ };
23594
+ const loc = await this.withTimeout(this.deps.locate({
23595
+ deviceId: this.deps.deviceId,
23596
+ profile: this.deps.profile,
23597
+ epochMs: probeMs
23598
+ }));
23599
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return null;
23600
+ if (loc.kind !== "segment") return null;
23601
+ const load = await this.loadScrubFragment(loc, probeMs);
23602
+ if (load !== null) this.scrubStats?.recordPrefetchRead(load.readBytes);
23603
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return null;
23604
+ this.scrubCache.put(this.deps.profile, load.seg);
23605
+ this.scrubPrefetched.add(load.seg.startMs);
23606
+ return {
23607
+ startMs: load.seg.startMs,
23608
+ durMs: load.seg.durMs,
23609
+ fetched: true
23610
+ };
23611
+ } catch {
23612
+ return null;
23613
+ }
23378
23614
  }
23379
23615
  /**
23380
23616
  * EXPERIMENTAL feeder-scrub: register a drag target. Records the newest target
@@ -23533,7 +23769,10 @@ var RecordedFeeder = class {
23533
23769
  coverage = "memo";
23534
23770
  } else {
23535
23771
  seg = this.scrubCache.covering(this.deps.profile, target);
23536
- if (seg) coverage = "cache";
23772
+ if (seg) {
23773
+ coverage = "cache";
23774
+ if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordPrefetchHit();
23775
+ }
23537
23776
  }
23538
23777
  if (!seg) {
23539
23778
  const locateStartMs = performance.now();
@@ -23635,7 +23874,7 @@ var RecordedFeeder = class {
23635
23874
  firstIdx = this.pickScrubIndex(seg, effTarget, true);
23636
23875
  const kf = seg.aus[firstIdx];
23637
23876
  aus = kf === void 0 ? [] : [kf];
23638
- if (prevTarget !== null && kf !== void 0) jumpTravel = this.collectWarmTravelStills(prevTarget, effTarget, seg.startMs);
23877
+ if (prevTarget !== null && kf !== void 0) jumpTravel = this.collectWarmTravelStills(prevTarget, effTarget, seg.startMs, firstIdx);
23639
23878
  }
23640
23879
  const lastAu = aus[aus.length - 1];
23641
23880
  if (lastAu === void 0) {
@@ -23741,15 +23980,19 @@ var RecordedFeeder = class {
23741
23980
  return idx;
23742
23981
  }
23743
23982
  /**
23744
- * Load the scrub fragment covering `effTarget` inside the located segment:
23745
- * the GOP byte-range read (D31 — one moof/mdat plus the moov head, 41–173 KB
23746
- * on `low`) when the provider offers `readGop`, else the whole-segment read
23747
- * (100–600 KB on `low` the documented degradation for an older provider).
23983
+ * Load the scrub unit covering `effTarget`.
23984
+ *
23985
+ * For the scrub profile that unit is the WHOLE SEGMENT one sequential read
23986
+ * of ~230 KB that leaves ten seconds of demuxed access units behind, so every
23987
+ * later tick landing in it costs nothing at all. Everything else keeps the
23988
+ * GOP byte-range (D31): a `high` segment is ~5 MB and reading it to push one
23989
+ * keyframe would be this same trade made backwards.
23990
+ *
23748
23991
  * Null when superseded mid-flight. Video-only: scrub never plays audio.
23749
23992
  */
23750
23993
  async loadScrubFragment(loc, effTarget) {
23751
23994
  const readGop = this.deps.readGop;
23752
- if (readGop) {
23995
+ if (readGop && !this.readsWholeSegment(loc)) {
23753
23996
  const token = this.seekToken;
23754
23997
  const readStartMs = performance.now();
23755
23998
  const gop = await this.withTimeout(readGop({
@@ -23763,6 +24006,7 @@ var RecordedFeeder = class {
23763
24006
  if (this.disposed || token !== this.seekToken) return null;
23764
24007
  const aus = await this.withTimeout(this.deps.demux(gop.bytes));
23765
24008
  if (this.disposed || token !== this.seekToken) return null;
24009
+ this.noteScrubReadMs(readEndMs - readStartMs);
23766
24010
  return {
23767
24011
  seg: {
23768
24012
  startMs: gop.gopStartMs,
@@ -23775,22 +24019,60 @@ var RecordedFeeder = class {
23775
24019
  wholeSegmentRead: false
23776
24020
  };
23777
24021
  }
23778
- const wholeStartMs = performance.now();
23779
- const loaded = await this.loadAus(loc.startMs, false);
23780
- if (loaded === null) return null;
24022
+ const token = this.seekToken;
24023
+ const readStartMs = performance.now();
24024
+ const bytes = await this.withTimeout(this.deps.readBytes({
24025
+ deviceId: this.deps.deviceId,
24026
+ profile: this.deps.profile,
24027
+ startMs: loc.startMs
24028
+ }));
24029
+ const readEndMs = performance.now();
24030
+ if (this.disposed || token !== this.seekToken) return null;
24031
+ const aus = await this.withTimeout(this.deps.demux(bytes));
24032
+ if (this.disposed || token !== this.seekToken) return null;
24033
+ this.noteScrubReadMs(readEndMs - readStartMs);
23781
24034
  return {
23782
24035
  seg: {
23783
24036
  startMs: loc.startMs,
23784
24037
  durMs: loc.durMs,
23785
- aus: loaded.video
24038
+ aus
23786
24039
  },
23787
- readMs: performance.now() - wholeStartMs,
23788
- readBytes: loc.bytes,
23789
- demuxMs: null,
24040
+ readMs: readEndMs - readStartMs,
24041
+ readBytes: bytes.length,
24042
+ demuxMs: performance.now() - readEndMs,
23790
24043
  wholeSegmentRead: true
23791
24044
  };
23792
24045
  }
23793
24046
  /**
24047
+ * Whether this located segment is read WHOLE. The scrub profile is, up to a
24048
+ * safety ceiling on the byte size — see {@link SCRUB_WHOLE_SEGMENT_PROFILE}.
24049
+ * A `locate` that does not report a size is trusted for the scrub profile,
24050
+ * where the shape is known and small.
24051
+ */
24052
+ readsWholeSegment(loc) {
24053
+ if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
24054
+ return loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES;
24055
+ }
24056
+ /**
24057
+ * Fold one read into the recent-read window that governs speculation. The
24058
+ * window is short on purpose: suppression must track what the disk is doing
24059
+ * NOW, and lift as soon as it recovers.
24060
+ */
24061
+ noteScrubReadMs(readMs) {
24062
+ this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
24063
+ }
24064
+ /**
24065
+ * Is the disk the problem right now? Median of the recent reads against
24066
+ * {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
24067
+ * noise and the decision must not swing on it; and never before a few
24068
+ * samples, or a cold first read would suppress the whole drag.
24069
+ */
24070
+ scrubReadsAreSlow() {
24071
+ if (this.scrubReadMsWindow.length < 3) return false;
24072
+ const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
24073
+ return (sorted[Math.floor(sorted.length / 2)] ?? 0) >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
24074
+ }
24075
+ /**
23794
24076
  * A single slow `locate` gets its own line, throttled per drag. Reserved for
23795
24077
  * the pathological case on purpose: D68 moved this RPC off the warm path, so
23796
24078
  * a slow one still landing there is the finding — and if it is NOT rare, the
@@ -23922,18 +24204,21 @@ var RecordedFeeder = class {
23922
24204
  * deduped by fragment, excluding the fragments that cover the endpoints
23923
24205
  * (one is on screen, the other is the jump's own push).
23924
24206
  */
23925
- collectWarmTravelStills(fromMs, toMs, excludeFragStartMs) {
24207
+ collectWarmTravelStills(fromMs, toMs, excludeFragStartMs, excludeKfIdx) {
23926
24208
  const stills = [];
23927
- const seen = new Set([excludeFragStartMs]);
24209
+ const seen = /* @__PURE__ */ new Set();
23928
24210
  const samples = SCRUB_JUMP_TRAVEL_MAX_STILLS;
23929
24211
  for (let i = 1; i <= samples; i++) {
23930
24212
  const tMs = Math.round(fromMs + (toMs - fromMs) * i / 4);
23931
24213
  const seg = this.scrubCache.covering(this.deps.profile, tMs);
23932
- if (!seg || seen.has(seg.startMs) || fragmentCovers(seg, fromMs)) continue;
24214
+ if (!seg || fragmentCovers(seg, fromMs)) continue;
23933
24215
  const kfIdx = this.pickScrubIndex(seg, tMs, true);
23934
24216
  const au = seg.aus[kfIdx];
23935
24217
  if (au === void 0 || !au.keyframe) continue;
23936
- seen.add(seg.startMs);
24218
+ if (seg.startMs === excludeFragStartMs && kfIdx === excludeKfIdx) continue;
24219
+ const key = `${seg.startMs}|${kfIdx}`;
24220
+ if (seen.has(key)) continue;
24221
+ seen.add(key);
23937
24222
  stills.push({
23938
24223
  seg,
23939
24224
  au,
@@ -23955,28 +24240,45 @@ var RecordedFeeder = class {
23955
24240
  this.scrubDirRun = 0;
23956
24241
  return;
23957
24242
  }
23958
- const dir = target > prev ? 1 : target < prev ? -1 : 0;
24243
+ const stepMs = target - prev;
24244
+ const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
23959
24245
  if (dir === 0) {
23960
24246
  this.scrubDirRun = 0;
23961
24247
  return;
23962
24248
  }
23963
24249
  this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
23964
24250
  if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
23965
- if (this.scrubPrefetchInFlight) return;
23966
- const probeMs = dir === 1 ? seg.startMs + seg.durMs + 1 : seg.startMs - 1;
23967
- if (probeMs < 0) return;
23968
- this.scrubPrefetch(probeMs, gen);
24251
+ if (this.scrubReadsAreSlow()) {
24252
+ this.scrubStats?.recordPrefetchSuppressed();
24253
+ return;
24254
+ }
24255
+ const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
24256
+ const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
24257
+ for (let horizon = 1; horizon <= horizons; horizon++) {
24258
+ if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
24259
+ const probeMs = target + dir * aheadMs * horizon;
24260
+ if (probeMs < 0) continue;
24261
+ if (this.scrubPrefetchProbes.has(probeMs)) continue;
24262
+ if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
24263
+ this.scrubPrefetch(probeMs, gen);
24264
+ }
23969
24265
  }
23970
24266
  /**
23971
- * Background (second-slot) prefetch: locate + read + demux the fragment
23972
- * covering `probeMs` into `scrubCache`. Bounded to one in flight; aborts at any
24267
+ * Speculative read: locate + read + demux the fragment covering `probeMs` into
24268
+ * `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
23973
24269
  * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
23974
- * swallowed (the neighbour just demuxes on demand). Never pushes and never
24270
+ * swallowed (the fragment just demuxes on demand). Never pushes and never
23975
24271
  * mutates latest-wins state.
24272
+ *
24273
+ * Every read it issues is COUNTED (`prefetchReads` / `prefetchBytes`), and the
24274
+ * fragment it lands is remembered so a later tick hitting it counts as a
24275
+ * `prefetchHit`. Speculation that cannot be shown to pay for itself is just
24276
+ * load on the disk the drag is already waiting for.
23976
24277
  */
23977
24278
  async scrubPrefetch(probeMs, gen) {
23978
- if (this.scrubPrefetchInFlight) return;
23979
- this.scrubPrefetchInFlight = true;
24279
+ if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
24280
+ this.scrubPrefetchInFlight += 1;
24281
+ this.scrubPrefetchProbes.add(probeMs);
23980
24282
  try {
23981
24283
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
23982
24284
  if (this.scrubCache.covering(this.deps.profile, probeMs)) return;
@@ -23988,10 +24290,13 @@ var RecordedFeeder = class {
23988
24290
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
23989
24291
  if (loc.kind !== "segment") return;
23990
24292
  const load = await this.loadScrubFragment(loc, probeMs);
24293
+ if (load !== null) this.scrubStats?.recordPrefetchRead(load.readBytes);
23991
24294
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
23992
24295
  this.scrubCache.put(this.deps.profile, load.seg);
24296
+ this.scrubPrefetched.add(load.seg.startMs);
23993
24297
  } catch {} finally {
23994
- this.scrubPrefetchInFlight = false;
24298
+ this.scrubPrefetchInFlight -= 1;
24299
+ this.scrubPrefetchProbes.delete(probeMs);
23995
24300
  }
23996
24301
  }
23997
24302
  /**