@camstack/addon-pipeline 1.2.129 → 1.2.130

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 (123) hide show
  1. package/dist/{addon-utils-CKB-2oM9.js → addon-utils-DH78p79i.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 +5 -5
  5. package/dist/detection-pipeline/index.mjs +3 -3
  6. package/dist/{dist-D9x2_csL.js → dist-DOw-izoI.js} +201 -49
  7. package/dist/{dist-CMio-1qq.mjs → dist-Wt81pN5Y.mjs} +201 -49
  8. package/dist/{event-loop-stall-monitor-UW8nsiMb.js → event-loop-stall-monitor-Bld7jNCb.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-B9dxYxyf.mjs → event-loop-stall-monitor-CtQCOZqm.mjs} +1 -1
  10. package/dist/{lazy-sharp-DrLwLKpg.js → lazy-sharp-BbNI2ZD-.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-C6es8dMA.mjs → process-memory-1_D3EZT9.mjs} +1 -1
  16. package/dist/{process-memory-CSJ7-Wlp.js → process-memory-D9H5qjDl.js} +1 -1
  17. package/dist/recorder/index.js +467 -29
  18. package/dist/recorder/index.mjs +465 -28
  19. package/dist/{segment-demux-js-BRCjXbI0.mjs → segment-demux-js-BW-Q-j2A.mjs} +1 -1
  20. package/dist/{segment-demux-js-DiLDoV9_.js → segment-demux-js-C2jBBqGW.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 +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-1O99xPTr.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dw-iwP4y.mjs} +3 -3
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Bme2OZQp.mjs +26 -0
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Do2DEtRt.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D36MoVV1.mjs} +1 -1
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-8scvCWua.mjs → hostInit-DEVCEYgu.mjs} +3 -3
  30. package/dist/stream-broker/index.js +426 -319
  31. package/dist/stream-broker/index.mjs +426 -319
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-BmBYCioC.js → worker-protocol-nAZl_Rsv.js} +1 -1
  34. package/dist/{worker-protocol-BHNWB9Ve.mjs → worker-protocol-rpnr7Ldd.mjs} +1 -1
  35. package/package.json +1 -1
  36. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  37. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  38. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  39. package/python/__pycache__/test_inference_pool_backpressure.cpython-312-pytest-9.1.1.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_backpressure.cpython-312.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312-pytest-9.1.1.pyc +0 -0
  43. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312.pyc +0 -0
  44. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  45. package/python/__pycache__/test_inference_pool_device_selection.cpython-312-pytest-9.1.1.pyc +0 -0
  46. package/python/__pycache__/test_inference_pool_device_selection.cpython-312.pyc +0 -0
  47. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  48. package/python/__pycache__/test_inference_pool_layout.cpython-312-pytest-9.1.1.pyc +0 -0
  49. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  50. package/python/__pycache__/test_inference_pool_memstats.cpython-312-pytest-9.1.1.pyc +0 -0
  51. package/python/__pycache__/test_inference_pool_memstats.cpython-312.pyc +0 -0
  52. package/python/__pycache__/test_inference_pool_memstats.cpython-314.pyc +0 -0
  53. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312-pytest-9.1.1.pyc +0 -0
  54. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312.pyc +0 -0
  55. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-314.pyc +0 -0
  56. package/python/__pycache__/test_inference_pool_preprocess.cpython-312-pytest-9.1.1.pyc +0 -0
  57. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  58. package/python/__pycache__/test_inference_pool_reconfigure.cpython-314.pyc +0 -0
  59. package/python/__pycache__/test_inference_pool_static_batch.cpython-312-pytest-9.1.1.pyc +0 -0
  60. package/python/__pycache__/test_inference_pool_static_batch.cpython-312.pyc +0 -0
  61. package/python/__pycache__/test_inference_pool_static_batch.cpython-314.pyc +0 -0
  62. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  63. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  64. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  65. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  66. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  67. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  68. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  70. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  71. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  72. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  73. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  74. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  75. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  76. package/python/postprocessors/__pycache__/plate_slots.cpython-312.pyc +0 -0
  77. package/python/postprocessors/__pycache__/plate_slots.cpython-314.pyc +0 -0
  78. package/python/postprocessors/__pycache__/rfdetr.cpython-312.pyc +0 -0
  79. package/python/postprocessors/__pycache__/rfdetr.cpython-314.pyc +0 -0
  80. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  81. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  82. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  83. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  84. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  85. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  86. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  87. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  88. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  89. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  90. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  91. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  92. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  93. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  94. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  95. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  96. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  97. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  98. package/python/postprocessors/__pycache__/test_ctc.cpython-314.pyc +0 -0
  99. package/python/postprocessors/__pycache__/test_plate_slots.cpython-312-pytest-9.1.1.pyc +0 -0
  100. package/python/postprocessors/__pycache__/test_rfdetr.cpython-312-pytest-9.1.1.pyc +0 -0
  101. package/python/postprocessors/__pycache__/test_rfdetr.cpython-314.pyc +0 -0
  102. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  103. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  104. package/python/postprocessors/__pycache__/test_saliency.cpython-314.pyc +0 -0
  105. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  106. package/python/postprocessors/__pycache__/test_softmax.cpython-314.pyc +0 -0
  107. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  108. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-314.pyc +0 -0
  109. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  110. package/python/postprocessors/__pycache__/test_yolonas.cpython-312-pytest-9.1.1.pyc +0 -0
  111. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  112. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  113. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  114. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  115. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  116. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  117. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  118. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  119. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  120. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
  121. package/python/postprocessors/__pycache__/yolonas.cpython-312.pyc +0 -0
  122. package/python/postprocessors/__pycache__/yolonas.cpython-314.pyc +0 -0
  123. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CxppPmXr.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-CMio-1qq.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-Wt81pN5Y.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-BRCjXbI0.mjs";
6
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-BW-Q-j2A.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";
@@ -23474,9 +23474,13 @@ var ControlMessageSchema = discriminatedUnion("t", [
23474
23474
  }),
23475
23475
  object({
23476
23476
  t: literal("scrubCommit"),
23477
- epoch: number()
23477
+ epoch: number(),
23478
+ dragId: string().optional()
23479
+ }),
23480
+ object({
23481
+ t: literal("scrubStart"),
23482
+ dragId: string().optional()
23478
23483
  }),
