@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
@@ -1,9 +1,9 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { At as makeSourceBrokerId, C as RATE_CONTROL_TIGHT, Ct as CAM_PROFILE_ORDER, E as RingBuffer, Et as createEvent, Gt as string, H as egressTranscodeSharingKey, Ht as number, It as _enum, Kt as union, Lt as array, M as batteryCapability, N as cameraStreamsCapability, Nt as parseProfileBrokerId, O as addonWidgetsSourceCapability, Ot as isEvent, P as createHwAccelCache, R as deriveBatteryPresence, Rt as boolean, S as RATE_CONTROL_RELAXED, St as BaseAddon, Tt as DeviceType, U as egressTransportFromRequest, Ut as object, Vt as literal, Wt as record, Y as maskUrlCredentials, _t as AUDIO_PRESETS, bt as invocationFromEncodeProfile, ct as resolveEgressDecodeHwAccel, gt as errMsg, h as EncodeProfileSchema, ht as webrtcSessionCapability, i as BatteryStatusSchema, jt as nodePin, kt as makeProfileBrokerId, pt as streamBrokerCapability, qt as EventCategory, vt as Fmp4BoxSplitter, wt as DeviceFeature, xt as isSoftwareDecode, yt as buildFfmpegArgs$1, zt as discriminatedUnion } from "../dist-HLAOw6Pe.mjs";
2
+ import { At as makeSourceBrokerId, C as RATE_CONTROL_TIGHT, Ct as CAM_PROFILE_ORDER, E as RingBuffer, Et as createEvent, Gt as string, H as egressTranscodeSharingKey, Ht as number, It as _enum, Kt as union, Lt as array, M as batteryCapability, N as cameraStreamsCapability, Nt as parseProfileBrokerId, O as addonWidgetsSourceCapability, Ot as isEvent, P as createHwAccelCache, R as deriveBatteryPresence, Rt as boolean, S as RATE_CONTROL_RELAXED, St as BaseAddon, Tt as DeviceType, U as egressTransportFromRequest, Ut as object, Vt as literal, Wt as record, Y as maskUrlCredentials, _t as AUDIO_PRESETS, bt as invocationFromEncodeProfile, ct as resolveEgressDecodeHwAccel, gt as errMsg, h as EncodeProfileSchema, ht as webrtcSessionCapability, i as BatteryStatusSchema, jt as nodePin, kt as makeProfileBrokerId, pt as streamBrokerCapability, qt as EventCategory, vt as Fmp4BoxSplitter, wt as DeviceFeature, xt as isSoftwareDecode, yt as buildFfmpegArgs$1, zt as discriminatedUnion } from "../dist-CMio-1qq.mjs";
3
3
  import { t as parseRestreamPath } from "../restream-intent-B4BXZra7.mjs";
4
4
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
6
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-D-TLcHW5.mjs";
6
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-BRCjXbI0.mjs";
7
7
  import { createRequire } from "node:module";
8
8
  import * as crypto$1 from "node:crypto";
9
9
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -385,6 +385,83 @@ var STREAM_BROKER_RETIRED_ROOT_KEYS = [
385
385
  }
386
386
  ];
387
387
  //#endregion
388
+ //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
389
+ /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
390
+ function logBannerArgs(level) {
391
+ return [
392
+ "-hide_banner",
393
+ "-loglevel",
394
+ level
395
+ ];
396
+ }
397
+ //#endregion
398
+ //#region src/shared/ffmpeg-decode-args.ts
399
+ /**
400
+ * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
401
+ *
402
+ * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
403
+ * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
404
+ * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
405
+ * `qsv` first on Intel, so we re-rank its result through this table before
406
+ * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
407
+ * trail for NVIDIA. A method not listed here keeps its incoming relative order,
408
+ * placed AFTER every ranked one.
409
+ */
410
+ var DECODE_HWACCEL_RANK = [
411
+ "videotoolbox",
412
+ "vaapi",
413
+ "qsv",
414
+ "cuda",
415
+ "nvdec",
416
+ "d3d11va",
417
+ "dxva2",
418
+ "amf",
419
+ "vdpau",
420
+ "drm"
421
+ ];
422
+ /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
423
+ function decodeHwAccelRankIndex(method) {
424
+ const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
425
+ return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
426
+ }
427
+ /**
428
+ * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
429
+ * `'auto'` decode session, best-first. The session walks this chain on an
430
+ * early-exit-with-no-frames and only falls to software once it is exhausted.
431
+ *
432
+ * Strategy:
433
+ * - Seed candidates from the kernel-preferred order UNION the canonical rank
434
+ * UNION the build's supported list, so a method the kernel omitted but the
435
+ * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
436
+ * still attempted.
437
+ * - When the build's supported-method list is known (non-empty), keep only
438
+ * methods it actually offers; on a probe miss (empty) keep just the kernel
439
+ * preferences (avoid blindly spawning every backend).
440
+ * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
441
+ * `qsv`. A supported method not in the rank table keeps its position AFTER
442
+ * every ranked one (stable sort). Returns lowercased method names; empty ⇒
443
+ * pure software.
444
+ */
445
+ function rankDecodeHwAccels(preferred, supportedMethods) {
446
+ const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
447
+ const hasSupportList = supported.size > 0;
448
+ const pool = hasSupportList ? [
449
+ ...preferred,
450
+ ...DECODE_HWACCEL_RANK,
451
+ ...supportedMethods
452
+ ] : [...preferred];
453
+ const seen = /* @__PURE__ */ new Set();
454
+ const candidates = [];
455
+ for (const raw of pool) {
456
+ const method = raw.toLowerCase();
457
+ if (seen.has(method)) continue;
458
+ seen.add(method);
459
+ if (hasSupportList && !supported.has(method)) continue;
460
+ candidates.push(method);
461
+ }
462
+ return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
463
+ }
464
+ //#endregion
388
465
  //#region src/stream-broker/battery-wake-on-play.ts
