@camstack/addon-pipeline 1.2.127 → 1.2.129

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 (35) hide show
  1. package/dist/{addon-utils-BnprivSs.js → addon-utils-CKB-2oM9.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +241 -95
  5. package/dist/detection-pipeline/index.mjs +239 -93
  6. package/dist/{dist-HLAOw6Pe.mjs → dist-CMio-1qq.mjs} +47 -31
  7. package/dist/{dist-BZUXb5S5.js → dist-D9x2_csL.js} +47 -31
  8. package/dist/{event-loop-stall-monitor-BH-cL_hg.mjs → event-loop-stall-monitor-B9dxYxyf.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-BdArVUlx.js → event-loop-stall-monitor-UW8nsiMb.js} +1 -1
  10. package/dist/{lazy-sharp-DTDXrhMG.js → lazy-sharp-DrLwLKpg.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/{process-memory-CDPRLalN.mjs → process-memory-C6es8dMA.mjs} +1 -1
  16. package/dist/{process-memory-Dh1SyzdJ.js → process-memory-CSJ7-Wlp.js} +1 -1
  17. package/dist/recorder/index.js +121 -33
  18. package/dist/recorder/index.mjs +120 -32
  19. package/dist/{segment-demux-js-D-TLcHW5.mjs → segment-demux-js-BRCjXbI0.mjs} +1 -1
  20. package/dist/{segment-demux-js-DRutggd6.js → segment-demux-js-DiLDoV9_.js} +1 -1
  21. package/dist/session-decode/decode-worker-child.js +2 -2
  22. package/dist/session-decode/decode-worker-child.mjs +1 -1
  23. package/dist/stream-broker/_stub.js +1 -1
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BDR5z0Z9.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-1O99xPTr.mjs} +2 -2
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CxppPmXr.mjs +26 -0
  26. package/dist/stream-broker/demux-worker-child.js +1 -1
  27. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  28. package/dist/stream-broker/{hostInit-Dmw5LWmP.mjs → hostInit-8scvCWua.mjs} +2 -2
  29. package/dist/stream-broker/index.js +530 -154
  30. package/dist/stream-broker/index.mjs +530 -154
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/dist/{worker-protocol-h7Smb_0i.mjs → worker-protocol-BHNWB9Ve.mjs} +1 -1
  33. package/dist/{worker-protocol-BxkCoVjw.js → worker-protocol-BmBYCioC.js} +1 -1
  34. package/package.json +1 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cs8bmrWP.mjs +0 -26
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BZUXb5S5.js");
5
+ const require_dist = require("../dist-D9x2_csL.js");
6
6
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
9
- const require_segment_demux_js = require("../segment-demux-js-DRutggd6.js");
9
+ const require_segment_demux_js = require("../segment-demux-js-DiLDoV9_.js");
10
10
  let node_crypto = require("node:crypto");
11
11
  node_crypto = require_dist.__toESM(node_crypto, 1);
12
12
  let node_child_process = require("node:child_process");
@@ -390,6 +390,83 @@ var STREAM_BROKER_RETIRED_ROOT_KEYS = [
390
390
  }
391
391
  ];
392
392
  //#endregion
393
+ //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
394
+ /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
395
+ function logBannerArgs(level) {
396
+ return [
397
+ "-hide_banner",
398
+ "-loglevel",
399
+ level
400
+ ];
401
+ }
402
+ //#endregion
403
+ //#region src/shared/ffmpeg-decode-args.ts
404
+ /**
405
+ * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
406
+ *
407
+ * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
408
+ * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
409
+ * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
410
+ * `qsv` first on Intel, so we re-rank its result through this table before
411
+ * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
412
+ * trail for NVIDIA. A method not listed here keeps its incoming relative order,
413
+ * placed AFTER every ranked one.
414
+ */
415
+ var DECODE_HWACCEL_RANK = [
416
+ "videotoolbox",
417
+ "vaapi",
418
+ "qsv",
419
+ "cuda",
420
+ "nvdec",
421
+ "d3d11va",
422
+ "dxva2",
423
+ "amf",
424
+ "vdpau",
425
+ "drm"
426
+ ];
427
+ /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
428
+ function decodeHwAccelRankIndex(method) {
429
+ const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
430
+ return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
431
+ }
432
+ /**
433
+ * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
434
+ * `'auto'` decode session, best-first. The session walks this chain on an
435
+ * early-exit-with-no-frames and only falls to software once it is exhausted.
436
+ *
437
+ * Strategy:
438
+ * - Seed candidates from the kernel-preferred order UNION the canonical rank
439
+ * UNION the build's supported list, so a method the kernel omitted but the
440
+ * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
441
+ * still attempted.
442
+ * - When the build's supported-method list is known (non-empty), keep only
443
+ * methods it actually offers; on a probe miss (empty) keep just the kernel
444
+ * preferences (avoid blindly spawning every backend).
445
+ * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
446
+ * `qsv`. A supported method not in the rank table keeps its position AFTER
447
+ * every ranked one (stable sort). Returns lowercased method names; empty ⇒
448
+ * pure software.
449
+ */
450
+ function rankDecodeHwAccels(preferred, supportedMethods) {
451
+ const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
452
+ const hasSupportList = supported.size > 0;
453
+ const pool = hasSupportList ? [
454
+ ...preferred,
455
+ ...DECODE_HWACCEL_RANK,
456
+ ...supportedMethods
457
+ ] : [...preferred];
458
+ const seen = /* @__PURE__ */ new Set();
459
+ const candidates = [];
460
+ for (const raw of pool) {
461
+ const method = raw.toLowerCase();
462
+ if (seen.has(method)) continue;
463
+ seen.add(method);
464
+ if (hasSupportList && !supported.has(method)) continue;
465
+ candidates.push(method);
466
+ }
467
+ return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
468
+ }
469
+ //#endregion
393
470
  //#region src/stream-broker/battery-wake-on-play.ts