23479
- object({ t: literal("scrubStart") }),
23480
23484
  object({
23481
23485
  t: literal("scrubTo"),
23482
23486
  epoch: number(),
@@ -23628,104 +23632,6 @@ var MonotonicClock = class {
23628
23632
  this.rate = rate;
23629
23633
  }
23630
23634
  };
23631
- var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
23632
- var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
23633
- /** Demuxed bytes an entry holds — what the byte bound actually counts. */
23634
- function fragmentBytes(frag) {
23635
- let total = 0;
23636
- for (const au of frag.aus) total += au.data.length;
23637
- return total;
23638
- }
23639
- var ScrubFragmentCache = class {
23640
- maxBytes;
23641
- otherMaxBytes;
23642
- map = /* @__PURE__ */ new Map();
23643
- /**
23644
- * Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
23645
- * `high` entry must never be able to evict the `low` segments the drag is
23646
- * running on, and the reverse would be just as wrong.
23647
- */
23648
- bytesByProfile = /* @__PURE__ */ new Map();
23649
- constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
23650
- this.maxBytes = maxBytes;
23651
- this.otherMaxBytes = otherMaxBytes;
23652
- }
23653
- /** The byte budget for a profile — the scrub profile gets the real one. */
23654
- budgetFor(profile) {
23655
- return profile === "low" ? this.maxBytes : this.otherMaxBytes;
23656
- }
23657
- key(profile, startMs) {
23658
- return `${profile}|${startMs}`;
23659
- }
23660
- /** Exact-start lookup with LRU touch (re-insertion marks most recent). */
23661
- get(profile, startMs) {
23662
- const k = this.key(profile, startMs);
23663
- const frag = this.map.get(k);
23664
- if (!frag) return null;
23665
- this.map.delete(k);
23666
- this.map.set(k, frag);
23667
- return frag;
23668
- }
23669
- /**
23670
- * The cached fragment COVERING an instant, if any — the lookup that lets a
23671
- * warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
23672
- * saturated hub). Coverage is proof of footage: recorded fragments are
23673
- * immutable, so a hit needs no re-validation. LRU-touches the hit.
23674
- */
23675
- covering(profile, epochMs) {
23676
- for (const [k, frag] of this.map) {
23677
- if (!k.startsWith(`${profile}|`)) continue;
23678
- if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
23679
- this.map.delete(k);
23680
- this.map.set(k, frag);
23681
- return frag;
23682
- }
23683
- }
23684
- return null;
23685
- }
23686
- /** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
23687
- put(profile, frag) {
23688
- const k = this.key(profile, frag.startMs);
23689
- const held = this.map.get(k);
23690
- let bytes = this.bytesByProfile.get(profile) ?? 0;
23691
- if (held) bytes -= fragmentBytes(held);
23692
- this.map.delete(k);
23693
- this.map.set(k, frag);
23694
- bytes += fragmentBytes(frag);
23695
- this.bytesByProfile.set(profile, bytes);
23696
- const budget = this.budgetFor(profile);
23697
- const prefix = `${profile}|`;
23698
- while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
23699
- const oldest = this.oldestKeyWithPrefix(prefix);
23700
- if (oldest === void 0) break;
23701
- const evicted = this.map.get(oldest);
23702
- this.map.delete(oldest);
23703
- if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
23704
- }
23705
- }
23706
- oldestKeyWithPrefix(prefix) {
23707
- for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
23708
- }
23709
- entriesFor(profile) {
23710
- const prefix = `${profile}|`;
23711
- let n = 0;
23712
- for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
23713
- return n;
23714
- }
23715
- /** Demuxed bytes currently held across every profile. */
23716
- get byteSize() {
23717
- let total = 0;
23718
- for (const bytes of this.bytesByProfile.values()) total += bytes;
23719
- return total;
23720
- }
23721
- /** Demuxed bytes held for one profile — the number its MB bound governs. */
23722
- byteSizeFor(profile) {
23723
- return this.bytesByProfile.get(profile) ?? 0;
23724
- }
23725
- get size() {
23726
- return this.map.size;
23727
- }
23728
- };
23729
23635
  /**
23730
23636
  * A bounded numeric series. Exact count/sum/min/max forever; the median comes
23731
23637
  * from the first {@link SCRUB_PHASE_SAMPLE_CAP} samples.
@@ -23791,6 +23697,9 @@ var ScrubDragStats = class {
23791
23697
  prefetchBytes = 0;
23792
23698
  prefetchHits = 0;
23793
23699
  prefetchSuppressed = 0;
23700
+ warmReads = 0;
23701
+ warmBytes = 0;
23702
+ warmHits = 0;
23794
23703
  ackTimeouts = 0;
23795
23704
  deadlineMisses = 0;
23796
23705
  retargets = 0;
@@ -23857,6 +23766,15 @@ var ScrubDragStats = class {
23857
23766
  recordPrefetchHit() {
23858
23767
  this.prefetchHits += 1;
23859
23768
  }
23769
+ /** One window-warm read taken while the drag was live, and what it cost. */
23770
+ recordWarmRead(bytes) {
23771
+ this.warmReads += 1;
23772
+ this.warmBytes += bytes;
23773
+ }
23774
+ /** A tick served by a fragment the window warm had already landed. */
23775
+ recordWarmHit() {
23776
+ this.warmHits += 1;
23777
+ }
23860
23778
  /** Pushes written off after the ack timeout — dropped work, never silent. */
23861
23779
  recordAckTimeout(pushes) {
23862
23780
  this.ackTimeouts += pushes;
@@ -23939,6 +23857,9 @@ var ScrubDragStats = class {
23939
23857
  prefetchBytes: this.prefetchBytes,
23940
23858
  prefetchHits: this.prefetchHits,
23941
23859
  prefetchSuppressed: this.prefetchSuppressed,
23860
+ warmReads: this.warmReads,
23861
+ warmBytes: this.warmBytes,
23862
+ warmHits: this.warmHits,
23942
23863
  ackTimeouts: this.ackTimeouts,
23943
23864
  deadlineMisses: this.deadlineMisses,
23944
23865
  retargets: this.retargets,
@@ -23958,8 +23879,158 @@ var ScrubDragStats = class {
23958
23879
  };
23959
23880
  }
23960
23881
  };
23882
+ var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
23883
+ var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
23884
+ /** Demuxed bytes an entry holds — what the byte bound actually counts. */
23885
+ function fragmentBytes(frag) {
23886
+ let total = 0;
23887
+ for (const au of frag.aus) total += au.data.length;
23888
+ return total;
23889
+ }
23890
+ var ScrubFragmentCache = class {
23891
+ maxBytes;
23892
+ otherMaxBytes;
23893
+ map = /* @__PURE__ */ new Map();
23894
+ /**
23895
+ * Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
23896
+ * `high` entry must never be able to evict the `low` segments the drag is
23897
+ * running on, and the reverse would be just as wrong.
23898
+ */
23899
+ bytesByProfile = /* @__PURE__ */ new Map();
23900
+ constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
23901
+ this.maxBytes = maxBytes;
23902
+ this.otherMaxBytes = otherMaxBytes;
23903
+ }
23904
+ /** The byte budget for a profile — the scrub profile gets the real one. */
23905
+ budgetFor(profile) {
23906
+ return profile === "low" ? this.maxBytes : this.otherMaxBytes;
23907
+ }
23908
+ key(profile, startMs) {
23909
+ return `${profile}|${startMs}`;
23910
+ }
23911
+ /** Exact-start lookup with LRU touch (re-insertion marks most recent). */
23912
+ get(profile, startMs) {
23913
+ const k = this.key(profile, startMs);
23914
+ const frag = this.map.get(k);
23915
+ if (!frag) return null;
23916
+ this.map.delete(k);
23917
+ this.map.set(k, frag);
23918
+ return frag;
23919
+ }
23920
+ /**
23921
+ * The cached fragment COVERING an instant, if any — the lookup that lets a
23922
+ * warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
23923
+ * saturated hub). Coverage is proof of footage: recorded fragments are
23924
+ * immutable, so a hit needs no re-validation. LRU-touches the hit.
23925
+ */
23926
+ covering(profile, epochMs) {
23927
+ for (const [k, frag] of this.map) {
23928
+ if (!k.startsWith(`${profile}|`)) continue;
23929
+ if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
23930
+ this.map.delete(k);
23931
+ this.map.set(k, frag);
23932
+ return frag;
23933
+ }
23934
+ }
23935
+ return null;
23936
+ }
23937
+ /** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
23938
+ put(profile, frag) {
23939
+ const k = this.key(profile, frag.startMs);
23940
+ const held = this.map.get(k);
23941
+ let bytes = this.bytesByProfile.get(profile) ?? 0;
23942
+ if (held) bytes -= fragmentBytes(held);
23943
+ this.map.delete(k);
23944
+ this.map.set(k, frag);
23945
+ bytes += fragmentBytes(frag);
23946
+ this.bytesByProfile.set(profile, bytes);
23947
+ const budget = this.budgetFor(profile);
23948
+ const prefix = `${profile}|`;
23949
+ while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
23950
+ const oldest = this.oldestKeyWithPrefix(prefix);
23951
+ if (oldest === void 0) break;
23952
+ const evicted = this.map.get(oldest);
23953
+ this.map.delete(oldest);
23954
+ if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
23955
+ }
23956
+ }
23957
+ oldestKeyWithPrefix(prefix) {
23958
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
23959
+ }
23960
+ entriesFor(profile) {
23961
+ const prefix = `${profile}|`;
23962
+ let n = 0;
23963
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
23964
+ return n;
23965
+ }
23966
+ /** Demuxed bytes currently held across every profile. */
23967
+ get byteSize() {
23968
+ let total = 0;
23969
+ for (const bytes of this.bytesByProfile.values()) total += bytes;
23970
+ return total;
23971
+ }
23972
+ /** Demuxed bytes held for one profile — the number its MB bound governs. */
23973
+ byteSizeFor(profile) {
23974
+ return this.bytesByProfile.get(profile) ?? 0;
23975
+ }
23976
+ get size() {
23977
+ return this.map.size;
23978
+ }
23979
+ };
23980
+ /**
23981
+ * `velocity` is ms of timeline per ms of wall clock, signed. `readMs` is what a
23982
+ * read costs on this disk right now. Either being null means "not enough is
23983
+ * known to aim" — which is NOT saturation: an unknown velocity must not switch
23984
+ * speculation off, or the first ticks of every drag would lose it.
23985
+ */
23986
+ function scrubLeadDecision(velocity, readMs) {
23987
+ if (velocity === null || readMs === null) return {
23988
+ leadMs: 0,
23989
+ saturated: false
23990
+ };
23991
+ const raw = Math.round(velocity * readMs);
23992
+ if (Math.abs(raw) > 3e4) return {
23993
+ leadMs: 0,
23994
+ saturated: true
23995
+ };
23996
+ return {
23997
+ leadMs: raw,
23998
+ saturated: false
23999
+ };
24000
+ }
23961
24001
  //#endregion