389
466
  /**
390
467
  * Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
@@ -587,83 +664,6 @@ async function wakeBatteryDeviceForPlay(deviceId, deps) {
587
664
  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");
588
665
  }
589
666
  //#endregion
590
- //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
591
- /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
592
- function logBannerArgs(level) {
593
- return [
594
- "-hide_banner",
595
- "-loglevel",
596
- level
597
- ];
598
- }
599
- //#endregion
600
- //#region src/shared/ffmpeg-decode-args.ts
601
- /**
602
- * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
603
- *
604
- * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
605
- * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
606
- * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
607
- * `qsv` first on Intel, so we re-rank its result through this table before
608
- * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
609
- * trail for NVIDIA. A method not listed here keeps its incoming relative order,
610
- * placed AFTER every ranked one.
611
- */
612
- var DECODE_HWACCEL_RANK = [
613
- "videotoolbox",
614
- "vaapi",
615
- "qsv",
616
- "cuda",
617
- "nvdec",
618
- "d3d11va",
619
- "dxva2",
620
- "amf",
621
- "vdpau",
622
- "drm"
623
- ];
624
- /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
625
- function decodeHwAccelRankIndex(method) {
626
- const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
627
- return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
628
- }
629
- /**
630
- * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
631
- * `'auto'` decode session, best-first. The session walks this chain on an
632
- * early-exit-with-no-frames and only falls to software once it is exhausted.
633
- *
634
- * Strategy:
635
- * - Seed candidates from the kernel-preferred order UNION the canonical rank
636
- * UNION the build's supported list, so a method the kernel omitted but the
637
- * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
638
- * still attempted.
639
- * - When the build's supported-method list is known (non-empty), keep only
640
- * methods it actually offers; on a probe miss (empty) keep just the kernel
641
- * preferences (avoid blindly spawning every backend).
642
- * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
643
- * `qsv`. A supported method not in the rank table keeps its position AFTER
644
- * every ranked one (stable sort). Returns lowercased method names; empty ⇒
645
- * pure software.
646
- */
647
- function rankDecodeHwAccels(preferred, supportedMethods) {
648
- const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
649
- const hasSupportList = supported.size > 0;
650
- const pool = hasSupportList ? [
651
- ...preferred,
652
- ...DECODE_HWACCEL_RANK,
653
- ...supportedMethods
654
- ] : [...preferred];
655
- const seen = /* @__PURE__ */ new Set();
656
- const candidates = [];
657
- for (const raw of pool) {
658
- const method = raw.toLowerCase();
659
- if (seen.has(method)) continue;
660
- seen.add(method);
661
- if (hasSupportList && !supported.has(method)) continue;
662
- candidates.push(method);
663
- }
664
- return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
665
- }
666
- //#endregion
667
667
  //#region src/stream-broker/derived/cycle-check.ts