394
471
  /**
395
472
  * Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
@@ -592,83 +669,6 @@ async function wakeBatteryDeviceForPlay(deviceId, deps) {
592
669
  throw new BatteryStreamUnavailableError(deviceId, outcome, wake.awoke ? "it acknowledged the wake but no stream had appeared yet — try again in a few seconds" : "it did not answer the wake in time — try again");
593
670
  }
594
671
  //#endregion
595
- //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
596
- /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
597
- function logBannerArgs(level) {
598
- return [
599
- "-hide_banner",
600
- "-loglevel",
601
- level
602
- ];
603
- }
604
- //#endregion
605
- //#region src/shared/ffmpeg-decode-args.ts
606
- /**
607
- * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
608
- *
609
- * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
610
- * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
611
- * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
612
- * `qsv` first on Intel, so we re-rank its result through this table before
613
- * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
614
- * trail for NVIDIA. A method not listed here keeps its incoming relative order,
615
- * placed AFTER every ranked one.
616
- */
617
- var DECODE_HWACCEL_RANK = [
618
- "videotoolbox",
619
- "vaapi",
620
- "qsv",
621
- "cuda",
622
- "nvdec",
623
- "d3d11va",
624
- "dxva2",
625
- "amf",
626
- "vdpau",
627
- "drm"
628
- ];
629
- /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
630
- function decodeHwAccelRankIndex(method) {
631
- const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
632
- return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
633
- }
634
- /**
635
- * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
636
- * `'auto'` decode session, best-first. The session walks this chain on an
637
- * early-exit-with-no-frames and only falls to software once it is exhausted.
638
- *
639
- * Strategy:
640
- * - Seed candidates from the kernel-preferred order UNION the canonical rank
641
- * UNION the build's supported list, so a method the kernel omitted but the
642
- * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
643
- * still attempted.
644
- * - When the build's supported-method list is known (non-empty), keep only
645
- * methods it actually offers; on a probe miss (empty) keep just the kernel
646
- * preferences (avoid blindly spawning every backend).
647
- * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
648
- * `qsv`. A supported method not in the rank table keeps its position AFTER
649
- * every ranked one (stable sort). Returns lowercased method names; empty ⇒
650
- * pure software.
651
- */
652
- function rankDecodeHwAccels(preferred, supportedMethods) {
653
- const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
654
- const hasSupportList = supported.size > 0;
655
- const pool = hasSupportList ? [
656
- ...preferred,
657
- ...DECODE_HWACCEL_RANK,
658
- ...supportedMethods
659
- ] : [...preferred];
660
- const seen = /* @__PURE__ */ new Set();
661
- const candidates = [];
662
- for (const raw of pool) {
663
- const method = raw.toLowerCase();
664
- if (seen.has(method)) continue;
665
- seen.add(method);
666
- if (hasSupportList && !supported.has(method)) continue;
667
- candidates.push(method);
668
- }
669
- return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
670
- }
671
- //#endregion
672
672
  //#region src/stream-broker/derived/cycle-check.ts