23962
24002
  //#region src/stream-broker/recorded/recorded-feeder.ts
24003
+ /**
24004
+ * READ-AHEAD LEAD — the fix for "the picture chases the finger".
24005
+ *
24006
+ * A scrub frame is always the frame a read STARTED for, one read time ago.
24007
+ * On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
24008
+ * a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
24009
+ * the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
24010
+ * behind the finger. Nothing about that is I/O — it is aiming at where the
24011
+ * finger WAS.
24012
+ *
24013
+ * So a steady drag reads where the finger WILL BE: `mediaVelocity ×
24014
+ * medianRecentReadMs`. It costs nothing (the same one read, at a different
24015
+ * offset), and it is bounded by the two things that make it a prediction
24016
+ * rather than a guess:
24017
+ *
24018
+ * - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
24019
+ * samples, so a jittery or reversing drag never leads. Pointer samples and
24020
+ * not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
24021
+ * ~4 pushes, so a gate counted in pushes has no runway inside the gesture
24022
+ * it is supposed to help — measured in the drag bench, it engaged once, at
24023
+ * the very end;
24024
+ * - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
24025
+ * is derived from this disk right now and not from a constant. ONE is
24026
+ * enough: against a read cost that is unknown, a one-sample estimate is not
24027
+ * a good prediction — it is simply a far better one than assuming zero.
24028
+ *
24029
+ * Overshoot is self-correcting: the retarget below serves the newest target
24030
+ * the landed fragment covers, so a lead that is slightly long lands on the
24031
+ * finger exactly, and one that is wrong costs the same single read it would
24032
+ * have cost anyway.
24033
+ */
23963
24034
  var SEGMENT_TIMEOUT_MS = 8e3;
23964
24035
  /**
23965
24036
  * Deadline for the recorded-AUDIO demux on the `reusedWhole` seek path, and
@@ -24060,30 +24131,6 @@ var SEEK_ANIMATION_MIN_DISTANCE_MS = 8e3;
24060
24131
  */
24061
24132
  var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
24062
24133
  /**
24063
- * Consecutive same-direction `scrubTo` deltas required before a direction-aware
24064
- * prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
24065
- * sustained direction, so a jittery/reversing drag never wastes the slot.
24066
- */
24067
- var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
24068
- /**
24069
- * How many speculative GOP reads may be in flight at once, i.e. how many drag
24070
- * steps ahead the feeder reads.
24071
- *
24072
- * The scrub tick IS the read: measured on salone 590 over `low`,
24073
- * `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
24074
- * the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
24075
- * hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
24076
- * tens of seconds apart revisits nothing. Reading N steps ahead is the only
24077
- * thing that converts wall-clock the drag is ALREADY spending into pushes.
24078
- *
24079
- * 2 and not more: these reads land on the same spindles the recorder is writing
24080
- * every camera to, and past a small number concurrent random reads stop
24081
- * overlapping and start thrashing. Raise it only against a measured
24082
- * `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
24083
- * both halves of the evidence.
24084
- */
24085
- var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24086
- /**
24087
24134
  * The profile whose scrub read unit is the WHOLE SEGMENT.
24088
24135
  *
24089
24136
  * Every drag is pinned to `low` by the client, and a `low` segment is ~230 KB
@@ -24119,14 +24166,30 @@ var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
24119
24166
  * (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
24120
24167
  * instead of being inferred from a falling hit rate.
24121
24168
  *
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.
24169
+ * The real fix, for when 60 s segments land, is a read unit that is a BYTE
24170
+ * BUDGET over the fragment's own keyframe table — "as many consecutive GOPs
24171
+ * as fit in N bytes" — computed from `tfra` offsets alone, no payload read
24172
+ * (`fragmentsWithinBudget`, `recorder/mfra-index.ts`). `readGopBytes` now uses
24173
+ * exactly this budget on the fallback path below: it no longer reads exactly
24174
+ * one GOP, it reads as many CONSECUTIVE fragments as fit this same ceiling, in
24175
+ * one range read (`gop-range-read.ts`'s `maxBytes`). The recorder-side handler
24176
+ * (`recording-provider.ts`) declares its OWN copy of this number
24177
+ * (`SCRUB_GOP_BUDGET_MAX_BYTES` in `gop-range-read.ts`) rather than importing
24178
+ * this module — the client feeder is a different addon-internal concern, and
24179
+ * pulling its whole dependency graph in for one number is the wrong
24180
+ * direction. Exported here so a future caller in THIS process can reuse it;
24181
+ * if D163's number moves, move both.
24127
24182
  */
24128
24183
  var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
24129
24184
  /**
24185
+ * How still the finger must be before the window warm may take the disk back.
24186
+ *
24187
+ * Long enough that it does not thrash on the gap between two ticks of a slow
24188
+ * drag (tickMsP50 was 120-129 ms on the measured drags), short enough that a
24189
+ * pause the operator would call "stopped" is warm again before they move.
24190
+ */
24191
+ var SCRUB_WARM_QUIET_MS = 300;
24192
+ /**
24130
24193
  * How far the entry warm-up reaches, backward and forward from the playhead the
24131
24194
  * drag starts on.
24132
24195
  *
@@ -24161,17 +24224,6 @@ var SCRUB_WARM_MAX_SEGMENTS = Math.max(1, Math.floor(SCRUB_FRAGMENT_CACHE_MAX_BY
24161
24224
  */
24162
24225
  var IDLE_WARM_READ_GAP_MS = 250;
24163
24226
  /**
24164
- * Median recent read time at or above which the feeder STOPS speculating.
24165
- *
24166
- * Speculation is a bet that spare I/O exists. When reads are this slow there is
24167
- * no spare I/O — the array is saturated — and a prefetch is not free work, it
24168
- * is another random seek queued ahead of the read the operator is waiting on.
24169
- * The field regime that forced this: 54 prefetch reads for 9 hits with the disk
24170
- * at 98% busy. Suppression is counted (`prefetchSuppressed`) so the decision is
24171
- * visible, and it lifts by itself as soon as reads come back.
24172
- */
24173
- var SCRUB_SLOW_READ_MS = 600;
24174
- /**
24175
24227
  * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
24176
24228
  * that SWEEPS predicted frames instead of jumping. A steady forward drag is
24177
24229
  * playback with a moving target: once a keyframe decoded, the predicted frames
@@ -24244,37 +24296,6 @@ var SCRUB_RIDE_WINDOW_MS = 1e4;
24244
24296
  * moved on to. At the cap, a tick rides the newest in-flight read instead.
24245
24297
  */
24246
24298
  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
24299
  var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