668
668
  /**
669
669
  * Would assigning `derivedCamStreamId` to `targetProfile` create a cycle?
@@ -16274,7 +16274,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16274
16274
  }
16275
16275
  let devices;
16276
16276
  try {
16277
- devices = await api.deviceManager.listAll.query({});
16277
+ devices = await api.deviceManager.listAll.query({ projection: "slim" });
16278
16278
  } catch (err) {
16279
16279
  this.logger.warn("profile-map prune skipped — deviceManager.listAll failed", { meta: {
16280
16280
  persistedCount,
@@ -16599,7 +16599,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16599
16599
  if (!this.api) return;
16600
16600
  let devices;
16601
16601
  try {
16602
- devices = await this.api.deviceManager.listAll.query({});
16602
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16603
16603
  } catch (err) {
16604
16604
  this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", { meta: { error: errMsg(err) } });
16605
16605
  return;
@@ -16739,7 +16739,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16739
16739
  if (!this.api) return false;
16740
16740
  let devices;
16741
16741
  try {
16742
- devices = await this.api.deviceManager.listAll.query({});
16742
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16743
16743
  } catch {
16744
16744
  return false;
16745
16745
  }
@@ -23792,6 +23792,9 @@ var ScrubDragStats = class {
23792
23792
  prefetchHits = 0;
23793
23793
  prefetchSuppressed = 0;
23794
23794
  ackTimeouts = 0;
23795
+ deadlineMisses = 0;
23796
+ retargets = 0;
23797
+ resolveJoins = 0;
23795
23798
  failures = 0;
23796
23799
  firstPushMs = null;
23797
23800
  creditEndedAt = null;
@@ -23801,6 +23804,9 @@ var ScrubDragStats = class {
23801
23804
  demuxMs = new PhaseSamples();
23802
23805
  tickMs = new PhaseSamples();
23803
23806
  ackMs = new PhaseSamples();
23807
+ leadMs = new PhaseSamples();
23808
+ oversizeSegments = 0;
23809
+ degradedProfile = null;
23804
23810
  constructor(startedAtMs) {
23805
23811
  this.startedAtMs = startedAtMs;
23806
23812
  }
@@ -23855,6 +23861,30 @@ var ScrubDragStats = class {
23855
23861
  recordAckTimeout(pushes) {
23856
23862
  this.ackTimeouts += pushes;
23857
23863
  }
23864
+ /** A frame abandoned on its deadline — dropped work, counted every time. */
23865
+ recordDeadlineMiss() {
23866
+ this.deadlineMisses += 1;
23867
+ }
23868
+ /** A landing re-aimed at the newer finger position it also covered. */
23869
+ recordRetarget() {
23870
+ this.retargets += 1;
23871
+ }
23872
+ /** A whole-segment read that had to degrade to a GOP range on size. */
23873
+ recordOversizeSegment() {
23874
+ this.oversizeSegments += 1;
23875
+ }
23876
+ /** This drag is not running on the scrub profile — every gate is off. */
23877
+ recordProfileDegraded(profile) {
23878
+ this.degradedProfile = profile;
23879
+ }
23880
+ /** One read aimed ahead of the finger, and by how much. */
23881
+ recordLead(leadMs) {
23882
+ this.leadMs.add(Math.abs(leadMs));
23883
+ }
23884
+ /** A tick served by the read another tick already had in flight. */
23885
+ recordResolveJoin() {
23886
+ this.resolveJoins += 1;
23887
+ }
23858
23888
  /** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
23859
23889
  recordFailure() {
23860
23890
  this.failures += 1;
@@ -23870,6 +23900,7 @@ var ScrubDragStats = class {
23870
23900
  const dmx = this.demuxMs.summary();
23871
23901
  const tick = this.tickMs.summary();
23872
23902
  const ack = this.ackMs.summary();
23903
+ const lead = this.leadMs.summary();
23873
23904
  return {
23874
23905
  ticks: this.ticks,
23875
23906
  dragMs: Math.round(dragMs),
@@ -23909,6 +23940,13 @@ var ScrubDragStats = class {
23909
23940
  prefetchHits: this.prefetchHits,
23910
23941
  prefetchSuppressed: this.prefetchSuppressed,
23911
23942
  ackTimeouts: this.ackTimeouts,
23943
+ deadlineMisses: this.deadlineMisses,
23944
+ retargets: this.retargets,
23945
+ resolveJoins: this.resolveJoins,
23946
+ leadMsP50: lead.p50Ms,
23947
+ leadMsMax: lead.maxMs,
23948
+ oversizeSegments: this.oversizeSegments,
23949
+ degradedProfile: this.degradedProfile,
23912
23950
  failures: this.failures,
23913
23951
  creditEndedAt: this.creditEndedAt,
23914
23952
  acks: ack.n,
@@ -24063,11 +24101,29 @@ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24063
24101
  */
24064
24102
  var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
24065
24103
  /**
24066
- * Safety ceiling on the whole-segment read. `low` is ~230 KB per 10 s, so this
24067
- * is ~6× headroom for a camera whose substream is unusually fat — but a
24068
- * segment past it (a mis-pinned `high` at ~5 MB) falls back to the GOP range,
24069
- * because reading 5 MB to push one keyframe is the trade this change exists to
24070
- * avoid, in the other direction.
24104
+ * Safety ceiling on ONE scrub read, in bytes.
24105
+ *
24106
+ * `low` is ~230 KB per 10 s, so this is ~6× headroom for a camera whose
24107
+ * substream is unusually fat but a segment past it (a mis-pinned `high` at
24108
+ * ~5 MB) falls back to the GOP range, because reading 5 MB to push one
24109
+ * keyframe is the trade this change exists to avoid, in the other direction.
24110
+ *
24111
+ * ⚠️ THIS NUMBER IS TIED TO THE SEGMENT DURATION AND THE TIE IS NOT ENFORCED.
24112
+ * It was tuned against a 10-second `low` segment. At the 60-second segments
24113
+ * the 2026-08-25 teardown proposes, a NATIVE `low` is ~1.4 MB (just inside,
24114
+ * and the whole-segment read gets six times better — one seek leaves a minute
24115
+ * of access units behind) while a DERIVED `low` is ~4.8 MB (outside, and every
24116
+ * scrub read silently degrades to a 3-seek GOP range that leaves nothing
24117
+ * behind). Same constant, opposite outcomes, no signal either way — which is
24118
+ * why the degradation is now COUNTED and LOGGED
24119
+ * (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
24120
+ * instead of being inferred from a falling hit rate.
24121
+ *
24122
+ * The real fix, when 60 s segments land, is a read unit that is a BYTE BUDGET
24123
+ * over the fragment's own keyframe table — "as many consecutive GOPs as fit in
24124
+ * N bytes" — which needs a provider method we do not have
24125
+ * (`readGopBytes` reads exactly one GOP). Until then this is an absolute cap
24126
+ * on a single read and the log is the guard.
24071
24127
  */
24072
24128
  var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
24073
24129
  /**
@@ -24128,6 +24184,115 @@ var SCRUB_SLOW_READ_MS = 600;
24128
24184
  */
24129
24185
  var SCRUB_SWEEP_WINDOW_MS = 4e3;