673
673
  /**
674
674
  * Would assigning `derivedCamStreamId` to `targetProfile` create a cycle?
@@ -16279,7 +16279,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16279
16279
  }
16280
16280
  let devices;
16281
16281
  try {
16282
- devices = await api.deviceManager.listAll.query({});
16282
+ devices = await api.deviceManager.listAll.query({ projection: "slim" });
16283
16283
  } catch (err) {
16284
16284
  this.logger.warn("profile-map prune skipped — deviceManager.listAll failed", { meta: {
16285
16285
  persistedCount,
@@ -16604,7 +16604,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16604
16604
  if (!this.api) return;
16605
16605
  let devices;
16606
16606
  try {
16607
- devices = await this.api.deviceManager.listAll.query({});
16607
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16608
16608
  } catch (err) {
16609
16609
  this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", { meta: { error: require_dist.errMsg(err) } });
16610
16610
  return;
@@ -16744,7 +16744,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16744
16744
  if (!this.api) return false;
16745
16745
  let devices;
16746
16746
  try {
16747
- devices = await this.api.deviceManager.listAll.query({});
16747
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16748
16748
  } catch {
16749
16749
  return false;
16750
16750
  }
@@ -23797,6 +23797,9 @@ var ScrubDragStats = class {
23797
23797
  prefetchHits = 0;
23798
23798
  prefetchSuppressed = 0;
23799
23799
  ackTimeouts = 0;
23800
+ deadlineMisses = 0;
23801
+ retargets = 0;
23802
+ resolveJoins = 0;
23800
23803
  failures = 0;
23801
23804
  firstPushMs = null;
23802
23805
  creditEndedAt = null;
@@ -23806,6 +23809,9 @@ var ScrubDragStats = class {
23806
23809
  demuxMs = new PhaseSamples();
23807
23810
  tickMs = new PhaseSamples();
23808
23811
  ackMs = new PhaseSamples();
23812
+ leadMs = new PhaseSamples();
23813
+ oversizeSegments = 0;
23814
+ degradedProfile = null;
23809
23815
  constructor(startedAtMs) {
23810
23816
  this.startedAtMs = startedAtMs;
23811
23817
  }
@@ -23860,6 +23866,30 @@ var ScrubDragStats = class {
23860
23866
  recordAckTimeout(pushes) {
23861
23867
  this.ackTimeouts += pushes;
23862
23868
  }
23869
+ /** A frame abandoned on its deadline — dropped work, counted every time. */
23870
+ recordDeadlineMiss() {
23871
+ this.deadlineMisses += 1;
23872
+ }
23873
+ /** A landing re-aimed at the newer finger position it also covered. */
23874
+ recordRetarget() {
23875
+ this.retargets += 1;
23876
+ }
23877
+ /** A whole-segment read that had to degrade to a GOP range on size. */
23878
+ recordOversizeSegment() {
23879
+ this.oversizeSegments += 1;
23880
+ }
23881
+ /** This drag is not running on the scrub profile — every gate is off. */
23882
+ recordProfileDegraded(profile) {
23883
+ this.degradedProfile = profile;
23884
+ }
23885
+ /** One read aimed ahead of the finger, and by how much. */
23886
+ recordLead(leadMs) {
23887
+ this.leadMs.add(Math.abs(leadMs));
23888
+ }
23889
+ /** A tick served by the read another tick already had in flight. */
23890
+ recordResolveJoin() {
23891
+ this.resolveJoins += 1;
23892
+ }
23863
23893
  /** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
23864
23894
  recordFailure() {
23865
23895
  this.failures += 1;
@@ -23875,6 +23905,7 @@ var ScrubDragStats = class {
23875
23905
  const dmx = this.demuxMs.summary();
23876
23906
  const tick = this.tickMs.summary();
23877
23907
  const ack = this.ackMs.summary();
23908
+ const lead = this.leadMs.summary();
23878
23909
  return {
23879
23910
  ticks: this.ticks,
23880
23911
  dragMs: Math.round(dragMs),
@@ -23914,6 +23945,13 @@ var ScrubDragStats = class {
23914
23945
  prefetchHits: this.prefetchHits,
23915
23946
  prefetchSuppressed: this.prefetchSuppressed,
23916
23947
  ackTimeouts: this.ackTimeouts,
23948
+ deadlineMisses: this.deadlineMisses,
23949
+ retargets: this.retargets,
23950
+ resolveJoins: this.resolveJoins,
23951
+ leadMsP50: lead.p50Ms,
23952
+ leadMsMax: lead.maxMs,
23953
+ oversizeSegments: this.oversizeSegments,
23954
+ degradedProfile: this.degradedProfile,
23917
23955
  failures: this.failures,
23918
23956
  creditEndedAt: this.creditEndedAt,
23919
23957
  acks: ack.n,
@@ -24068,11 +24106,29 @@ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24068
24106
  */
24069
24107
  var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
24070
24108
  /**
24071
- * Safety ceiling on the whole-segment read. `low` is ~230 KB per 10 s, so this
24072
- * is ~6× headroom for a camera whose substream is unusually fat — but a
24073
- * segment past it (a mis-pinned `high` at ~5 MB) falls back to the GOP range,
24074
- * because reading 5 MB to push one keyframe is the trade this change exists to
24075
- * avoid, in the other direction.
24109
+ * Safety ceiling on ONE scrub read, in bytes.
24110
+ *
24111
+ * `low` is ~230 KB per 10 s, so this is ~6× headroom for a camera whose
24112
+ * substream is unusually fat but a segment past it (a mis-pinned `high` at
24113
+ * ~5 MB) falls back to the GOP range, because reading 5 MB to push one
24114
+ * keyframe is the trade this change exists to avoid, in the other direction.
24115
+ *
24116
+ * ⚠️ THIS NUMBER IS TIED TO THE SEGMENT DURATION AND THE TIE IS NOT ENFORCED.
24117
+ * It was tuned against a 10-second `low` segment. At the 60-second segments
24118
+ * the 2026-08-25 teardown proposes, a NATIVE `low` is ~1.4 MB (just inside,
24119
+ * and the whole-segment read gets six times better — one seek leaves a minute
24120
+ * of access units behind) while a DERIVED `low` is ~4.8 MB (outside, and every
24121
+ * scrub read silently degrades to a 3-seek GOP range that leaves nothing
24122
+ * behind). Same constant, opposite outcomes, no signal either way — which is
24123
+ * why the degradation is now COUNTED and LOGGED
24124
+ * (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
24125
+ * instead of being inferred from a falling hit rate.
24126
+ *
24127
+ * The real fix, when 60 s segments land, is a read unit that is a BYTE BUDGET
24128
+ * over the fragment's own keyframe table — "as many consecutive GOPs as fit in
24129
+ * N bytes" — which needs a provider method we do not have
24130
+ * (`readGopBytes` reads exactly one GOP). Until then this is an absolute cap
24131
+ * on a single read and the log is the guard.
24076
24132
  */
24077
24133
  var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
24078
24134
  /**
@@ -24133,6 +24189,115 @@ var SCRUB_SLOW_READ_MS = 600;
24133
24189
  */
24134
24190
  var SCRUB_SWEEP_WINDOW_MS = 4e3;