24279
24300
  /**
24280
24301
  * Smoothing for the media-velocity estimate (ms of timeline per ms of wall
@@ -24507,25 +24528,6 @@ var RecordedFeeder = class {
24507
24528
  scrubLastPushLogAtMs = 0;
24508
24529
  scrubLastSlowLocateLogAtMs = 0;
24509
24530
  /**
24510
- * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
24511
- * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
24512
- * a reversal or an in-fragment no-move resets it.
24513
- */
24514
- scrubDirRun = 0;
24515
- /**
24516
- * Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
24517
- * these are the SPECULATIVE concurrency slots, and they only fill
24518
- * `scrubCache` — they never push, never touch the pending/timer/latest-wins
24519
- * state, and never block the live coalesced tick.
24520
- *
24521
- * More than one because the read IS the tick (263 of 270 ms measured): with a
24522
- * single slot the drag can never go faster than one cold read, however well
24523
- * the speculation guesses.
24524
- */
24525
- scrubPrefetchInFlight = 0;
24526
- /** Probe instants currently being read, so two horizons never duplicate one. */
24527
- scrubPrefetchProbes = /* @__PURE__ */ new Set();
24528
- /**
24529
24531
  * Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
24530
24532
  * hits one of them is what makes speculation provably worth its I/O
24531
24533
  * (`prefetchHits`) — without this the feeder could double the load on the disk
@@ -24575,6 +24577,18 @@ var RecordedFeeder = class {
24575
24577
  scrubFillLoopActive = false;
24576
24578
  scrubWarmBackMs = SCRUB_WARM_BACK_MS;
24577
24579
  scrubWarmFwdMs = SCRUB_WARM_FWD_MS;
24580
+ /**
24581
+ * The client-supplied id of the CURRENT (or most recently active) scrub
24582
+ * gesture, set by `beginScrub(dragId)`. Printed on every `scrub-feeder:*`
24583
+ * line — ticks, warm fill, park/deadline/ack pathologies, and the drag-end
24584
+ * aggregate — so a drag can be isolated in the logs without depending on
24585
+ * wall-clock proximity. `undefined` outside a drag (and for a feeder whose
24586
+ * caller never sends one — additive, optional, see D1 in the plan).
24587
+ */
24588
+ scrubDragId;
24589
+ /** This feeder's own last-announced state — the `from` half of every
24590
+ * {@link FeederStateTransition}. `null` before the first transition. */
24591
+ lastAnnouncedState = null;
24578
24592
  constructor(deps) {
24579
24593
  this.deps = deps;
24580
24594
  this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
@@ -24592,6 +24606,50 @@ var RecordedFeeder = class {
24592
24606
  if (typeof pcmu === "function") return (data, rtpTs) => pcmu.call(sink, data, rtpTs);
24593
24607
  }
24594
24608
  /**
24609
+ * Emit one state transition through `onState` — see
24610
+ * {@link FeederStateTransition} for why `from`/`reason`/`seekToken` exist.
24611
+ * The ONLY place `this.lastAnnouncedState` is written, so every announced
24612
+ * state (wherever it is announced from) is captured as the next
24613
+ * transition's `from`.
24614
+ */
24615
+ emitState(to, reason, seekToken, dragId) {
24616
+ const from = this.lastAnnouncedState;
24617
+ this.lastAnnouncedState = to;
24618
+ this.deps.onState({
24619
+ from,
24620
+ to,
24621
+ reason,
24622
+ seekToken,
24623
+ dragId
24624
+ });
24625
+ }
24626
+ /**
24627
+ * ONE line per `seek()` call, unconditionally — the C3 rendiconto. Before
24628
+ * this, a commit's outcome was told by WHICHEVER of several lines happened
24629
+ * to fire: `recorded:seek-ready` only on success, `recorded:seek-abandoned`
24630
+ * only when a later token/dispose superseded this one, `recorded:seek-no-footage`
24631
+ * / `recorded:snap-too-far` only on a gap — and a raw thrown error (or an
24632
+ * error on an ALREADY-abandoned commit) logged NOTHING at all. That silent
24633
+ * shape is exactly the 2026-08-26 stall: two commits reached
24634
+ * `playback:intent` and never `playback:ready`, with nothing in the feeder's
24635
+ * own logs saying why. Every return path in `seek()` now calls this before
24636
+ * it exits, so a commit that never lands still leaves one line naming what
24637
+ * happened to it.
24638
+ */
24639
+ logSeekOutcome(token, seekStartedAt, epochMs, dragId, outcome, state) {
24640
+ this.deps.onLog?.("recorded:seek-outcome", {
24641
+ dragId,
24642
+ epochMs,
24643
+ outcome,
24644
+ state,
24645
+ disposed: this.disposed,
24646
+ superseded: token !== this.seekToken,
24647
+ tokenAtStart: token,
24648
+ tokenNow: this.seekToken,
24649
+ totalMs: Math.round(performance.now() - seekStartedAt)
24650
+ });
24651
+ }
24652
+ /**
24595
24653
  * Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
24596
24654
  * the FIRST located segment is fed; the forward `advance` loop is started
24597
24655
  * fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
@@ -24604,6 +24662,7 @@ var RecordedFeeder = class {
24604
24662
  let loadMs = 0;
24605
24663
  let firstFrameMs = 0;
24606
24664
  const token = ++this.seekToken;
24665
+ const dragId = opts?.dragId;
24607
24666
  const wasScrubbing = this.scrubbing;
24608
24667
  if (this.scrubbing) {
24609
24668
  this.emitDragEnd();
@@ -24615,7 +24674,6 @@ var RecordedFeeder = class {
24615
24674
  this.clearScrubAckTimeout();
24616
24675
  this.scrubPendingHint = null;
24617
24676
  this.scrubPrevTarget = null;
24618
- this.scrubDirRun = 0;
24619
24677
  if (this.scrubTimer) {
24620
24678
  clearTimeout(this.scrubTimer);
24621
24679
  this.scrubTimer = null;
@@ -24625,7 +24683,7 @@ var RecordedFeeder = class {
24625
24683
  this.inFlightSeekEpochMs = epochMs;
24626
24684
  let snapInfo = null;
24627
24685
  try {
24628
- this.deps.onState("loading");
24686
+ this.emitState("loading", "seek-start", token, dragId);
24629
24687
  if (this.clockHasAdvanced) {
24630
24688
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
24631
24689
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
@@ -24637,21 +24695,29 @@ var RecordedFeeder = class {
24637
24695
  epochMs
24638
24696
  }));
24639
24697
  locateMs += performance.now() - locateStartedAt;
24640
- if (this.disposed || token !== this.seekToken) return;
24698
+ if (this.disposed || token !== this.seekToken) {
24699
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24700
+ return;
24701
+ }
24641
24702
  let landMs = epochMs;
24642
24703
  if (loc.kind === "gap") {
24643
24704
  const decision = resolveSnapTarget(loc, epochMs);
24644
24705
  if (decision.kind !== "snap") {
24645
24706
  if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
24707
+ dragId,
24646
24708
  requestedMs: epochMs,
24647
24709
  nearestMs: decision.snapMs,
24648
24710
  distanceMs: decision.distanceMs
24649
24711
  });
24650
- else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
24712
+ else this.deps.onLog?.("recorded:seek-no-footage", {
24713
+ dragId,
24714
+ requestedMs: epochMs
24715
+ });
24651
24716
  this.currentSegment = null;
24652
24717
  this.lastFedVideoIdx = -1;
24653
24718
  this.stopPositionReports();
24654
- this.deps.onState("gap");
24719
+ this.emitState("gap", decision.kind === "too-far" ? "seek-snap-too-far" : "seek-no-footage", token, dragId);
24720
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24655
24721
  return;
24656
24722
  }
24657
24723
  const snapLocateStartedAt = performance.now();
@@ -24661,19 +24727,25 @@ var RecordedFeeder = class {
24661
24727
  epochMs: decision.snapMs
24662
24728
  }));
24663
24729
  locateMs += performance.now() - snapLocateStartedAt;
24664
- if (this.disposed || token !== this.seekToken) return;
24730
+ if (this.disposed || token !== this.seekToken) {
24731
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24732
+ return;
24733
+ }
24665
24734
  if (snapped.kind !== "segment") {
24666
24735
  this.deps.onLog?.("recorded:seek-no-footage", {
24736
+ dragId,
24667
24737
  requestedMs: epochMs,
24668
24738
  snappedMs: decision.snapMs
24669
24739
  });
24670
24740
  this.currentSegment = null;
24671
24741
  this.lastFedVideoIdx = -1;
24672
24742
  this.stopPositionReports();
24673
- this.deps.onState("gap");
24743
+ this.emitState("gap", "seek-snap-target-no-footage", token, dragId);
24744
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24674
24745
  return;
24675
24746
  }
24676
24747
  this.deps.onLog?.("recorded:seek-snapped", {
24748
+ dragId,
24677
24749
  requestedMs: epochMs,
24678
24750
  snappedMs: decision.snapMs
24679
24751
  });
@@ -24687,9 +24759,12 @@ var RecordedFeeder = class {
24687
24759
  const travelFromMs = opts?.travelFromMs ?? (this.clockHasAdvanced ? this.cursorMs : null);
24688
24760
  if (!wasScrubbing && travelFromMs !== null) {
24689
24761
  const travelStartedAt = performance.now();
24690
- await this.animateSeekTravel(travelFromMs, landMs, token);
24762
+ await this.animateSeekTravel(travelFromMs, landMs, token, dragId);
24691
24763
  travelMs = performance.now() - travelStartedAt;
24692
- if (this.disposed || token !== this.seekToken) return;
24764
+ if (this.disposed || token !== this.seekToken) {
24765
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24766
+ return;
24767
+ }
24693
24768
  }
24694
24769
  this.cursorMs = loc.startMs;
24695
24770
  const loadStartedAt = performance.now();
@@ -24706,7 +24781,10 @@ var RecordedFeeder = class {
24706
24781
  prefixSource = "cache";
24707
24782
  } else {
24708
24783
  const fragmentLoad = await this.loadScrubFragment(loc, landMs);
24709
- if (fragmentLoad === null) return;
24784
+ if (fragmentLoad === null) {
24785
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24786
+ return;
24787
+ }
24710
24788
  fragment = fragmentLoad.seg;
24711
24789
  this.scrubCache.put(this.deps.profile, fragment);
24712
24790
  prefixSource = fragmentLoad.wholeSegmentRead ? "whole" : "gop";
@@ -24715,7 +24793,10 @@ var RecordedFeeder = class {
24715
24793
  bytes: fragmentLoad.rawBytes
24716
24794
  };
24717
24795
  }
24718
- if (this.disposed || token !== this.seekToken) return;
24796
+ if (this.disposed || token !== this.seekToken) {
24797
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24798
+ return;
24799
+ }
24719
24800
  const fragmentLoc = {
24720
24801
  kind: "segment",
24721
24802
  startMs: fragment.startMs,
@@ -24726,7 +24807,7 @@ var RecordedFeeder = class {
24726
24807
  prefixFed = true;
24727
24808
  gopFirst = true;
24728
24809
  firstFrameMs = performance.now() - seekStartedAt;
24729
- this.deps.onState(this.rate === 0 ? "paused" : "playing");
24810
+ this.emitState(this.rate === 0 ? "paused" : "playing", "seek-gop-prefix-landed", token, dragId);
24730
24811
  this.deps.onPosition(this.cursorMs);
24731
24812
  this.startPositionReports();
24732
24813
  stateAnnounced = true;
@@ -24741,6 +24822,7 @@ var RecordedFeeder = class {
24741
24822
  audio = [...await this.withTimeout(this.deps.demuxAudio(reusedWhole.bytes), AUDIO_DEMUX_TIMEOUT_MS)];
24742
24823
  } catch (err) {
24743
24824
  this.deps.onLog?.("recorded:seek-audio-dropped", {
24825
+ dragId,
24744
24826
  waitedMs: Date.now() - audioStartedAt,
24745
24827
  budgetMs: AUDIO_DEMUX_TIMEOUT_MS,
24746
24828
  bytes: reusedWhole.bytes.byteLength,
@@ -24768,7 +24850,21 @@ var RecordedFeeder = class {
24768
24850
  loadAusMs = performance.now() - loadAusStartedAt;
24769
24851
  }
24770
24852
  loadMs = performance.now() - loadStartedAt;
24771
- if (this.disposed || token !== this.seekToken || loaded === null) return;
24853
+ if (this.disposed || token !== this.seekToken || loaded === null) {
24854
+ this.deps.onLog?.("recorded:seek-abandoned", {
24855
+ dragId,
24856
+ requestedMs: epochMs,
24857
+ disposed: this.disposed,
24858
+ superseded: token !== this.seekToken,
24859
+ tokenAtStart: token,
24860
+ tokenNow: this.seekToken,
24861
+ loadedNull: loaded === null,
24862
+ announcedLoading: true,
24863
+ loadMs: Math.round(loadMs)
24864
+ });
24865
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24866
+ return;
24867
+ }
24772
24868
  if (!prefixFed) this.feedPrefix(loc, loaded.video, landMs);
24773
24869
  if (gopFirst) {
24774
24870
  this.currentSegment = {
@@ -24790,11 +24886,12 @@ var RecordedFeeder = class {
24790
24886
  }
24791
24887
  if (this.rate === 0) {
24792
24888
  if (!stateAnnounced) {
24793
- this.deps.onState("paused");
24889
+ this.emitState("paused", "seek-landed-paused", token, dragId);
24794
24890
  this.deps.onPosition(this.cursorMs);
24795
24891
  this.startPositionReports();
24796
24892
  }
24797
24893
  this.logSeekReady({
24894
+ dragId,
24798
24895
  epochMs,
24799
24896
  startedAt: seekStartedAt,
24800
24897
  locateMs,
@@ -24810,14 +24907,16 @@ var RecordedFeeder = class {
24810
24907
  loadAusMs: Math.round(loadAusMs)
24811
24908
  });
24812
24909
  this.startIdleWarm(this.cursorMs);
24910
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "paused");
24813
24911
  return;
24814
24912
  }
24815
24913
  if (!stateAnnounced) {
24816
- this.deps.onState("playing");
24914
+ this.emitState("playing", "seek-landed-playing", token, dragId);
24817
24915
  this.deps.onPosition(this.cursorMs);
24818
24916
  this.startPositionReports();
24819
24917
  }
24820
24918
  this.logSeekReady({
24919
+ dragId,
24821
24920
  epochMs,
24822
24921
  startedAt: seekStartedAt,
24823
24922
  locateMs,
@@ -24847,22 +24946,26 @@ var RecordedFeeder = class {
24847
24946
  resumeAfterPtsMs
24848
24947
  });
24849
24948
  this.startIdleWarm(this.cursorMs);
24949
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "playing");
24850
24950
  } catch (err) {
24851
24951
  if (!this.disposed && token === this.seekToken) {
24852
24952
  this.stopPositionReports();
24853
24953
  if (snapInfo !== null) {
24854
24954
  this.deps.onLog?.("recorded:seek-snap-load-failed", {
24955
+ dragId,
24855
24956
  requestedMs: snapInfo.requestedMs,
24856
24957
  snappedMs: snapInfo.snappedMs,
24857
24958
  error: errMsg(err)
24858
24959
  });
24859
24960
  this.currentSegment = null;
24860
24961
  this.lastFedVideoIdx = -1;
24861
- this.deps.onState("gap");
24962
+ this.emitState("gap", "seek-snap-load-failed", token, dragId);
24963
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24862
24964
  return;
24863
24965
  }
24864
- this.deps.onState("error");
24865
- }
24966
+ this.emitState("error", "seek-error", token, dragId);
24967
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "error", "error");
24968
+ } else this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24866
24969
  } finally {
24867
24970
  if (token === this.seekToken) {
24868
24971
  this.inFlightSeekEpochMs = null;
@@ -24872,6 +24975,7 @@ var RecordedFeeder = class {
24872
24975
  }
24873
24976
  logSeekReady(fields) {
24874
24977
  this.deps.onLog?.("recorded:seek-ready", {
24978
+ dragId: fields.dragId,
24875
24979
  epochMs: fields.epochMs,
24876
24980
  state: fields.state,
24877
24981
  totalMs: Math.round(performance.now() - fields.startedAt),
@@ -24906,7 +25010,7 @@ var RecordedFeeder = class {
24906
25010
  if (next === 0) {
24907
25011
  this.advanceToken++;
24908
25012
  this.stopPositionReports();
24909
- this.deps.onState("paused");
25013
+ this.emitState("paused", "rate-zero", this.seekToken, this.scrubDragId);
24910
25014
  return;
24911
25015
  }
24912
25016
  this.pendingStepDirs.length = 0;
@@ -24928,9 +25032,16 @@ var RecordedFeeder = class {
24928
25032
  * (which flips `scrubbing` false and clears the session cache) or `dispose()`;
24929
25033
  * a genuinely NEW drag is therefore always preceded by that exit, so its
24930
25034
  * `beginScrub` runs the full reset below. (D-scrub-ux)
25035
+ *
25036
+ * `dragId` is the client's id for this gesture (optional — the viewer's main
25037
+ * session generates it; a caller that omits it gets identical behaviour,
25038
+ * just no id on the logs). Stored for the whole drag, including its commit:
25039
+ * every `scrub-feeder:*` line printed until the next `beginScrub` carries it
25040
+ * (C1).
24931
25041
  */
24932
- beginScrub() {
25042
+ beginScrub(dragId) {
24933
25043
  if (this.disposed || this.scrubbing) return;
25044
+ this.scrubDragId = dragId;
24934
25045
  this.seekToken++;
24935
25046
  this.advanceToken++;
24936
25047
  this.scrubbing = true;
@@ -24939,7 +25050,6 @@ var RecordedFeeder = class {
24939
25050
  this.scrubSegment = null;
24940
25051
  this.scrubCredit = SCRUB_CREDIT_CAP;
24941
25052
  this.scrubPrevTarget = null;
24942
- this.scrubDirRun = 0;
24943
25053
  this.scrubPrefetched = /* @__PURE__ */ new Set();
24944
25054
  this.scrubCursor = null;
24945
25055
  this.scrubNoFootageLogged = false;
@@ -25022,6 +25132,7 @@ var RecordedFeeder = class {
25022
25132
  this.scrubWarmBackMs = opts?.backMs ?? SCRUB_WARM_BACK_MS;
25023
25133
  this.scrubWarmFwdMs = opts?.fwdMs ?? SCRUB_WARM_FWD_MS;
25024
25134
  this.deps.onLog?.("scrub-feeder:warm-start", {
25135
+ dragId: this.scrubDragId,
25025
25136
  centreMs,
25026
25137
  fillGen: this.scrubWarmGen
25027
25138
  });
@@ -25047,6 +25158,7 @@ var RecordedFeeder = class {
25047
25158
  lastWorked = gen;
25048
25159
  if (gen === this.scrubWarmGen) {
25049
25160
  this.deps.onLog?.("scrub-feeder:warm-done", {
25161
+ dragId: this.scrubDragId,
25050
25162
  fillGen: gen,
25051
25163
  idle: this.idleWarm && !this.scrubbing,
25052
25164
  fetched,
@@ -25083,6 +25195,19 @@ var RecordedFeeder = class {
25083
25195
  if (Math.abs(targetMs - centre) <= SCRUB_RECENTER_MS) return;
25084
25196
  this.recenterScrubWarm(targetMs);
25085
25197
  }
25198
+ /**
25199
+ * Is the operator's finger actually moving right now?
25200
+ *
25201
+ * `scrubbing` alone is not the question — a drag stays open while the finger
25202
+ * rests, and those pauses are exactly when the warm should work. The signal
25203
+ * is the pointer sample clock, which `noteScrubSample` stamps on every
25204
+ * `scrubTo` before any velocity gating, so it is true even for a drag too
25205
+ * slow or too jittery for the velocity estimator.
25206
+ */
25207
+ fingerIsMoving() {
25208
+ if (!this.scrubbing) return false;
25209
+ return performance.now() - this.scrubLastSampleAtMs < SCRUB_WARM_QUIET_MS;
25210
+ }
25086
25211
  async warmScrubWindow(centreMs, dragGen, fillGen, backMs, fwdMs) {
25087
25212
  const spent = await this.warmScrubDirection(centreMs, -1, backMs, 0, dragGen, fillGen);
25088
25213
  return this.warmScrubDirection(centreMs, 1, fwdMs, spent, dragGen, fillGen);
@@ -25122,6 +25247,10 @@ var RecordedFeeder = class {
25122
25247
  durMs: warm.durMs,
25123
25248
  fetched: false
25124
25249
  };
25250
+ while (this.fingerIsMoving()) {
25251
+ if (!this.warmLoopAlive(dragGen) || fillGen !== this.scrubWarmGen) return null;
25252
+ await new Promise((r) => setTimeout(r, SCRUB_WARM_QUIET_MS));
25253
+ }
25125
25254
  if (this.idleWarm && !this.scrubbing) {
25126
25255
  await new Promise((r) => setTimeout(r, this.deps.idleWarmGapMs ?? IDLE_WARM_READ_GAP_MS));
25127
25256
  while (this.inFlightSeekEpochMs !== null) {
@@ -25141,7 +25270,7 @@ var RecordedFeeder = class {
25141
25270
  if (loc.kind !== "segment") return null;
25142
25271
  if (!await this.waitForTickReadIdle(fillGen)) return null;
25143
25272
  const load = await this.loadScrubFragment(loc, probeMs);
25144
- if (load !== null && this.scrubbing) this.scrubStats?.recordPrefetchRead(load.readBytes);
25273
+ if (load !== null && this.scrubbing) this.scrubStats?.recordWarmRead(load.readBytes);
25145
25274
  if (!this.warmLoopAlive(dragGen) || load === null) return null;
25146
25275
  if (fillGen !== this.scrubWarmGen) return null;
25147
25276
  this.scrubCache.put(this.deps.profile, load.seg);
@@ -25261,6 +25390,7 @@ var RecordedFeeder = class {
25261
25390
  if (!this.scrubAckTimeoutLogged) {
25262
25391
  this.scrubAckTimeoutLogged = true;
25263
25392
  this.deps.onLog?.("scrub-feeder:ack-timeout", {
25393
+ dragId: this.scrubDragId,
25264
25394
  pushes: expired,
25265
25395
  frames,
25266
25396
  timeoutMs: SCRUB_ACK_TIMEOUT_MS,
@@ -25292,6 +25422,7 @@ var RecordedFeeder = class {
25292
25422
  if (!this.scrubParkLogged) {
25293
25423
  this.scrubParkLogged = true;
25294
25424
  this.deps.onLog?.("scrub-feeder:parked", {
25425
+ dragId: this.scrubDragId,
25295
25426
  pendingEpochMs: this.scrubPendingEpochMs,
25296
25427
  outstanding: this.scrubOutstanding.length
25297
25428
  });
@@ -25324,7 +25455,7 @@ var RecordedFeeder = class {
25324
25455
  seg = this.scrubCache.covering(this.deps.profile, target);
25325
25456
  if (seg) {
25326
25457
  coverage = "cache";
25327
- if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordPrefetchHit();
25458
+ if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordWarmHit();
25328
25459
  }
25329
25460
  }
25330
25461
  if (!seg) {
@@ -25354,6 +25485,7 @@ var RecordedFeeder = class {
25354
25485
  if (!this.scrubDeadlineLogged) {
25355
25486
  this.scrubDeadlineLogged = true;
25356
25487
  this.deps.onLog?.("scrub-feeder:deadline-miss", {
25488
+ dragId: this.scrubDragId,
25357
25489
  epochMs: target,
25358
25490
  deadlineMs,
25359
25491
  first: !this.scrubPushedThisDrag,
@@ -25477,6 +25609,7 @@ var RecordedFeeder = class {
25477
25609
  if (this.scrubLastPushLogAtMs === 0 || pushedAtMs - this.scrubLastPushLogAtMs >= 1e3) {
25478
25610
  this.scrubLastPushLogAtMs = pushedAtMs;
25479
25611
  this.deps.onLog?.("scrub-feeder:pushed", {
25612
+ dragId: this.scrubDragId,
25480
25613
  epochMs: effTarget,
25481
25614
  mode,
25482
25615
  dir,
@@ -25494,10 +25627,11 @@ var RecordedFeeder = class {
25494
25627
  });
25495
25628
  }
25496
25629
  this.deps.onPosition(this.clampIntoSegment(seg, effTarget), pushSeq);
25497
- this.maybeScrubPrefetch(seg, effTarget, gen);
25630
+ this.scrubPrevTarget = effTarget;
25498
25631
  } catch (err) {
25499
25632
  this.scrubStats?.recordFailure();
25500
25633
  this.deps.onLog?.("scrub-feeder:tick-failed", {
25634
+ dragId: this.scrubDragId,
25501
25635
  epochMs: target,
25502
25636
  error: err instanceof Error ? err.message : String(err)
25503
25637
  });
@@ -25543,11 +25677,15 @@ var RecordedFeeder = class {
25543
25677
  if (!this.scrubNoFootageLogged) {
25544
25678
  this.scrubNoFootageLogged = true;
25545
25679
  if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25680
+ dragId: this.scrubDragId,
25546
25681
  epochMs: target,
25547
25682
  nearestMs: decision.snapMs,
25548
25683
  distanceMs: decision.distanceMs
25549
25684
  });
25550
- else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25685
+ else this.deps.onLog?.("scrub-feeder:no-footage", {
25686
+ dragId: this.scrubDragId,
25687
+ epochMs: target
25688
+ });
25551
25689
  }
25552
25690
  return null;
25553
25691
  }
@@ -25563,11 +25701,15 @@ var RecordedFeeder = class {
25563
25701
  if (snapped.kind !== "segment") {
25564
25702
  if (!this.scrubNoFootageLogged) {
25565
25703
  this.scrubNoFootageLogged = true;
25566
- this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25704
+ this.deps.onLog?.("scrub-feeder:no-footage", {
25705
+ dragId: this.scrubDragId,
25706
+ epochMs: target
25707
+ });
25567
25708
  }
25568
25709
  return null;
25569
25710
  }
25570
25711
  this.deps.onLog?.("scrub-feeder:snapped", {
25712
+ dragId: this.scrubDragId,
25571
25713
  requestedMs: target,
25572
25714
  snappedMs: decision.snapMs
25573
25715
  });
@@ -25700,11 +25842,12 @@ var RecordedFeeder = class {
25700
25842
  */
25701
25843
  readsWholeSegment(loc) {
25702
25844
  if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
25703
- if (loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES) return true;
25845
+ if (loc.bytes === void 0 || loc.bytes <= 15e5) return true;
25704
25846
  this.scrubStats?.recordOversizeSegment();
25705
25847
  if (!this.scrubOversizeLogged) {
25706
25848
  this.scrubOversizeLogged = true;
25707
25849
  this.deps.onLog?.("scrub-feeder:oversize-segment", {
25850
+ dragId: this.scrubDragId,
25708
25851
  profile: this.deps.profile,
25709
25852
  startMs: loc.startMs,
25710
25853
  durMs: loc.durMs,
@@ -25740,6 +25883,7 @@ var RecordedFeeder = class {
25740
25883
  if (this.scrubProfileLogged) return;
25741
25884
  this.scrubProfileLogged = true;
25742
25885
  this.deps.onLog?.("scrub-feeder:profile-degraded", {
25886
+ dragId: this.scrubDragId,
25743
25887
  profile: this.deps.profile,
25744
25888
  scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
25745
25889
  });
@@ -25755,17 +25899,6 @@ var RecordedFeeder = class {
25755
25899
  return sorted[Math.floor(sorted.length / 2)] ?? null;
25756
25900
  }
25757
25901
  /**
25758
- * Is the disk the problem right now? The recent-read median against
25759
- * {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
25760
- * noise and the decision must not swing on it; and never before a few
25761
- * samples, or a cold first read would suppress the whole drag.
25762
- */
25763
- scrubReadsAreSlow() {
25764
- const median = this.scrubReadMedianMs();
25765
- if (median === null) return false;
25766
- return median >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25767
- }
25768
- /**
25769
25902
  * Fold one pointer sample into the drag's media velocity (ms of timeline per
25770
25903
  * ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
25771
25904
  */
@@ -25801,7 +25934,7 @@ var RecordedFeeder = class {
25801
25934
  if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
25802
25935
  const readMs = this.scrubReadMedianMs();
25803
25936
  if (readMs === null) return 0;
25804
- return Math.round(velocity * readMs);
25937
+ return scrubLeadDecision(velocity, readMs).leadMs;
25805
25938
  }
25806
25939
  /**
25807
25940
  * A single slow `locate` gets its own line, throttled per drag. Reserved for
@@ -25817,6 +25950,7 @@ var RecordedFeeder = class {
25817
25950
  if (this.scrubLastSlowLocateLogAtMs !== 0 && now - this.scrubLastSlowLocateLogAtMs < SCRUB_PATHOLOGY_LOG_INTERVAL_MS) return;
25818
25951
  this.scrubLastSlowLocateLogAtMs = now;
25819
25952
  this.deps.onLog?.("scrub-feeder:slow-locate", {
25953
+ dragId: this.scrubDragId,
25820
25954
  epochMs,
25821
25955
  locateMs: Math.round(locateMs),
25822
25956
  thresholdMs: threshold
@@ -25833,7 +25967,7 @@ var RecordedFeeder = class {
25833
25967
  * interval comes out warm for the next drag. Aborts (without landing — the
25834
25968
  * caller owns that) the moment a newer seek bumps the token.
25835
25969
  */
25836
- async animateSeekTravel(fromMs, toMs, token) {
25970
+ async animateSeekTravel(fromMs, toMs, token, dragId) {
25837
25971
  const cfg = this.deps.seekAnimation;
25838
25972
  const budgetMs = cfg?.budgetMs ?? SEEK_ANIMATION_BUDGET_MS;
25839
25973
  const tickMs = cfg?.tickMs ?? SEEK_ANIMATION_TICK_MS;
@@ -25865,6 +25999,7 @@ var RecordedFeeder = class {
25865
25999
  if (this.disposed || token !== this.seekToken) return;
25866
26000
  }
25867
26001
  if (stills > 0) this.deps.onLog?.("recorded:seek-travel", {
26002
+ dragId,
25868
26003
  fromMs,
25869
26004
  toMs,
25870
26005
  stills,
@@ -25959,79 +26094,41 @@ var RecordedFeeder = class {
25959
26094
  return stills;
25960
26095
  }
25961
26096
  /**
25962
- * Update the drag-direction run from the just-served target and, once the drag
25963
- * shows a sustained direction, kick off a single background prefetch of the
25964
- * NEXT fragment in that direction. Prefetch fills the cache only — it never
25965
- * pushes, so crossing into the neighbour later is an instant cache hit.
25966
- */
25967
- maybeScrubPrefetch(seg, target, gen) {
25968
- const prev = this.scrubPrevTarget;
25969
- this.scrubPrevTarget = target;
25970
- if (prev === null) {
25971
- this.scrubDirRun = 0;
25972
- return;
25973
- }
25974
- const stepMs = target - prev;
25975
- const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
25976
- if (dir === 0) {
25977
- this.scrubDirRun = 0;
25978
- return;
25979
- }
25980
- this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
25981
- if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
25982
- if (this.scrubReadsAreSlow()) {
25983
- this.scrubStats?.recordPrefetchSuppressed();
25984
- return;
25985
- }
25986
- if (this.scrubFillLoopActive) return;
25987
- const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
25988
- const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
25989
- for (let horizon = 1; horizon <= horizons; horizon++) {
25990
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
25991
- const probeMs = target + dir * aheadMs * horizon;
25992
- if (probeMs < 0) continue;
25993
- if (this.scrubPrefetchProbes.has(probeMs)) continue;
25994
- if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
25995
- if (this.scrubWarmCentreMs !== null && Math.abs(probeMs - this.scrubWarmCentreMs) > SCRUB_WARM_AROUND_MS) continue;
25996
- this.scrubPrefetch(probeMs, gen);
25997
- }
25998
- }
25999
- /**
26000
- * Speculative read: locate + read + demux the fragment covering `probeMs` into
26001
- * `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
26002
- * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
26003
- * swallowed (the fragment just demuxes on demand). Never pushes and never
26004
- * mutates latest-wins state.
26097
+ * The directional prefetch is GONE (2026-08-26), not disabled.
26005
26098
  *
26006
- * Every read it issues is COUNTED (`prefetchReads` / `prefetchBytes`), and the
26007
- * fragment it lands is remembered so a later tick hitting it counts as a
26008
- * `prefetchHit`. Speculation that cannot be shown to pay for itself is just
26009
- * load on the disk the drag is already waiting for.
26010
- */
26011
- async scrubPrefetch(probeMs, gen) {
26012
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
26013
- this.scrubPrefetchInFlight += 1;
26014
- this.scrubPrefetchProbes.add(probeMs);
26015
- try {
26016
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
26017
- if (this.scrubCache.covering(this.deps.profile, probeMs)) return;
26018
- const loc = await this.withTimeout(this.deps.locate({
26019
- deviceId: this.deps.deviceId,
26020
- profile: this.deps.profile,
26021
- epochMs: probeMs
26022
- }));
26023
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
26024
- if (loc.kind !== "segment") return;
26025
- const load = await this.loadScrubFragment(loc, probeMs);
26026
- if (load !== null) this.scrubStats?.recordPrefetchRead(load.readBytes);
26027
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
26028
- this.scrubCache.put(this.deps.profile, load.seg);
26029
- this.scrubPrefetched.add(load.seg.startMs);
26030
- } catch {} finally {
26031
- this.scrubPrefetchInFlight -= 1;
26032
- this.scrubPrefetchProbes.delete(probeMs);
26033
- }
26034
- }
26099
+ * It guessed where the next tick would land from the drag's recent
26100
+ * direction. Measured on the operator's own drags the day it was removed:
26101
+ * **25 speculative reads bought 3 hits** 88% waste, and roughly 60% of all
26102
+ * disk work during a gesture, queued as random seeks ahead of the read the
26103
+ * finger was actually blocked on, on a 5400 rpm SMR disk.
26104
+ *
26105
+ * The regression tests it passed drove a drag at CONSTANT step size, which is
26106
+ * the one regime where direction predicts position. A real backward scrub is
26107
+ * exploratory — 26 of 29 jumps backward, step size set by where the operator
26108
+ * decided to look next — so the premise never held in the field.
26109
+ *
26110
+ * Scrypted has no predictive prefetch either: their read-ahead is a stream
26111
+ * `highWaterMark`, and a short forward move is a fast-forward on an open fd
26112
+ * rather than a seek. They deleted the problem instead of solving it.
26113
+ *
26114
+ * What REMAINS and must not be confused with this: the WINDOW warm
26115
+ * (`runScrubFillLoop`) — a sequential walk of the window around the playhead.
26116
+ *
26117
+ * It was described here, wrongly, as running "when no drag is live, so it
26118
+ * competes with nothing". It does not: its survival condition is
26119
+ * `this.scrubbing || this.idleWarm`, and within hours of this deletion it had
26120
+ * inherited the deleted predictor's role 1.9 MB and 2.4 MB of reads on two
26121
+ * measured drags, 38-45% of the gesture's I/O, zero cache hits. Deleting one
26122
+ * speculator without looking at the other left the contention exactly where
26123
+ * it was. It now waits out a moving finger (`fingerIsMoving`), which is what
26124
+ * the sentence above claimed all along.
26125
+ *
26126
+ * Cost of the removal, bounded and accepted: a careful short-step drag no
26127
+ * longer warms the fragment across the edge, so the first tick past a
26128
+ * boundary may pay one cold read. Its own regression test shows that case is
26129
+ * served by the MEMO (`coverMemo ≥ 5`) with the neighbour warm worth ≤2
26130
+ * reads.
26131
+ */
26035
26132
  /**
26036
26133
  * Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
26037
26134
  * paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
@@ -26215,7 +26312,7 @@ var RecordedFeeder = class {
26215
26312
  this.advanceLoop(gen, initial).catch(() => {
26216
26313
  if (!this.disposed && gen === this.advanceToken) {
26217
26314
  this.stopPositionReports();
26218
- this.deps.onState("error");
26315
+ this.emitState("error", "advance-loop-failed", this.seekToken, this.scrubDragId);
26219
26316
  }
26220
26317
  });
26221
26318
  }
@@ -26232,7 +26329,7 @@ var RecordedFeeder = class {
26232
26329
  if (prepared === null) return;
26233
26330
  if (prepared === "ended") {
26234
26331
  this.stopPositionReports();
26235
- this.deps.onState("ended");
26332
+ this.emitState("ended", "advance-ended", this.seekToken, this.scrubDragId);
26236
26333
  return;
26237
26334
  }
26238
26335
  if (isResumedSegment) {} else {
@@ -26509,7 +26606,10 @@ var RecordedFeeder = class {
26509
26606
  const mark = this.scrubDemuxMark;
26510
26607
  const demux = mark && this.deps.demuxTally ? this.deps.demuxTally.deltaSince(mark) : void 0;
26511
26608
  this.scrubDemuxMark = null;
26512
- this.deps.onLog?.("scrub-feeder:drag-end", stats.toLogFields(performance.now() - this.scrubStartedAtMs, demux));
26609
+ this.deps.onLog?.("scrub-feeder:drag-end", {
26610
+ dragId: this.scrubDragId,
26611
+ ...stats.toLogFields(performance.now() - this.scrubStartedAtMs, demux)
26612
+ });
26513
26613
  }
26514
26614
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
26515
26615
  dispose() {
@@ -26522,7 +26622,6 @@ var RecordedFeeder = class {
26522
26622
  this.scrubToken++;
26523
26623
  this.scrubSegment = null;
26524
26624
  this.scrubPrevTarget = null;
26525
- this.scrubDirRun = 0;
26526
26625
  this.scrubCursor = null;
26527
26626
  this.scrubNoFootageLogged = false;
26528
26627
  this.scrubParkLogged = false;
@@ -26614,10 +26713,11 @@ var TimelineSession = class {
26614
26713
  await this.playAt(msg.epoch, this.profile, { travelFromMs: this.lastPositionMs });
26615
26714
  break;
26616
26715
  case "scrubCommit":
26617
- await this.playAt(msg.epoch, this.profile);
26716
+ if (msg.dragId !== void 0) await this.playAt(msg.epoch, this.profile, { dragId: msg.dragId });
26717
+ else await this.playAt(msg.epoch, this.profile);
26618
26718
  break;
26619
26719
  case "scrubStart":
26620
- (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
26720
+ (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub(msg.dragId);
26621
26721
  break;
26622
26722
  case "scrubTo": {
26623
26723
  const hint = msg.m !== void 0 && msg.v !== void 0 ? {
@@ -26871,7 +26971,7 @@ function createOffThreadJsParse(deps) {
26871
26971
  if (!w) return deps.parseInProcess(bytes);
26872
26972
  const id = nextId;
26873
26973
  nextId += 1;
26874
- const copy = bytes.slice();
26974
+ const copy = new Uint8Array(bytes);
26875
26975
  const request = {
26876
26976
  id,
26877
26977
  bytes: copy
@@ -28308,14 +28408,21 @@ var BrokerWebrtcServer = class {
28308
28408
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
28309
28409
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
28310
28410
  },
28311
- onState: (s) => {
28312
- sessionLogger.info("recorded: feeder state", { meta: {
28313
- sessionId,
28314
- deviceId,
28315
- profile,
28316
- state: s
28317
- } });
28318
- timeline._emitState(s);
28411
+ onState: (t) => {
28412
+ sessionLogger.info("recorded: feeder state", {
28413
+ tags: { deviceId },
28414
+ meta: {
28415
+ sessionId,
28416
+ deviceId,
28417
+ profile,
28418
+ from: t.from,
28419
+ to: t.to,
28420
+ reason: t.reason,
28421
+ seekToken: t.seekToken,
28422
+ dragId: t.dragId
28423
+ }
28424
+ });
28425
+ timeline._emitState(t.to);
28319
28426
  },
28320
28427
  onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
28321
28428
  onLog: (msg, data) => sessionLogger.info(msg, {