24130
24186
  /**
24187
+ * THE DEADLINE ON ONE SCRUB FRAME — and the reason it is not
24188
+ * {@link SEGMENT_TIMEOUT_MS}.
24189
+ *
24190
+ * A scrub tick used to be bounded only by the 8 s per-RPC timeout, against a
24191
+ * cold read measured at p50 531 ms, p95 1.4–2 s and a 9.2 s max (census
24192
+ * 2026-08-25). So the feeder WAITED. The frame it eventually pushed was for a
24193
+ * finger position from seconds earlier, and the gesture spent its whole
24194
+ * duration on a handful of stale frames — the operator's "quasi 0 frame, e
24195
+ * l'immagine insegue il dito".
24196
+ *
24197
+ * The reference design (Scrypted NVR, teardown 2026-08-25) spends at most
24198
+ * ~1000 ms on the WHOLE scrub animation and gives every single frame the
24199
+ * REMAINING window as its deadline: a frame that cannot be produced in time is
24200
+ * dropped, never awaited. This is the same contract on our constraints.
24201
+ *
24202
+ * 700 ms and not 1000: the deadline must be shorter than the gesture's own
24203
+ * animation window or it can never fire twice inside one, and it must be above
24204
+ * the cold-read p50 (531 ms) or the median frame would be dropped — a deadline
24205
+ * that drops the median makes the picture WORSE, not fresher. 700 ms sits
24206
+ * between: it serves the p50–p60 of cold reads and cuts the p90 tail that used
24207
+ * to eat the gesture.
24208
+ *
24209
+ * Dropping is never silent: `deadlineMisses` on the drag aggregate plus one
24210
+ * `scrub-feeder:deadline-miss` line per drag.
24211
+ */
24212
+ var SCRUB_TICK_DEADLINE_MS = 700;
24213
+ /**
24214
+ * The FIRST frame of a drag gets a longer deadline, because nothing is painted
24215
+ * yet.
24216
+ *
24217
+ * Every later frame has a predecessor on screen, so dropping it costs
24218
+ * freshness. The first one has none: dropping it costs the whole picture, and
24219
+ * the operator reads a blank recorded view as "playback parte dopo una vita".
24220
+ * The reference design has the same asymmetry for the same reason — its
24221
+ * animation frames are deadline-dropped while the LANDING seek is unbounded.
24222
+ *
24223
+ * 2500 ms covers the cold-read p95 (1.4–2 s) and still cuts the 9.2 s tail.
24224
+ */
24225
+ var SCRUB_FIRST_TICK_DEADLINE_MS = 2500;
24226
+ /**
24227
+ * How far a new target may be from an in-flight read's target and still RIDE
24228
+ * it instead of starting a second one.
24229
+ *
24230
+ * A `low` segment is ~10 s of timeline and the read unit is the whole segment
24231
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}), so a target within one segment of the
24232
+ * one being read is very likely already inside the bytes on their way. Riding
24233
+ * costs nothing and saves a random seek on the spindle that is the bottleneck;
24234
+ * being wrong costs one wasted deadline, which is now cheap by construction.
24235
+ */
24236
+ var SCRUB_RIDE_WINDOW_MS = 1e4;
24237
+ /**
24238
+ * Hard bound on concurrent fragment resolutions for one drag.
24239
+ *
24240
+ * A deadline that drops frames is only safe if dropping does not multiply the
24241
+ * work: without this bound a drag over slow footage would abandon a read every
24242
+ * {@link SCRUB_TICK_DEADLINE_MS} and start another, turning one saturated
24243
+ * spindle into N. Two: the one that is landing, and the one the finger has
24244
+ * moved on to. At the cap, a tick rides the newest in-flight read instead.
24245
+ */
24246
+ var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
24247
+ /**
24248
+ * READ-AHEAD LEAD — the fix for "the picture chases the finger".
24249
+ *
24250
+ * A scrub frame is always the frame a read STARTED for, one read time ago.
24251
+ * On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
24252
+ * a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
24253
+ * the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
24254
+ * behind the finger. Nothing about that is I/O — it is aiming at where the
24255
+ * finger WAS.
24256
+ *
24257
+ * So a steady drag reads where the finger WILL BE: `mediaVelocity ×
24258
+ * medianRecentReadMs`. It costs nothing (the same one read, at a different
24259
+ * offset), and it is bounded by the two things that make it a prediction
24260
+ * rather than a guess:
24261
+ *
24262
+ * - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
24263
+ * samples, so a jittery or reversing drag never leads. Pointer samples and
24264
+ * not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
24265
+ * ~4 pushes, so a gate counted in pushes has no runway inside the gesture
24266
+ * it is supposed to help — measured in the drag bench, it engaged once, at
24267
+ * the very end;
24268
+ * - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
24269
+ * is derived from this disk right now and not from a constant. ONE is
24270
+ * enough: against a read cost that is unknown, a one-sample estimate is not
24271
+ * a good prediction — it is simply a far better one than assuming zero.
24272
+ *
24273
+ * Overshoot is self-correcting: the retarget below serves the newest target
24274
+ * the landed fragment covers, so a lead that is slightly long lands on the
24275
+ * finger exactly, and one that is wrong costs the same single read it would
24276
+ * have cost anyway.
24277
+ */
24278
+ var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
24279
+ /**
24280
+ * Smoothing for the media-velocity estimate (ms of timeline per ms of wall
24281
+ * clock), folded on every pointer sample. Half-and-half: fast enough to track
24282
+ * a fling that starts mid-gesture, slow enough that one jittery sample cannot
24283
+ * aim a read an hour away.
24284
+ */
24285
+ var SCRUB_VELOCITY_EMA = .5;
24286
+ /** Same-direction POINTER samples required before a read may aim ahead. */
24287
+ var SCRUB_LEAD_MIN_DIR_RUN = 2;
24288
+ /**
24289
+ * Pointer-sample gaps outside this range do not update the velocity estimate:
24290
+ * below it the delta is dominated by timer jitter, above it the finger has
24291
+ * effectively paused and the "velocity" is an artefact of the pause.
24292
+ */
24293
+ var SCRUB_VELOCITY_MIN_GAP_MS = 10;
24294
+ var SCRUB_VELOCITY_MAX_GAP_MS = 500;
24295
+ /**
24131
24296
  * Receiver-credit cap for scrub pushes: at most this many PUSHES may be
24132
24297
  * OUTSTANDING (on the track, not yet acknowledged by the viewer via
24133
24298
  * `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
@@ -24384,6 +24549,28 @@ var RecordedFeeder = class {
24384
24549
  scrubWarmGen = 0;
24385
24550
  /** Tick demand-read in flight: the fill waits so the finger wins the spindle. */