24135
24191
  /**
24192
+ * THE DEADLINE ON ONE SCRUB FRAME — and the reason it is not
24193
+ * {@link SEGMENT_TIMEOUT_MS}.
24194
+ *
24195
+ * A scrub tick used to be bounded only by the 8 s per-RPC timeout, against a
24196
+ * cold read measured at p50 531 ms, p95 1.4–2 s and a 9.2 s max (census
24197
+ * 2026-08-25). So the feeder WAITED. The frame it eventually pushed was for a
24198
+ * finger position from seconds earlier, and the gesture spent its whole
24199
+ * duration on a handful of stale frames — the operator's "quasi 0 frame, e
24200
+ * l'immagine insegue il dito".
24201
+ *
24202
+ * The reference design (Scrypted NVR, teardown 2026-08-25) spends at most
24203
+ * ~1000 ms on the WHOLE scrub animation and gives every single frame the
24204
+ * REMAINING window as its deadline: a frame that cannot be produced in time is
24205
+ * dropped, never awaited. This is the same contract on our constraints.
24206
+ *
24207
+ * 700 ms and not 1000: the deadline must be shorter than the gesture's own
24208
+ * animation window or it can never fire twice inside one, and it must be above
24209
+ * the cold-read p50 (531 ms) or the median frame would be dropped — a deadline
24210
+ * that drops the median makes the picture WORSE, not fresher. 700 ms sits
24211
+ * between: it serves the p50–p60 of cold reads and cuts the p90 tail that used
24212
+ * to eat the gesture.
24213
+ *
24214
+ * Dropping is never silent: `deadlineMisses` on the drag aggregate plus one
24215
+ * `scrub-feeder:deadline-miss` line per drag.
24216
+ */
24217
+ var SCRUB_TICK_DEADLINE_MS = 700;
24218
+ /**
24219
+ * The FIRST frame of a drag gets a longer deadline, because nothing is painted
24220
+ * yet.
24221
+ *
24222
+ * Every later frame has a predecessor on screen, so dropping it costs
24223
+ * freshness. The first one has none: dropping it costs the whole picture, and
24224
+ * the operator reads a blank recorded view as "playback parte dopo una vita".
24225
+ * The reference design has the same asymmetry for the same reason — its
24226
+ * animation frames are deadline-dropped while the LANDING seek is unbounded.
24227
+ *
24228
+ * 2500 ms covers the cold-read p95 (1.4–2 s) and still cuts the 9.2 s tail.
24229
+ */
24230
+ var SCRUB_FIRST_TICK_DEADLINE_MS = 2500;
24231
+ /**
24232
+ * How far a new target may be from an in-flight read's target and still RIDE
24233
+ * it instead of starting a second one.
24234
+ *
24235
+ * A `low` segment is ~10 s of timeline and the read unit is the whole segment
24236
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}), so a target within one segment of the
24237
+ * one being read is very likely already inside the bytes on their way. Riding
24238
+ * costs nothing and saves a random seek on the spindle that is the bottleneck;
24239
+ * being wrong costs one wasted deadline, which is now cheap by construction.
24240
+ */
24241
+ var SCRUB_RIDE_WINDOW_MS = 1e4;
24242
+ /**
24243
+ * Hard bound on concurrent fragment resolutions for one drag.
24244
+ *
24245
+ * A deadline that drops frames is only safe if dropping does not multiply the
24246
+ * work: without this bound a drag over slow footage would abandon a read every
24247
+ * {@link SCRUB_TICK_DEADLINE_MS} and start another, turning one saturated
24248
+ * spindle into N. Two: the one that is landing, and the one the finger has
24249
+ * moved on to. At the cap, a tick rides the newest in-flight read instead.
24250
+ */
24251
+ var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
24252
+ /**
24253
+ * READ-AHEAD LEAD — the fix for "the picture chases the finger".
24254
+ *
24255
+ * A scrub frame is always the frame a read STARTED for, one read time ago.
24256
+ * On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
24257
+ * a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
24258
+ * the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
24259
+ * behind the finger. Nothing about that is I/O — it is aiming at where the
24260
+ * finger WAS.
24261
+ *
24262
+ * So a steady drag reads where the finger WILL BE: `mediaVelocity ×
24263
+ * medianRecentReadMs`. It costs nothing (the same one read, at a different
24264
+ * offset), and it is bounded by the two things that make it a prediction
24265
+ * rather than a guess:
24266
+ *
24267
+ * - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
24268
+ * samples, so a jittery or reversing drag never leads. Pointer samples and
24269
+ * not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
24270
+ * ~4 pushes, so a gate counted in pushes has no runway inside the gesture
24271
+ * it is supposed to help — measured in the drag bench, it engaged once, at
24272
+ * the very end;
24273
+ * - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
24274
+ * is derived from this disk right now and not from a constant. ONE is
24275
+ * enough: against a read cost that is unknown, a one-sample estimate is not
24276
+ * a good prediction — it is simply a far better one than assuming zero.
24277
+ *
24278
+ * Overshoot is self-correcting: the retarget below serves the newest target
24279
+ * the landed fragment covers, so a lead that is slightly long lands on the
24280
+ * finger exactly, and one that is wrong costs the same single read it would
24281
+ * have cost anyway.
24282
+ */
24283
+ var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
24284
+ /**
24285
+ * Smoothing for the media-velocity estimate (ms of timeline per ms of wall
24286
+ * clock), folded on every pointer sample. Half-and-half: fast enough to track
24287
+ * a fling that starts mid-gesture, slow enough that one jittery sample cannot
24288
+ * aim a read an hour away.
24289
+ */
24290
+ var SCRUB_VELOCITY_EMA = .5;
24291
+ /** Same-direction POINTER samples required before a read may aim ahead. */
24292
+ var SCRUB_LEAD_MIN_DIR_RUN = 2;
24293
+ /**
24294
+ * Pointer-sample gaps outside this range do not update the velocity estimate:
24295
+ * below it the delta is dominated by timer jitter, above it the finger has
24296
+ * effectively paused and the "velocity" is an artefact of the pause.
24297
+ */
24298
+ var SCRUB_VELOCITY_MIN_GAP_MS = 10;
24299
+ var SCRUB_VELOCITY_MAX_GAP_MS = 500;
24300
+ /**
24136
24301
  * Receiver-credit cap for scrub pushes: at most this many PUSHES may be
24137
24302
  * OUTSTANDING (on the track, not yet acknowledged by the viewer via
24138
24303
  * `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
@@ -24389,6 +24554,28 @@ var RecordedFeeder = class {
24389
24554
  scrubWarmGen = 0;
24390
24555
  /** Tick demand-read in flight: the fill waits so the finger wins the spindle. */
24391
24556
  scrubTickReadInFlight = false;
24557
+ /**
24558
+ * The newest fragment resolution in flight for this drag — what a later tick
24559
+ * rides instead of issuing its own read. Cleared by the resolution itself.
24560
+ */
24561
+ scrubResolve = null;
24562
+ /** Concurrent resolutions, bounded by {@link SCRUB_RESOLVE_MAX_INFLIGHT}. */
24563
+ scrubResolvesInFlight = 0;
24564
+ /** One `deadline-miss` line per drag; the count lives on the aggregate. */
24565
+ scrubDeadlineLogged = false;
24566
+ /** One `oversize-segment` line per drag; the count lives on the aggregate. */
24567
+ scrubOversizeLogged = false;
24568
+ /** One `profile-degraded` line per drag — the `low` pin is lost. */
24569
+ scrubProfileLogged = false;
24570
+ /** Has this drag put a frame on the wire? Gates the first-frame deadline. */
24571
+ scrubPushedThisDrag = false;
24572
+ /** Smoothed media-ms per wall-ms of the drag, signed. Null until measurable. */
24573
+ scrubVelocity = null;
24574
+ /** Previous pointer sample, for the velocity estimate above. */
24575
+ scrubLastSampleMs = null;
24576
+ scrubLastSampleAtMs = 0;
24577
+ /** Signed run of consecutive same-direction POINTER samples. */
24578
+ scrubVelRun = 0;
24392
24579
  /** One fill walker per drag — a fling retargets it, it does not spawn another. */
24393
24580
  scrubFillLoopActive = false;
24394
24581
  scrubWarmBackMs = SCRUB_WARM_BACK_MS;