24386
24551
  scrubTickReadInFlight = false;
24552
+ /**
24553
+ * The newest fragment resolution in flight for this drag — what a later tick
24554
+ * rides instead of issuing its own read. Cleared by the resolution itself.
24555
+ */
24556
+ scrubResolve = null;
24557
+ /** Concurrent resolutions, bounded by {@link SCRUB_RESOLVE_MAX_INFLIGHT}. */
24558
+ scrubResolvesInFlight = 0;
24559
+ /** One `deadline-miss` line per drag; the count lives on the aggregate. */
24560
+ scrubDeadlineLogged = false;
24561
+ /** One `oversize-segment` line per drag; the count lives on the aggregate. */
24562
+ scrubOversizeLogged = false;
24563
+ /** One `profile-degraded` line per drag — the `low` pin is lost. */
24564
+ scrubProfileLogged = false;
24565
+ /** Has this drag put a frame on the wire? Gates the first-frame deadline. */
24566
+ scrubPushedThisDrag = false;
24567
+ /** Smoothed media-ms per wall-ms of the drag, signed. Null until measurable. */
24568
+ scrubVelocity = null;
24569
+ /** Previous pointer sample, for the velocity estimate above. */
24570
+ scrubLastSampleMs = null;
24571
+ scrubLastSampleAtMs = 0;
24572
+ /** Signed run of consecutive same-direction POINTER samples. */
24573
+ scrubVelRun = 0;
24387
24574
  /** One fill walker per drag — a fling retargets it, it does not spawn another. */
24388
24575
  scrubFillLoopActive = false;
24389
24576
  scrubWarmBackMs = SCRUB_WARM_BACK_MS;
@@ -24757,12 +24944,21 @@ var RecordedFeeder = class {
24757
24944
  this.scrubCursor = null;
24758
24945
  this.scrubNoFootageLogged = false;
24759
24946
  this.scrubParkLogged = false;
24947
+ this.scrubDeadlineLogged = false;
24948
+ this.scrubOversizeLogged = false;
24949
+ this.scrubProfileLogged = false;
24950
+ this.scrubPushedThisDrag = false;
24951
+ this.scrubResolve = null;
24952
+ this.scrubVelocity = null;
24953
+ this.scrubLastSampleMs = null;
24954
+ this.scrubVelRun = 0;
24760
24955
  this.scrubOutstanding = [];
24761
24956
  this.clearScrubAckTimeout();
24762
24957
  this.scrubAckTimeoutLogged = false;
24763
24958
  this.scrubPendingHint = null;
24764
24959
  this.scrubStartedAtMs = performance.now();
24765
24960
  this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
24961
+ this.reportProfileDegradation();
24766
24962
  this.scrubDemuxMark = this.deps.demuxTally?.counts() ?? null;
24767
24963
  this.scrubLastPushLogAtMs = 0;
24768
24964
  this.scrubLastSlowLocateLogAtMs = 0;
@@ -24977,6 +25173,7 @@ var RecordedFeeder = class {
24977
25173
  */
24978
25174
  scrubTo(epochMs, hint) {
24979
25175
  if (this.disposed || !this.scrubbing) return;
25176
+ this.noteScrubSample(epochMs);
24980
25177
  this.scrubPendingEpochMs = epochMs;
24981
25178
  this.scrubPendingHint = hint ?? null;
24982
25179
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
@@ -25102,7 +25299,7 @@ var RecordedFeeder = class {
25102
25299
  return;
25103
25300
  }
25104
25301
  const target = this.scrubPendingEpochMs;
25105
- const hint = this.scrubPendingHint;
25302
+ let hint = this.scrubPendingHint;
25106
25303
  this.scrubPendingEpochMs = null;
25107
25304
  this.scrubPendingHint = null;
25108
25305
  if (target === null) {
@@ -25131,70 +25328,57 @@ var RecordedFeeder = class {
25131
25328
  }
25132
25329
  }
25133
25330
  if (!seg) {
25134
- this.scrubTickReadInFlight = true;
25135
- try {
25136
- this.maybeRecenterScrubWarm(target);
25137
- const locateStartMs = performance.now();
25138
- let loc = await this.withTimeout(this.deps.locate({
25139
- deviceId: this.deps.deviceId,
25140
- profile: this.deps.profile,
25141
- epochMs: target
25142
- }));
25143
- locateCalls += 1;
25144
- locateMs = performance.now() - locateStartMs;
25145
- this.maybeLogSlowLocate(target, locateMs);
25146
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25147
- if (loc.kind === "gap") {
25148
- const decision = resolveSnapTarget(loc, target);
25149
- if (decision.kind !== "snap") {
25150
- if (!this.scrubNoFootageLogged) {
25151
- this.scrubNoFootageLogged = true;
25152
- if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25153
- epochMs: target,
25154
- nearestMs: decision.snapMs,
25155
- distanceMs: decision.distanceMs
25156
- });
25157
- else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25158
- }
25159
- return;
25160
- }
25161
- const snapStartMs = performance.now();
25162
- const snapped = await this.withTimeout(this.deps.locate({
25163
- deviceId: this.deps.deviceId,
25164
- profile: this.deps.profile,
25165
- epochMs: decision.snapMs
25166
- }));
25167
- locateCalls += 1;
25168
- locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25169
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25170
- if (snapped.kind !== "segment") {
25171
- if (!this.scrubNoFootageLogged) {
25172
- this.scrubNoFootageLogged = true;
25173
- this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25174
- }
25175
- return;
25176
- }
25177
- this.deps.onLog?.("scrub-feeder:snapped", {
25178
- requestedMs: target,
25179
- snappedMs: decision.snapMs
25331
+ const cfg = this.deps.scrubDeadline;
25332
+ const rideWindowMs = cfg?.rideWindowMs ?? SCRUB_RIDE_WINDOW_MS;
25333
+ const inFlight = this.scrubResolve;
25334
+ let entry;
25335
+ if (inFlight !== null && (Math.abs(target - inFlight.target) <= rideWindowMs || this.scrubResolvesInFlight >= SCRUB_RESOLVE_MAX_INFLIGHT)) {
25336
+ entry = inFlight;
25337
+ this.scrubStats?.recordResolveJoin();
25338
+ } else {
25339
+ const leadMs = this.scrubLeadMs();
25340
+ if (leadMs !== 0) this.scrubStats?.recordLead(leadMs);
25341
+ const readTarget = target + leadMs;
25342
+ entry = {
25343
+ target: readTarget,
25344
+ promise: this.startScrubResolve(readTarget, gen)
25345
+ };
25346
+ this.scrubResolve = entry;
25347
+ }
25348
+ const deadlineMs = this.scrubPushedThisDrag ? cfg?.tickMs ?? SCRUB_TICK_DEADLINE_MS : cfg?.firstTickMs ?? SCRUB_FIRST_TICK_DEADLINE_MS;
25349
+ const res = await this.raceDeadline(entry.promise, performance.now() + deadlineMs);
25350
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25351
+ if (res === "deadline") {
25352
+ this.scrubStats?.recordDeadlineMiss();
25353
+ if (this.scrubPendingEpochMs === null) this.scrubPendingEpochMs = target;
25354
+ if (!this.scrubDeadlineLogged) {
25355
+ this.scrubDeadlineLogged = true;
25356
+ this.deps.onLog?.("scrub-feeder:deadline-miss", {
25357
+ epochMs: target,
25358
+ deadlineMs,
25359
+ first: !this.scrubPushedThisDrag,
25360
+ inFlight: this.scrubResolvesInFlight
25180
25361
  });
25181
- loc = snapped;
25182
- effTarget = decision.snapMs;
25183
25362
  }
25184
- seg = this.scrubCache.covering(this.deps.profile, effTarget);
25185
- if (!seg) {
25186
- const load = await this.loadScrubFragment(loc, effTarget);
25187
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
25188
- seg = load.seg;
25189
- readMs = load.readMs;
25190
- readBytes = load.readBytes;
25191
- demuxMs = load.demuxMs;
25192
- wholeSegmentRead = load.wholeSegmentRead;
25193
- this.scrubCache.put(this.deps.profile, seg);
25194
- }
25195
- } finally {
25196
- this.scrubTickReadInFlight = false;
25363
+ return;
25197
25364
  }
25365
+ if (res === null) return;
25366
+ seg = res.seg;
25367
+ const newest = this.scrubPendingEpochMs;
25368
+ if (res.effTarget !== res.requested) effTarget = res.effTarget;
25369
+ else if (newest !== null && fragmentCovers(seg, newest)) {
25370
+ effTarget = newest;
25371
+ hint = this.scrubPendingHint;
25372
+ this.scrubPendingEpochMs = null;
25373
+ this.scrubPendingHint = null;
25374
+ } else effTarget = newest ?? target;
25375
+ if (effTarget !== res.effTarget) this.scrubStats?.recordRetarget();
25376
+ locateCalls = res.locateCalls;
25377
+ locateMs = res.locateMs;
25378
+ readMs = res.readMs;
25379
+ readBytes = res.readBytes;
25380
+ demuxMs = res.demuxMs;
25381
+ wholeSegmentRead = res.wholeSegmentRead;
25198
25382
  }
25199
25383
  this.scrubSegment = seg;
25200
25384
  const prevTarget = this.scrubPrevTarget;
@@ -25261,6 +25445,7 @@ var RecordedFeeder = class {
25261
25445
  pushedAtMs
25262
25446
  }];
25263
25447
  this.scrubCredit -= 1;
25448
+ this.scrubPushedThisDrag = true;
25264
25449
  this.armScrubAckTimeout();
25265
25450
  this.clockHasAdvanced = true;
25266
25451
  let runBytes = 0;
@@ -25322,6 +25507,107 @@ var RecordedFeeder = class {
25322
25507
  }
25323
25508
  }
25324
25509
  /**
25510
+ * ONE fragment resolution for the drag: locate (with gap snapping), read,
25511
+ * demux, cache. Deliberately NOT bound to the tick that started it.
25512
+ *
25513
+ * The tick races this with the frame's deadline and may walk away; this
25514
+ * promise then finishes on its own and puts the fragment in the session
25515
+ * cache, because the read is already on the wire and abandoning its RESULT
25516
+ * as well would turn a dropped frame into wasted disk. That is what makes
25517
+ * the deadline cheap: the tick after a miss is usually a cache hit.
25518
+ *
25519
+ * Null ⇒ nothing to serve (dead zone, superseded generation, or a load the
25520
+ * seek token invalidated). Throws propagate to the tick's `tick-failed`.
25521
+ */
25522
+ async startScrubResolve(target, gen) {
25523
+ this.scrubResolvesInFlight += 1;
25524
+ this.scrubTickReadInFlight = true;
25525
+ let locateCalls = 0;
25526
+ let locateMs = null;
25527
+ try {
25528
+ this.maybeRecenterScrubWarm(target);
25529
+ const locateStartMs = performance.now();
25530
+ let loc = await this.withTimeout(this.deps.locate({
25531
+ deviceId: this.deps.deviceId,
25532
+ profile: this.deps.profile,
25533
+ epochMs: target
25534
+ }));
25535
+ locateCalls += 1;
25536
+ locateMs = performance.now() - locateStartMs;
25537
+ this.maybeLogSlowLocate(target, locateMs);
25538
+ if (this.disposed || gen !== this.scrubToken) return null;
25539
+ let effTarget = target;
25540
+ if (loc.kind === "gap") {
25541
+ const decision = resolveSnapTarget(loc, target);
25542
+ if (decision.kind !== "snap") {
25543
+ if (!this.scrubNoFootageLogged) {
25544
+ this.scrubNoFootageLogged = true;
25545
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25546
+ epochMs: target,
25547
+ nearestMs: decision.snapMs,
25548
+ distanceMs: decision.distanceMs
25549
+ });
25550
+ else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25551
+ }
25552
+ return null;
25553
+ }
25554
+ const snapStartMs = performance.now();
25555
+ const snapped = await this.withTimeout(this.deps.locate({
25556
+ deviceId: this.deps.deviceId,
25557
+ profile: this.deps.profile,
25558
+ epochMs: decision.snapMs
25559
+ }));
25560
+ locateCalls += 1;
25561
+ locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25562
+ if (this.disposed || gen !== this.scrubToken) return null;
25563
+ if (snapped.kind !== "segment") {
25564
+ if (!this.scrubNoFootageLogged) {
25565
+ this.scrubNoFootageLogged = true;
25566
+ this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25567
+ }
25568
+ return null;
25569
+ }
25570
+ this.deps.onLog?.("scrub-feeder:snapped", {
25571
+ requestedMs: target,
25572
+ snappedMs: decision.snapMs
25573
+ });
25574
+ loc = snapped;
25575
+ effTarget = decision.snapMs;
25576
+ }
25577
+ const warm = this.scrubCache.covering(this.deps.profile, effTarget);
25578
+ if (warm) return {
25579
+ seg: warm,
25580
+ requested: target,
25581
+ effTarget,
25582
+ locateCalls,
25583
+ locateMs,
25584
+ readMs: null,
25585
+ readBytes: null,
25586
+ demuxMs: null,
25587
+ wholeSegmentRead: false
25588
+ };
25589
+ const load = await this.loadScrubFragment(loc, effTarget);
25590
+ if (this.disposed || load === null) return null;
25591
+ this.scrubCache.put(this.deps.profile, load.seg);
25592
+ if (gen !== this.scrubToken) return null;
25593
+ return {
25594
+ seg: load.seg,
25595
+ requested: target,
25596
+ effTarget,
25597
+ locateCalls,
25598
+ locateMs,
25599
+ readMs: load.readMs,
25600
+ readBytes: load.readBytes,
25601
+ demuxMs: load.demuxMs,
25602
+ wholeSegmentRead: load.wholeSegmentRead
25603
+ };
25604
+ } finally {
25605
+ this.scrubResolvesInFlight -= 1;
25606
+ if (this.scrubResolvesInFlight === 0) this.scrubTickReadInFlight = false;
25607
+ if (this.scrubResolve?.target === target) this.scrubResolve = null;
25608
+ }
25609
+ }
25610
+ /**
25325
25611
  * Index of the nearest AU at-or-before the drag target within `seg`
25326
25612
  * (default 0 — the fragment's params-fused IDR, which the demux contract
25327
25613
  * guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
@@ -25414,7 +25700,20 @@ var RecordedFeeder = class {
25414
25700
  */
25415
25701
  readsWholeSegment(loc) {
25416
25702
  if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
25417
- return loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES;
25703
+ if (loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES) return true;
25704
+ this.scrubStats?.recordOversizeSegment();
25705
+ if (!this.scrubOversizeLogged) {
25706
+ this.scrubOversizeLogged = true;
25707
+ this.deps.onLog?.("scrub-feeder:oversize-segment", {
25708
+ profile: this.deps.profile,
25709
+ startMs: loc.startMs,
25710
+ durMs: loc.durMs,
25711
+ bytes: loc.bytes,
25712
+ maxBytes: SCRUB_WHOLE_SEGMENT_MAX_BYTES,
25713
+ bytesPerSecond: loc.durMs > 0 ? Math.round(loc.bytes / (loc.durMs / 1e3)) : null
25714
+ });
25715
+ }
25716
+ return false;
25418
25717
  }
25419
25718
  /**
25420
25719
  * Fold one read into the recent-read window that governs speculation. The
@@ -25425,15 +25724,84 @@ var RecordedFeeder = class {
25425
25724
  this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
25426
25725
  }
25427
25726
  /**
25428
- * Is the disk the problem right now? Median of the recent reads against
25727
+ * A drag that is NOT on the scrub profile runs with every scrub optimisation
25728
+ * off: the whole-segment read unit, the 48 MB fragment-cache budget and the
25729
+ * entry warm are all gated on the exact string `low`
25730
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}). A momentary hole in `low` therefore
25731
+ * turns a drag into 3-seek GOP reads over ~5 MB `high` segments — measured at
25732
+ * 2 642 ms cold — and the client cannot repair it.
25733
+ *
25734
+ * It used to be indistinguishable from a slow disk. Now it says so, once per
25735
+ * drag, and the census carries the flag beside the numbers it explains.
25736
+ */
25737
+ reportProfileDegradation() {
25738
+ if (this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) return;
25739
+ this.scrubStats?.recordProfileDegraded(this.deps.profile);
25740
+ if (this.scrubProfileLogged) return;
25741
+ this.scrubProfileLogged = true;
25742
+ this.deps.onLog?.("scrub-feeder:profile-degraded", {
25743
+ profile: this.deps.profile,
25744
+ scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
25745
+ });
25746
+ }
25747
+ /**
25748
+ * Median of the recent reads — what a read costs on this disk RIGHT NOW.
25749
+ * Null until there are enough samples to call it anything: a single cold
25750
+ * first read is not a median, and acting on it would aim a whole gesture.
25751
+ */
25752
+ scrubReadMedianMs() {
25753
+ if (this.scrubReadMsWindow.length < SCRUB_LEAD_MIN_READ_SAMPLES) return null;
25754
+ const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
25755
+ return sorted[Math.floor(sorted.length / 2)] ?? null;
25756
+ }
25757
+ /**
25758
+ * Is the disk the problem right now? The recent-read median against
25429
25759
  * {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
25430
25760
  * noise and the decision must not swing on it; and never before a few
25431
25761
  * samples, or a cold first read would suppress the whole drag.
25432
25762
  */
25433
25763
  scrubReadsAreSlow() {
25434
- if (this.scrubReadMsWindow.length < 3) return false;
25435
- const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
25436
- return (sorted[Math.floor(sorted.length / 2)] ?? 0) >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25764
+ const median = this.scrubReadMedianMs();
25765
+ if (median === null) return false;
25766
+ return median >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25767
+ }
25768
+ /**
25769
+ * Fold one pointer sample into the drag's media velocity (ms of timeline per
25770
+ * ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
25771
+ */
25772
+ noteScrubSample(epochMs) {
25773
+ const now = performance.now();
25774
+ const prev = this.scrubLastSampleMs;
25775
+ const prevAt = this.scrubLastSampleAtMs;
25776
+ this.scrubLastSampleMs = epochMs;
25777
+ this.scrubLastSampleAtMs = now;
25778
+ if (prev === null) return;
25779
+ const gapMs = now - prevAt;
25780
+ if (gapMs < SCRUB_VELOCITY_MIN_GAP_MS || gapMs > SCRUB_VELOCITY_MAX_GAP_MS) return;
25781
+ const deltaMs = epochMs - prev;
25782
+ const dir = Math.sign(deltaMs);
25783
+ if (dir === 0) {
25784
+ this.scrubVelRun = 0;
25785
+ return;
25786
+ }
25787
+ this.scrubVelRun = Math.sign(this.scrubVelRun) === dir ? this.scrubVelRun + dir : dir;
25788
+ const v = deltaMs / gapMs;
25789
+ this.scrubVelocity = this.scrubVelocity === null ? v : this.scrubVelocity * (1 - SCRUB_VELOCITY_EMA) + v * SCRUB_VELOCITY_EMA;
25790
+ }
25791
+ /**
25792
+ * How far AHEAD of the finger this read should aim — see the
25793
+ * {@link SCRUB_LEAD_MIN_READ_SAMPLES} docblock for why this exists and what
25794
+ * bounds it. Zero whenever the prediction is not earned: no measured reads,
25795
+ * no sustained direction, or a velocity whose sign disagrees with the run.
25796
+ */
25797
+ scrubLeadMs() {
25798
+ const velocity = this.scrubVelocity;
25799
+ if (velocity === null) return 0;
25800
+ if (Math.abs(this.scrubVelRun) < SCRUB_LEAD_MIN_DIR_RUN) return 0;
25801
+ if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
25802
+ const readMs = this.scrubReadMedianMs();
25803
+ if (readMs === null) return 0;
25804
+ return Math.round(velocity * readMs);
25437
25805
  }
25438
25806
  /**
25439
25807
  * A single slow `locate` gets its own line, throttled per drag. Reserved for
@@ -26158,6 +26526,14 @@ var RecordedFeeder = class {
26158
26526
  this.scrubCursor = null;
26159
26527
  this.scrubNoFootageLogged = false;
26160
26528
  this.scrubParkLogged = false;
26529
+ this.scrubDeadlineLogged = false;
26530
+ this.scrubOversizeLogged = false;
26531
+ this.scrubProfileLogged = false;
26532
+ this.scrubPushedThisDrag = false;
26533
+ this.scrubResolve = null;
26534
+ this.scrubVelocity = null;
26535
+ this.scrubLastSampleMs = null;
26536
+ this.scrubVelRun = 0;
26161
26537
  this.scrubOutstanding = [];
26162
26538
  this.clearScrubAckTimeout();
26163
26539
  this.scrubPendingHint = null;