@@ -24762,12 +24949,21 @@ var RecordedFeeder = class {
24762
24949
  this.scrubCursor = null;
24763
24950
  this.scrubNoFootageLogged = false;
24764
24951
  this.scrubParkLogged = false;
24952
+ this.scrubDeadlineLogged = false;
24953
+ this.scrubOversizeLogged = false;
24954
+ this.scrubProfileLogged = false;
24955
+ this.scrubPushedThisDrag = false;
24956
+ this.scrubResolve = null;
24957
+ this.scrubVelocity = null;
24958
+ this.scrubLastSampleMs = null;
24959
+ this.scrubVelRun = 0;
24765
24960
  this.scrubOutstanding = [];
24766
24961
  this.clearScrubAckTimeout();
24767
24962
  this.scrubAckTimeoutLogged = false;
24768
24963
  this.scrubPendingHint = null;
24769
24964
  this.scrubStartedAtMs = performance.now();
24770
24965
  this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
24966
+ this.reportProfileDegradation();
24771
24967
  this.scrubDemuxMark = this.deps.demuxTally?.counts() ?? null;
24772
24968
  this.scrubLastPushLogAtMs = 0;
24773
24969
  this.scrubLastSlowLocateLogAtMs = 0;
@@ -24982,6 +25178,7 @@ var RecordedFeeder = class {
24982
25178
  */
24983
25179
  scrubTo(epochMs, hint) {
24984
25180
  if (this.disposed || !this.scrubbing) return;
25181
+ this.noteScrubSample(epochMs);
24985
25182
  this.scrubPendingEpochMs = epochMs;
24986
25183
  this.scrubPendingHint = hint ?? null;
24987
25184
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
@@ -25107,7 +25304,7 @@ var RecordedFeeder = class {
25107
25304
  return;
25108
25305
  }
25109
25306
  const target = this.scrubPendingEpochMs;
25110
- const hint = this.scrubPendingHint;
25307
+ let hint = this.scrubPendingHint;
25111
25308
  this.scrubPendingEpochMs = null;
25112
25309
  this.scrubPendingHint = null;
25113
25310
  if (target === null) {
@@ -25136,70 +25333,57 @@ var RecordedFeeder = class {
25136
25333
  }
25137
25334
  }
25138
25335
  if (!seg) {
25139
- this.scrubTickReadInFlight = true;
25140
- try {
25141
- this.maybeRecenterScrubWarm(target);
25142
- const locateStartMs = performance.now();
25143
- let loc = await this.withTimeout(this.deps.locate({
25144
- deviceId: this.deps.deviceId,
25145
- profile: this.deps.profile,
25146
- epochMs: target
25147
- }));
25148
- locateCalls += 1;
25149
- locateMs = performance.now() - locateStartMs;
25150
- this.maybeLogSlowLocate(target, locateMs);
25151
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25152
- if (loc.kind === "gap") {
25153
- const decision = resolveSnapTarget(loc, target);
25154
- if (decision.kind !== "snap") {
25155
- if (!this.scrubNoFootageLogged) {
25156
- this.scrubNoFootageLogged = true;
25157
- if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25158
- epochMs: target,
25159
- nearestMs: decision.snapMs,
25160
- distanceMs: decision.distanceMs
25161
- });
25162
- else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25163
- }
25164
- return;
25165
- }
25166
- const snapStartMs = performance.now();
25167
- const snapped = await this.withTimeout(this.deps.locate({
25168
- deviceId: this.deps.deviceId,
25169
- profile: this.deps.profile,
25170
- epochMs: decision.snapMs
25171
- }));
25172
- locateCalls += 1;
25173
- locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25174
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25175
- if (snapped.kind !== "segment") {
25176
- if (!this.scrubNoFootageLogged) {
25177
- this.scrubNoFootageLogged = true;
25178
- this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25179
- }
25180
- return;
25181
- }
25182
- this.deps.onLog?.("scrub-feeder:snapped", {
25183
- requestedMs: target,
25184
- snappedMs: decision.snapMs
25336
+ const cfg = this.deps.scrubDeadline;
25337
+ const rideWindowMs = cfg?.rideWindowMs ?? SCRUB_RIDE_WINDOW_MS;
25338
+ const inFlight = this.scrubResolve;
25339
+ let entry;
25340
+ if (inFlight !== null && (Math.abs(target - inFlight.target) <= rideWindowMs || this.scrubResolvesInFlight >= SCRUB_RESOLVE_MAX_INFLIGHT)) {
25341
+ entry = inFlight;
25342
+ this.scrubStats?.recordResolveJoin();
25343
+ } else {
25344
+ const leadMs = this.scrubLeadMs();
25345
+ if (leadMs !== 0) this.scrubStats?.recordLead(leadMs);
25346
+ const readTarget = target + leadMs;
25347
+ entry = {
25348
+ target: readTarget,
25349
+ promise: this.startScrubResolve(readTarget, gen)
25350
+ };
25351
+ this.scrubResolve = entry;
25352
+ }
25353
+ const deadlineMs = this.scrubPushedThisDrag ? cfg?.tickMs ?? SCRUB_TICK_DEADLINE_MS : cfg?.firstTickMs ?? SCRUB_FIRST_TICK_DEADLINE_MS;
25354
+ const res = await this.raceDeadline(entry.promise, performance.now() + deadlineMs);
25355
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25356
+ if (res === "deadline") {
25357
+ this.scrubStats?.recordDeadlineMiss();
25358
+ if (this.scrubPendingEpochMs === null) this.scrubPendingEpochMs = target;
25359
+ if (!this.scrubDeadlineLogged) {
25360
+ this.scrubDeadlineLogged = true;
25361
+ this.deps.onLog?.("scrub-feeder:deadline-miss", {
25362
+ epochMs: target,
25363
+ deadlineMs,
25364
+ first: !this.scrubPushedThisDrag,
25365
+ inFlight: this.scrubResolvesInFlight
25185
25366
  });
25186
- loc = snapped;
25187
- effTarget = decision.snapMs;
25188
25367
  }
25189
- seg = this.scrubCache.covering(this.deps.profile, effTarget);
25190
- if (!seg) {
25191
- const load = await this.loadScrubFragment(loc, effTarget);
25192
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
25193
- seg = load.seg;
25194
- readMs = load.readMs;
25195
- readBytes = load.readBytes;
25196
- demuxMs = load.demuxMs;
25197
- wholeSegmentRead = load.wholeSegmentRead;
25198
- this.scrubCache.put(this.deps.profile, seg);
25199
- }
25200
- } finally {
25201
- this.scrubTickReadInFlight = false;
25368
+ return;
25202
25369
  }
25370
+ if (res === null) return;
25371
+ seg = res.seg;
25372
+ const newest = this.scrubPendingEpochMs;
25373
+ if (res.effTarget !== res.requested) effTarget = res.effTarget;
25374
+ else if (newest !== null && fragmentCovers(seg, newest)) {
25375
+ effTarget = newest;
25376
+ hint = this.scrubPendingHint;
25377
+ this.scrubPendingEpochMs = null;
25378
+ this.scrubPendingHint = null;
25379
+ } else effTarget = newest ?? target;
25380
+ if (effTarget !== res.effTarget) this.scrubStats?.recordRetarget();
25381
+ locateCalls = res.locateCalls;
25382
+ locateMs = res.locateMs;
25383
+ readMs = res.readMs;
25384
+ readBytes = res.readBytes;
25385
+ demuxMs = res.demuxMs;
25386
+ wholeSegmentRead = res.wholeSegmentRead;
25203
25387
  }
25204
25388
  this.scrubSegment = seg;
25205
25389
  const prevTarget = this.scrubPrevTarget;
@@ -25266,6 +25450,7 @@ var RecordedFeeder = class {
25266
25450
  pushedAtMs
25267
25451
  }];
25268
25452
  this.scrubCredit -= 1;
25453
+ this.scrubPushedThisDrag = true;
25269
25454
  this.armScrubAckTimeout();
25270
25455
  this.clockHasAdvanced = true;
25271
25456
  let runBytes = 0;
@@ -25327,6 +25512,107 @@ var RecordedFeeder = class {
25327
25512
  }
25328
25513
  }
25329
25514
  /**
25515
+ * ONE fragment resolution for the drag: locate (with gap snapping), read,
25516
+ * demux, cache. Deliberately NOT bound to the tick that started it.
25517
+ *
25518
+ * The tick races this with the frame's deadline and may walk away; this
25519
+ * promise then finishes on its own and puts the fragment in the session
25520
+ * cache, because the read is already on the wire and abandoning its RESULT
25521
+ * as well would turn a dropped frame into wasted disk. That is what makes
25522
+ * the deadline cheap: the tick after a miss is usually a cache hit.
25523
+ *
25524
+ * Null ⇒ nothing to serve (dead zone, superseded generation, or a load the
25525
+ * seek token invalidated). Throws propagate to the tick's `tick-failed`.
25526
+ */
25527
+ async startScrubResolve(target, gen) {
25528
+ this.scrubResolvesInFlight += 1;
25529
+ this.scrubTickReadInFlight = true;
25530
+ let locateCalls = 0;
25531
+ let locateMs = null;
25532
+ try {
25533
+ this.maybeRecenterScrubWarm(target);
25534
+ const locateStartMs = performance.now();
25535
+ let loc = await this.withTimeout(this.deps.locate({
25536
+ deviceId: this.deps.deviceId,
25537
+ profile: this.deps.profile,
25538
+ epochMs: target
25539
+ }));
25540
+ locateCalls += 1;
25541
+ locateMs = performance.now() - locateStartMs;
25542
+ this.maybeLogSlowLocate(target, locateMs);
25543
+ if (this.disposed || gen !== this.scrubToken) return null;
25544
+ let effTarget = target;
25545
+ if (loc.kind === "gap") {
25546
+ const decision = resolveSnapTarget(loc, target);
25547
+ if (decision.kind !== "snap") {
25548
+ if (!this.scrubNoFootageLogged) {
25549
+ this.scrubNoFootageLogged = true;
25550
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25551
+ epochMs: target,
25552
+ nearestMs: decision.snapMs,
25553
+ distanceMs: decision.distanceMs
25554
+ });
25555
+ else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25556
+ }
25557
+ return null;
25558
+ }
25559
+ const snapStartMs = performance.now();
25560
+ const snapped = await this.withTimeout(this.deps.locate({
25561
+ deviceId: this.deps.deviceId,
25562
+ profile: this.deps.profile,
25563
+ epochMs: decision.snapMs
25564
+ }));
25565
+ locateCalls += 1;
25566
+ locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25567
+ if (this.disposed || gen !== this.scrubToken) return null;
25568
+ if (snapped.kind !== "segment") {
25569
+ if (!this.scrubNoFootageLogged) {
25570
+ this.scrubNoFootageLogged = true;
25571
+ this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25572
+ }
25573
+ return null;
25574
+ }
25575
+ this.deps.onLog?.("scrub-feeder:snapped", {
25576
+ requestedMs: target,
25577
+ snappedMs: decision.snapMs
25578
+ });
25579
+ loc = snapped;
25580
+ effTarget = decision.snapMs;
25581
+ }
25582
+ const warm = this.scrubCache.covering(this.deps.profile, effTarget);
25583
+ if (warm) return {
25584
+ seg: warm,
25585
+ requested: target,
25586
+ effTarget,
25587
+ locateCalls,
25588
+ locateMs,
25589
+ readMs: null,
25590
+ readBytes: null,
25591
+ demuxMs: null,
25592
+ wholeSegmentRead: false
25593
+ };
25594
+ const load = await this.loadScrubFragment(loc, effTarget);
25595
+ if (this.disposed || load === null) return null;
25596
+ this.scrubCache.put(this.deps.profile, load.seg);
25597
+ if (gen !== this.scrubToken) return null;
25598
+ return {
25599
+ seg: load.seg,
25600
+ requested: target,
25601
+ effTarget,
25602
+ locateCalls,
25603
+ locateMs,
25604
+ readMs: load.readMs,
25605
+ readBytes: load.readBytes,
25606
+ demuxMs: load.demuxMs,
25607
+ wholeSegmentRead: load.wholeSegmentRead
25608
+ };
25609
+ } finally {
25610
+ this.scrubResolvesInFlight -= 1;
25611
+ if (this.scrubResolvesInFlight === 0) this.scrubTickReadInFlight = false;
25612
+ if (this.scrubResolve?.target === target) this.scrubResolve = null;
25613
+ }
25614
+ }
25615
+ /**
25330
25616
  * Index of the nearest AU at-or-before the drag target within `seg`
25331
25617
  * (default 0 — the fragment's params-fused IDR, which the demux contract
25332
25618
  * guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
@@ -25419,7 +25705,20 @@ var RecordedFeeder = class {
25419
25705
  */
25420
25706
  readsWholeSegment(loc) {
25421
25707
  if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
25422
- return loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES;
25708
+ if (loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES) return true;
25709
+ this.scrubStats?.recordOversizeSegment();
25710
+ if (!this.scrubOversizeLogged) {
25711
+ this.scrubOversizeLogged = true;
25712
+ this.deps.onLog?.("scrub-feeder:oversize-segment", {
25713
+ profile: this.deps.profile,
25714
+ startMs: loc.startMs,
25715
+ durMs: loc.durMs,
25716
+ bytes: loc.bytes,
25717
+ maxBytes: SCRUB_WHOLE_SEGMENT_MAX_BYTES,
25718
+ bytesPerSecond: loc.durMs > 0 ? Math.round(loc.bytes / (loc.durMs / 1e3)) : null
25719
+ });
25720
+ }
25721
+ return false;
25423
25722
  }
25424
25723
  /**
25425
25724
  * Fold one read into the recent-read window that governs speculation. The
@@ -25430,15 +25729,84 @@ var RecordedFeeder = class {
25430
25729
  this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
25431
25730
  }
25432
25731
  /**
25433
- * Is the disk the problem right now? Median of the recent reads against
25732
+ * A drag that is NOT on the scrub profile runs with every scrub optimisation
25733
+ * off: the whole-segment read unit, the 48 MB fragment-cache budget and the
25734
+ * entry warm are all gated on the exact string `low`
25735
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}). A momentary hole in `low` therefore
25736
+ * turns a drag into 3-seek GOP reads over ~5 MB `high` segments — measured at
25737
+ * 2 642 ms cold — and the client cannot repair it.
25738
+ *
25739
+ * It used to be indistinguishable from a slow disk. Now it says so, once per
25740
+ * drag, and the census carries the flag beside the numbers it explains.
25741
+ */
25742
+ reportProfileDegradation() {
25743
+ if (this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) return;
25744
+ this.scrubStats?.recordProfileDegraded(this.deps.profile);
25745
+ if (this.scrubProfileLogged) return;
25746
+ this.scrubProfileLogged = true;
25747
+ this.deps.onLog?.("scrub-feeder:profile-degraded", {
25748
+ profile: this.deps.profile,
25749
+ scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
25750
+ });
25751
+ }
25752
+ /**
25753
+ * Median of the recent reads — what a read costs on this disk RIGHT NOW.
25754
+ * Null until there are enough samples to call it anything: a single cold
25755
+ * first read is not a median, and acting on it would aim a whole gesture.
25756
+ */
25757
+ scrubReadMedianMs() {
25758
+ if (this.scrubReadMsWindow.length < SCRUB_LEAD_MIN_READ_SAMPLES) return null;
25759
+ const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
25760
+ return sorted[Math.floor(sorted.length / 2)] ?? null;
25761
+ }
25762
+ /**
25763
+ * Is the disk the problem right now? The recent-read median against
25434
25764
  * {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
25435
25765
  * noise and the decision must not swing on it; and never before a few
25436
25766
  * samples, or a cold first read would suppress the whole drag.
25437
25767
  */
25438
25768
  scrubReadsAreSlow() {
25439
- if (this.scrubReadMsWindow.length < 3) return false;
25440
- const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
25441
- return (sorted[Math.floor(sorted.length / 2)] ?? 0) >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25769
+ const median = this.scrubReadMedianMs();
25770
+ if (median === null) return false;
25771
+ return median >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25772
+ }
25773
+ /**
25774
+ * Fold one pointer sample into the drag's media velocity (ms of timeline per
25775
+ * ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
25776
+ */
25777
+ noteScrubSample(epochMs) {
25778
+ const now = performance.now();
25779
+ const prev = this.scrubLastSampleMs;
25780
+ const prevAt = this.scrubLastSampleAtMs;
25781
+ this.scrubLastSampleMs = epochMs;
25782
+ this.scrubLastSampleAtMs = now;
25783
+ if (prev === null) return;
25784
+ const gapMs = now - prevAt;
25785
+ if (gapMs < SCRUB_VELOCITY_MIN_GAP_MS || gapMs > SCRUB_VELOCITY_MAX_GAP_MS) return;
25786
+ const deltaMs = epochMs - prev;
25787
+ const dir = Math.sign(deltaMs);
25788
+ if (dir === 0) {
25789
+ this.scrubVelRun = 0;
25790
+ return;
25791
+ }
25792
+ this.scrubVelRun = Math.sign(this.scrubVelRun) === dir ? this.scrubVelRun + dir : dir;
25793
+ const v = deltaMs / gapMs;
25794
+ this.scrubVelocity = this.scrubVelocity === null ? v : this.scrubVelocity * (1 - SCRUB_VELOCITY_EMA) + v * SCRUB_VELOCITY_EMA;
25795
+ }
25796
+ /**
25797
+ * How far AHEAD of the finger this read should aim — see the
25798
+ * {@link SCRUB_LEAD_MIN_READ_SAMPLES} docblock for why this exists and what
25799
+ * bounds it. Zero whenever the prediction is not earned: no measured reads,
25800
+ * no sustained direction, or a velocity whose sign disagrees with the run.
25801
+ */
25802
+ scrubLeadMs() {
25803
+ const velocity = this.scrubVelocity;
25804
+ if (velocity === null) return 0;
25805
+ if (Math.abs(this.scrubVelRun) < SCRUB_LEAD_MIN_DIR_RUN) return 0;
25806
+ if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
25807
+ const readMs = this.scrubReadMedianMs();
25808
+ if (readMs === null) return 0;
25809
+ return Math.round(velocity * readMs);
25442
25810
  }
25443
25811
  /**
25444
25812
  * A single slow `locate` gets its own line, throttled per drag. Reserved for
@@ -26163,6 +26531,14 @@ var RecordedFeeder = class {
26163
26531
  this.scrubCursor = null;
26164
26532
  this.scrubNoFootageLogged = false;
26165
26533
  this.scrubParkLogged = false;
26534
+ this.scrubDeadlineLogged = false;
26535
+ this.scrubOversizeLogged = false;
26536
+ this.scrubProfileLogged = false;
26537
+ this.scrubPushedThisDrag = false;
26538
+ this.scrubResolve = null;
26539
+ this.scrubVelocity = null;
26540
+ this.scrubLastSampleMs = null;
26541
+ this.scrubVelRun = 0;
26166
26542
  this.scrubOutstanding = [];
26167
26543
  this.clearScrubAckTimeout();
26168
26544
  this.scrubPendingHint = null;