@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
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-D9x2_csL.js");
5
+ const require_dist = require("../dist-DOw-izoI.js");
6
6
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
9
- const require_segment_demux_js = require("../segment-demux-js-DiLDoV9_.js");
9
+ const require_segment_demux_js = require("../segment-demux-js-C2jBBqGW.js");
10
10
  let node_crypto = require("node:crypto");
11
11
  node_crypto = require_dist.__toESM(node_crypto, 1);
12
12
  let node_child_process = require("node:child_process");
@@ -23479,9 +23479,13 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
23479
23479
  }),
23480
23480
  require_dist.object({
23481
23481
  t: require_dist.literal("scrubCommit"),
23482
- epoch: require_dist.number()
23482
+ epoch: require_dist.number(),
23483
+ dragId: require_dist.string().optional()
23484
+ }),
23485
+ require_dist.object({
23486
+ t: require_dist.literal("scrubStart"),
23487
+ dragId: require_dist.string().optional()
23483
23488
  }),
23484
- require_dist.object({ t: require_dist.literal("scrubStart") }),
23485
23489
  require_dist.object({
23486
23490
  t: require_dist.literal("scrubTo"),
23487
23491
  epoch: require_dist.number(),
@@ -23633,104 +23637,6 @@ var MonotonicClock = class {
23633
23637
  this.rate = rate;
23634
23638
  }
23635
23639
  };
23636
- var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
23637
- var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
23638
- /** Demuxed bytes an entry holds — what the byte bound actually counts. */
23639
- function fragmentBytes(frag) {
23640
- let total = 0;
23641
- for (const au of frag.aus) total += au.data.length;
23642
- return total;
23643
- }
23644
- var ScrubFragmentCache = class {
23645
- maxBytes;
23646
- otherMaxBytes;
23647
- map = /* @__PURE__ */ new Map();
23648
- /**
23649
- * Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
23650
- * `high` entry must never be able to evict the `low` segments the drag is
23651
- * running on, and the reverse would be just as wrong.
23652
- */
23653
- bytesByProfile = /* @__PURE__ */ new Map();
23654
- constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
23655
- this.maxBytes = maxBytes;
23656
- this.otherMaxBytes = otherMaxBytes;
23657
- }
23658
- /** The byte budget for a profile — the scrub profile gets the real one. */
23659
- budgetFor(profile) {
23660
- return profile === "low" ? this.maxBytes : this.otherMaxBytes;
23661
- }
23662
- key(profile, startMs) {
23663
- return `${profile}|${startMs}`;
23664
- }
23665
- /** Exact-start lookup with LRU touch (re-insertion marks most recent). */
23666
- get(profile, startMs) {
23667
- const k = this.key(profile, startMs);
23668
- const frag = this.map.get(k);
23669
- if (!frag) return null;
23670
- this.map.delete(k);
23671
- this.map.set(k, frag);
23672
- return frag;
23673
- }
23674
- /**
23675
- * The cached fragment COVERING an instant, if any — the lookup that lets a
23676
- * warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
23677
- * saturated hub). Coverage is proof of footage: recorded fragments are
23678
- * immutable, so a hit needs no re-validation. LRU-touches the hit.
23679
- */
23680
- covering(profile, epochMs) {
23681
- for (const [k, frag] of this.map) {
23682
- if (!k.startsWith(`${profile}|`)) continue;
23683
- if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
23684
- this.map.delete(k);
23685
- this.map.set(k, frag);
23686
- return frag;
23687
- }
23688
- }
23689
- return null;
23690
- }
23691
- /** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
23692
- put(profile, frag) {
23693
- const k = this.key(profile, frag.startMs);
23694
- const held = this.map.get(k);
23695
- let bytes = this.bytesByProfile.get(profile) ?? 0;
23696
- if (held) bytes -= fragmentBytes(held);
23697
- this.map.delete(k);
23698
- this.map.set(k, frag);
23699
- bytes += fragmentBytes(frag);
23700
- this.bytesByProfile.set(profile, bytes);
23701
- const budget = this.budgetFor(profile);
23702
- const prefix = `${profile}|`;
23703
- while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
23704
- const oldest = this.oldestKeyWithPrefix(prefix);
23705
- if (oldest === void 0) break;
23706
- const evicted = this.map.get(oldest);
23707
- this.map.delete(oldest);
23708
- if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
23709
- }
23710
- }
23711
- oldestKeyWithPrefix(prefix) {
23712
- for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
23713
- }
23714
- entriesFor(profile) {
23715
- const prefix = `${profile}|`;
23716
- let n = 0;
23717
- for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
23718
- return n;
23719
- }
23720
- /** Demuxed bytes currently held across every profile. */
23721
- get byteSize() {
23722
- let total = 0;
23723
- for (const bytes of this.bytesByProfile.values()) total += bytes;
23724
- return total;
23725
- }
23726
- /** Demuxed bytes held for one profile — the number its MB bound governs. */
23727
- byteSizeFor(profile) {
23728
- return this.bytesByProfile.get(profile) ?? 0;
23729
- }
23730
- get size() {
23731
- return this.map.size;
23732
- }
23733
- };
23734
23640
  /**
23735
23641
  * A bounded numeric series. Exact count/sum/min/max forever; the median comes
23736
23642
  * from the first {@link SCRUB_PHASE_SAMPLE_CAP} samples.
@@ -23796,6 +23702,9 @@ var ScrubDragStats = class {
23796
23702
  prefetchBytes = 0;
23797
23703
  prefetchHits = 0;
23798
23704
  prefetchSuppressed = 0;
23705
+ warmReads = 0;
23706
+ warmBytes = 0;
23707
+ warmHits = 0;
23799
23708
  ackTimeouts = 0;
23800
23709
  deadlineMisses = 0;
23801
23710
  retargets = 0;
@@ -23862,6 +23771,15 @@ var ScrubDragStats = class {
23862
23771
  recordPrefetchHit() {
23863
23772
  this.prefetchHits += 1;
23864
23773
  }
23774
+ /** One window-warm read taken while the drag was live, and what it cost. */
23775
+ recordWarmRead(bytes) {
23776
+ this.warmReads += 1;
23777
+ this.warmBytes += bytes;
23778
+ }
23779
+ /** A tick served by a fragment the window warm had already landed. */
23780
+ recordWarmHit() {
23781
+ this.warmHits += 1;
23782
+ }
23865
23783
  /** Pushes written off after the ack timeout — dropped work, never silent. */
23866
23784
  recordAckTimeout(pushes) {
23867
23785
  this.ackTimeouts += pushes;
@@ -23944,6 +23862,9 @@ var ScrubDragStats = class {
23944
23862
  prefetchBytes: this.prefetchBytes,
23945
23863
  prefetchHits: this.prefetchHits,
23946
23864
  prefetchSuppressed: this.prefetchSuppressed,
23865
+ warmReads: this.warmReads,
23866
+ warmBytes: this.warmBytes,
23867
+ warmHits: this.warmHits,
23947
23868
  ackTimeouts: this.ackTimeouts,
23948
23869
  deadlineMisses: this.deadlineMisses,
23949
23870
  retargets: this.retargets,
@@ -23963,8 +23884,158 @@ var ScrubDragStats = class {
23963
23884
  };
23964
23885
  }
23965
23886
  };
23887
+ var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
23888
+ var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
23889
+ /** Demuxed bytes an entry holds — what the byte bound actually counts. */
23890
+ function fragmentBytes(frag) {
23891
+ let total = 0;
23892
+ for (const au of frag.aus) total += au.data.length;
23893
+ return total;
23894
+ }
23895
+ var ScrubFragmentCache = class {
23896
+ maxBytes;
23897
+ otherMaxBytes;
23898
+ map = /* @__PURE__ */ new Map();
23899
+ /**
23900
+ * Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
23901
+ * `high` entry must never be able to evict the `low` segments the drag is
23902
+ * running on, and the reverse would be just as wrong.
23903
+ */
23904
+ bytesByProfile = /* @__PURE__ */ new Map();
23905
+ constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
23906
+ this.maxBytes = maxBytes;
23907
+ this.otherMaxBytes = otherMaxBytes;
23908
+ }
23909
+ /** The byte budget for a profile — the scrub profile gets the real one. */
23910
+ budgetFor(profile) {
23911
+ return profile === "low" ? this.maxBytes : this.otherMaxBytes;
23912
+ }
23913
+ key(profile, startMs) {
23914
+ return `${profile}|${startMs}`;
23915
+ }
23916
+ /** Exact-start lookup with LRU touch (re-insertion marks most recent). */
23917
+ get(profile, startMs) {
23918
+ const k = this.key(profile, startMs);
23919
+ const frag = this.map.get(k);
23920
+ if (!frag) return null;
23921
+ this.map.delete(k);
23922
+ this.map.set(k, frag);
23923
+ return frag;
23924
+ }
23925
+ /**
23926
+ * The cached fragment COVERING an instant, if any — the lookup that lets a
23927
+ * warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
23928
+ * saturated hub). Coverage is proof of footage: recorded fragments are
23929
+ * immutable, so a hit needs no re-validation. LRU-touches the hit.
23930
+ */
23931
+ covering(profile, epochMs) {
23932
+ for (const [k, frag] of this.map) {
23933
+ if (!k.startsWith(`${profile}|`)) continue;
23934
+ if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
23935
+ this.map.delete(k);
23936
+ this.map.set(k, frag);
23937
+ return frag;
23938
+ }
23939
+ }
23940
+ return null;
23941
+ }
23942
+ /** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
23943
+ put(profile, frag) {
23944
+ const k = this.key(profile, frag.startMs);
23945
+ const held = this.map.get(k);
23946
+ let bytes = this.bytesByProfile.get(profile) ?? 0;
23947
+ if (held) bytes -= fragmentBytes(held);
23948
+ this.map.delete(k);
23949
+ this.map.set(k, frag);
23950
+ bytes += fragmentBytes(frag);
23951
+ this.bytesByProfile.set(profile, bytes);
23952
+ const budget = this.budgetFor(profile);
23953
+ const prefix = `${profile}|`;
23954
+ while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
23955
+ const oldest = this.oldestKeyWithPrefix(prefix);
23956
+ if (oldest === void 0) break;
23957
+ const evicted = this.map.get(oldest);
23958
+ this.map.delete(oldest);
23959
+ if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
23960
+ }
23961
+ }
23962
+ oldestKeyWithPrefix(prefix) {
23963
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
23964
+ }
23965
+ entriesFor(profile) {
23966
+ const prefix = `${profile}|`;
23967
+ let n = 0;
23968
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
23969
+ return n;
23970
+ }
23971
+ /** Demuxed bytes currently held across every profile. */
23972
+ get byteSize() {
23973
+ let total = 0;
23974
+ for (const bytes of this.bytesByProfile.values()) total += bytes;
23975
+ return total;
23976
+ }
23977
+ /** Demuxed bytes held for one profile — the number its MB bound governs. */
23978
+ byteSizeFor(profile) {
23979
+ return this.bytesByProfile.get(profile) ?? 0;
23980
+ }
23981
+ get size() {
23982
+ return this.map.size;
23983
+ }
23984
+ };
23985
+ /**
23986
+ * `velocity` is ms of timeline per ms of wall clock, signed. `readMs` is what a
23987
+ * read costs on this disk right now. Either being null means "not enough is
23988
+ * known to aim" — which is NOT saturation: an unknown velocity must not switch
23989
+ * speculation off, or the first ticks of every drag would lose it.
23990
+ */
23991
+ function scrubLeadDecision(velocity, readMs) {
23992
+ if (velocity === null || readMs === null) return {
23993
+ leadMs: 0,
23994
+ saturated: false
23995
+ };
23996
+ const raw = Math.round(velocity * readMs);
23997
+ if (Math.abs(raw) > 3e4) return {
23998
+ leadMs: 0,
23999
+ saturated: true
24000
+ };
24001
+ return {
24002
+ leadMs: raw,
24003
+ saturated: false
24004
+ };
24005
+ }
23966
24006
  //#endregion
23967
24007
  //#region src/stream-broker/recorded/recorded-feeder.ts
24008
+ /**
24009
+ * READ-AHEAD LEAD — the fix for "the picture chases the finger".
24010
+ *
24011
+ * A scrub frame is always the frame a read STARTED for, one read time ago.
24012
+ * On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
24013
+ * a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
24014
+ * the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
24015
+ * behind the finger. Nothing about that is I/O — it is aiming at where the
24016
+ * finger WAS.
24017
+ *
24018
+ * So a steady drag reads where the finger WILL BE: `mediaVelocity ×
24019
+ * medianRecentReadMs`. It costs nothing (the same one read, at a different
24020
+ * offset), and it is bounded by the two things that make it a prediction
24021
+ * rather than a guess:
24022
+ *
24023
+ * - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
24024
+ * samples, so a jittery or reversing drag never leads. Pointer samples and
24025
+ * not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
24026
+ * ~4 pushes, so a gate counted in pushes has no runway inside the gesture
24027
+ * it is supposed to help — measured in the drag bench, it engaged once, at
24028
+ * the very end;
24029
+ * - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
24030
+ * is derived from this disk right now and not from a constant. ONE is
24031
+ * enough: against a read cost that is unknown, a one-sample estimate is not
24032
+ * a good prediction — it is simply a far better one than assuming zero.
24033
+ *
24034
+ * Overshoot is self-correcting: the retarget below serves the newest target
24035
+ * the landed fragment covers, so a lead that is slightly long lands on the
24036
+ * finger exactly, and one that is wrong costs the same single read it would
24037
+ * have cost anyway.
24038
+ */
23968
24039
  var SEGMENT_TIMEOUT_MS = 8e3;
23969
24040
  /**
23970
24041
  * Deadline for the recorded-AUDIO demux on the `reusedWhole` seek path, and
@@ -24065,30 +24136,6 @@ var SEEK_ANIMATION_MIN_DISTANCE_MS = 8e3;
24065
24136
  */
24066
24137
  var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
24067
24138
  /**
24068
- * Consecutive same-direction `scrubTo` deltas required before a direction-aware
24069
- * prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
24070
- * sustained direction, so a jittery/reversing drag never wastes the slot.
24071
- */
24072
- var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
24073
- /**
24074
- * How many speculative GOP reads may be in flight at once, i.e. how many drag
24075
- * steps ahead the feeder reads.
24076
- *
24077
- * The scrub tick IS the read: measured on salone 590 over `low`,
24078
- * `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
24079
- * the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
24080
- * hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
24081
- * tens of seconds apart revisits nothing. Reading N steps ahead is the only
24082
- * thing that converts wall-clock the drag is ALREADY spending into pushes.
24083
- *
24084
- * 2 and not more: these reads land on the same spindles the recorder is writing
24085
- * every camera to, and past a small number concurrent random reads stop
24086
- * overlapping and start thrashing. Raise it only against a measured
24087
- * `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
24088
- * both halves of the evidence.
24089
- */
24090
- var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24091
- /**
24092
24139
  * The profile whose scrub read unit is the WHOLE SEGMENT.
24093
24140
  *
24094
24141
  * Every drag is pinned to `low` by the client, and a `low` segment is ~230 KB
@@ -24124,14 +24171,30 @@ var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
24124
24171
  * (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
24125
24172
  * instead of being inferred from a falling hit rate.
24126
24173
  *
24127
- * The real fix, when 60 s segments land, is a read unit that is a BYTE BUDGET
24128
- * over the fragment's own keyframe table — "as many consecutive GOPs as fit in
24129
- * N bytes" — which needs a provider method we do not have
24130
- * (`readGopBytes` reads exactly one GOP). Until then this is an absolute cap
24131
- * on a single read and the log is the guard.
24174
+ * The real fix, for when 60 s segments land, is a read unit that is a BYTE
24175
+ * BUDGET over the fragment's own keyframe table — "as many consecutive GOPs
24176
+ * as fit in N bytes" — computed from `tfra` offsets alone, no payload read
24177
+ * (`fragmentsWithinBudget`, `recorder/mfra-index.ts`). `readGopBytes` now uses
24178
+ * exactly this budget on the fallback path below: it no longer reads exactly
24179
+ * one GOP, it reads as many CONSECUTIVE fragments as fit this same ceiling, in
24180
+ * one range read (`gop-range-read.ts`'s `maxBytes`). The recorder-side handler
24181
+ * (`recording-provider.ts`) declares its OWN copy of this number
24182
+ * (`SCRUB_GOP_BUDGET_MAX_BYTES` in `gop-range-read.ts`) rather than importing
24183
+ * this module — the client feeder is a different addon-internal concern, and
24184
+ * pulling its whole dependency graph in for one number is the wrong
24185
+ * direction. Exported here so a future caller in THIS process can reuse it;
24186
+ * if D163's number moves, move both.
24132
24187
  */
24133
24188
  var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
24134
24189
  /**
24190
+ * How still the finger must be before the window warm may take the disk back.
24191
+ *
24192
+ * Long enough that it does not thrash on the gap between two ticks of a slow
24193
+ * drag (tickMsP50 was 120-129 ms on the measured drags), short enough that a
24194
+ * pause the operator would call "stopped" is warm again before they move.
24195
+ */
24196
+ var SCRUB_WARM_QUIET_MS = 300;
24197
+ /**
24135
24198
  * How far the entry warm-up reaches, backward and forward from the playhead the
24136
24199
  * drag starts on.
24137
24200
  *
@@ -24166,17 +24229,6 @@ var SCRUB_WARM_MAX_SEGMENTS = Math.max(1, Math.floor(SCRUB_FRAGMENT_CACHE_MAX_BY
24166
24229
  */
24167
24230
  var IDLE_WARM_READ_GAP_MS = 250;
24168
24231
  /**
24169
- * Median recent read time at or above which the feeder STOPS speculating.
24170
- *
24171
- * Speculation is a bet that spare I/O exists. When reads are this slow there is
24172
- * no spare I/O — the array is saturated — and a prefetch is not free work, it
24173
- * is another random seek queued ahead of the read the operator is waiting on.
24174
- * The field regime that forced this: 54 prefetch reads for 9 hits with the disk
24175
- * at 98% busy. Suppression is counted (`prefetchSuppressed`) so the decision is
24176
- * visible, and it lifts by itself as soon as reads come back.
24177
- */
24178
- var SCRUB_SLOW_READ_MS = 600;
24179
- /**
24180
24232
  * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
24181
24233
  * that SWEEPS predicted frames instead of jumping. A steady forward drag is
24182
24234
  * playback with a moving target: once a keyframe decoded, the predicted frames
@@ -24249,37 +24301,6 @@ var SCRUB_RIDE_WINDOW_MS = 1e4;
24249
24301
  * moved on to. At the cap, a tick rides the newest in-flight read instead.
24250
24302
  */
24251
24303
  var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
24252
- /**
24253
- * READ-AHEAD LEAD — the fix for "the picture chases the finger".
24254
- *
24255
- * A scrub frame is always the frame a read STARTED for, one read time ago.
24256
- * On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
24257
- * a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
24258
- * the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
24259
- * behind the finger. Nothing about that is I/O — it is aiming at where the
24260
- * finger WAS.
24261
- *
24262
- * So a steady drag reads where the finger WILL BE: `mediaVelocity ×
24263
- * medianRecentReadMs`. It costs nothing (the same one read, at a different
24264
- * offset), and it is bounded by the two things that make it a prediction
24265
- * rather than a guess:
24266
- *
24267
- * - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
24268
- * samples, so a jittery or reversing drag never leads. Pointer samples and
24269
- * not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
24270
- * ~4 pushes, so a gate counted in pushes has no runway inside the gesture
24271
- * it is supposed to help — measured in the drag bench, it engaged once, at
24272
- * the very end;
24273
- * - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
24274
- * is derived from this disk right now and not from a constant. ONE is
24275
- * enough: against a read cost that is unknown, a one-sample estimate is not
24276
- * a good prediction — it is simply a far better one than assuming zero.
24277
- *
24278
- * Overshoot is self-correcting: the retarget below serves the newest target
24279
- * the landed fragment covers, so a lead that is slightly long lands on the
24280
- * finger exactly, and one that is wrong costs the same single read it would
24281
- * have cost anyway.
24282
- */
24283
24304
  var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
24284
24305
  /**
24285
24306
  * Smoothing for the media-velocity estimate (ms of timeline per ms of wall
@@ -24512,25 +24533,6 @@ var RecordedFeeder = class {
24512
24533
  scrubLastPushLogAtMs = 0;
24513
24534
  scrubLastSlowLocateLogAtMs = 0;
24514
24535
  /**
24515
- * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
24516
- * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
24517
- * a reversal or an in-fragment no-move resets it.
24518
- */
24519
- scrubDirRun = 0;
24520
- /**
24521
- * Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
24522
- * these are the SPECULATIVE concurrency slots, and they only fill
24523
- * `scrubCache` — they never push, never touch the pending/timer/latest-wins
24524
- * state, and never block the live coalesced tick.
24525
- *
24526
- * More than one because the read IS the tick (263 of 270 ms measured): with a
24527
- * single slot the drag can never go faster than one cold read, however well
24528
- * the speculation guesses.
24529
- */
24530
- scrubPrefetchInFlight = 0;
24531
- /** Probe instants currently being read, so two horizons never duplicate one. */
24532
- scrubPrefetchProbes = /* @__PURE__ */ new Set();
24533
- /**
24534
24536
  * Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
24535
24537
  * hits one of them is what makes speculation provably worth its I/O
24536
24538
  * (`prefetchHits`) — without this the feeder could double the load on the disk
@@ -24580,6 +24582,18 @@ var RecordedFeeder = class {
24580
24582
  scrubFillLoopActive = false;
24581
24583
  scrubWarmBackMs = SCRUB_WARM_BACK_MS;
24582
24584
  scrubWarmFwdMs = SCRUB_WARM_FWD_MS;
24585
+ /**
24586
+ * The client-supplied id of the CURRENT (or most recently active) scrub
24587
+ * gesture, set by `beginScrub(dragId)`. Printed on every `scrub-feeder:*`
24588
+ * line — ticks, warm fill, park/deadline/ack pathologies, and the drag-end
24589
+ * aggregate — so a drag can be isolated in the logs without depending on
24590
+ * wall-clock proximity. `undefined` outside a drag (and for a feeder whose
24591
+ * caller never sends one — additive, optional, see D1 in the plan).
24592
+ */
24593
+ scrubDragId;
24594
+ /** This feeder's own last-announced state — the `from` half of every
24595
+ * {@link FeederStateTransition}. `null` before the first transition. */
24596
+ lastAnnouncedState = null;
24583
24597
  constructor(deps) {
24584
24598
  this.deps = deps;
24585
24599
  this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
@@ -24597,6 +24611,50 @@ var RecordedFeeder = class {
24597
24611
  if (typeof pcmu === "function") return (data, rtpTs) => pcmu.call(sink, data, rtpTs);
24598
24612
  }
24599
24613
  /**
24614
+ * Emit one state transition through `onState` — see
24615
+ * {@link FeederStateTransition} for why `from`/`reason`/`seekToken` exist.
24616
+ * The ONLY place `this.lastAnnouncedState` is written, so every announced
24617
+ * state (wherever it is announced from) is captured as the next
24618
+ * transition's `from`.
24619
+ */
24620
+ emitState(to, reason, seekToken, dragId) {
24621
+ const from = this.lastAnnouncedState;
24622
+ this.lastAnnouncedState = to;
24623
+ this.deps.onState({
24624
+ from,
24625
+ to,
24626
+ reason,
24627
+ seekToken,
24628
+ dragId
24629
+ });
24630
+ }
24631
+ /**
24632
+ * ONE line per `seek()` call, unconditionally — the C3 rendiconto. Before
24633
+ * this, a commit's outcome was told by WHICHEVER of several lines happened
24634
+ * to fire: `recorded:seek-ready` only on success, `recorded:seek-abandoned`
24635
+ * only when a later token/dispose superseded this one, `recorded:seek-no-footage`
24636
+ * / `recorded:snap-too-far` only on a gap — and a raw thrown error (or an
24637
+ * error on an ALREADY-abandoned commit) logged NOTHING at all. That silent
24638
+ * shape is exactly the 2026-08-26 stall: two commits reached
24639
+ * `playback:intent` and never `playback:ready`, with nothing in the feeder's
24640
+ * own logs saying why. Every return path in `seek()` now calls this before
24641
+ * it exits, so a commit that never lands still leaves one line naming what
24642
+ * happened to it.
24643
+ */
24644
+ logSeekOutcome(token, seekStartedAt, epochMs, dragId, outcome, state) {
24645
+ this.deps.onLog?.("recorded:seek-outcome", {
24646
+ dragId,
24647
+ epochMs,
24648
+ outcome,
24649
+ state,
24650
+ disposed: this.disposed,
24651
+ superseded: token !== this.seekToken,
24652
+ tokenAtStart: token,
24653
+ tokenNow: this.seekToken,
24654
+ totalMs: Math.round(performance.now() - seekStartedAt)
24655
+ });
24656
+ }
24657
+ /**
24600
24658
  * Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
24601
24659
  * the FIRST located segment is fed; the forward `advance` loop is started
24602
24660
  * fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
@@ -24609,6 +24667,7 @@ var RecordedFeeder = class {
24609
24667
  let loadMs = 0;
24610
24668
  let firstFrameMs = 0;
24611
24669
  const token = ++this.seekToken;
24670
+ const dragId = opts?.dragId;
24612
24671
  const wasScrubbing = this.scrubbing;
24613
24672
  if (this.scrubbing) {
24614
24673
  this.emitDragEnd();
@@ -24620,7 +24679,6 @@ var RecordedFeeder = class {
24620
24679
  this.clearScrubAckTimeout();
24621
24680
  this.scrubPendingHint = null;
24622
24681
  this.scrubPrevTarget = null;
24623
- this.scrubDirRun = 0;
24624
24682
  if (this.scrubTimer) {
24625
24683
  clearTimeout(this.scrubTimer);
24626
24684
  this.scrubTimer = null;
@@ -24630,7 +24688,7 @@ var RecordedFeeder = class {
24630
24688
  this.inFlightSeekEpochMs = epochMs;
24631
24689
  let snapInfo = null;
24632
24690
  try {
24633
- this.deps.onState("loading");
24691
+ this.emitState("loading", "seek-start", token, dragId);
24634
24692
  if (this.clockHasAdvanced) {
24635
24693
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
24636
24694
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
@@ -24642,21 +24700,29 @@ var RecordedFeeder = class {
24642
24700
  epochMs
24643
24701
  }));
24644
24702
  locateMs += performance.now() - locateStartedAt;
24645
- if (this.disposed || token !== this.seekToken) return;
24703
+ if (this.disposed || token !== this.seekToken) {
24704
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24705
+ return;
24706
+ }
24646
24707
  let landMs = epochMs;
24647
24708
  if (loc.kind === "gap") {
24648
24709
  const decision = resolveSnapTarget(loc, epochMs);
24649
24710
  if (decision.kind !== "snap") {
24650
24711
  if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
24712
+ dragId,
24651
24713
  requestedMs: epochMs,
24652
24714
  nearestMs: decision.snapMs,
24653
24715
  distanceMs: decision.distanceMs
24654
24716
  });
24655
- else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
24717
+ else this.deps.onLog?.("recorded:seek-no-footage", {
24718
+ dragId,
24719
+ requestedMs: epochMs
24720
+ });
24656
24721
  this.currentSegment = null;
24657
24722
  this.lastFedVideoIdx = -1;
24658
24723
  this.stopPositionReports();
24659
- this.deps.onState("gap");
24724
+ this.emitState("gap", decision.kind === "too-far" ? "seek-snap-too-far" : "seek-no-footage", token, dragId);
24725
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24660
24726
  return;
24661
24727
  }
24662
24728
  const snapLocateStartedAt = performance.now();
@@ -24666,19 +24732,25 @@ var RecordedFeeder = class {
24666
24732
  epochMs: decision.snapMs
24667
24733
  }));
24668
24734
  locateMs += performance.now() - snapLocateStartedAt;
24669
- if (this.disposed || token !== this.seekToken) return;
24735
+ if (this.disposed || token !== this.seekToken) {
24736
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24737
+ return;
24738
+ }
24670
24739
  if (snapped.kind !== "segment") {
24671
24740
  this.deps.onLog?.("recorded:seek-no-footage", {
24741
+ dragId,
24672
24742
  requestedMs: epochMs,
24673
24743
  snappedMs: decision.snapMs
24674
24744
  });
24675
24745
  this.currentSegment = null;
24676
24746
  this.lastFedVideoIdx = -1;
24677
24747
  this.stopPositionReports();
24678
- this.deps.onState("gap");
24748
+ this.emitState("gap", "seek-snap-target-no-footage", token, dragId);
24749
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24679
24750
  return;
24680
24751
  }
24681
24752
  this.deps.onLog?.("recorded:seek-snapped", {
24753
+ dragId,
24682
24754
  requestedMs: epochMs,
24683
24755
  snappedMs: decision.snapMs
24684
24756
  });
@@ -24692,9 +24764,12 @@ var RecordedFeeder = class {
24692
24764
  const travelFromMs = opts?.travelFromMs ?? (this.clockHasAdvanced ? this.cursorMs : null);
24693
24765
  if (!wasScrubbing && travelFromMs !== null) {
24694
24766
  const travelStartedAt = performance.now();
24695
- await this.animateSeekTravel(travelFromMs, landMs, token);
24767
+ await this.animateSeekTravel(travelFromMs, landMs, token, dragId);
24696
24768
  travelMs = performance.now() - travelStartedAt;
24697
- if (this.disposed || token !== this.seekToken) return;
24769
+ if (this.disposed || token !== this.seekToken) {
24770
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24771
+ return;
24772
+ }
24698
24773
  }
24699
24774
  this.cursorMs = loc.startMs;
24700
24775
  const loadStartedAt = performance.now();
@@ -24711,7 +24786,10 @@ var RecordedFeeder = class {
24711
24786
  prefixSource = "cache";
24712
24787
  } else {
24713
24788
  const fragmentLoad = await this.loadScrubFragment(loc, landMs);
24714
- if (fragmentLoad === null) return;
24789
+ if (fragmentLoad === null) {
24790
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24791
+ return;
24792
+ }
24715
24793
  fragment = fragmentLoad.seg;
24716
24794
  this.scrubCache.put(this.deps.profile, fragment);
24717
24795
  prefixSource = fragmentLoad.wholeSegmentRead ? "whole" : "gop";
@@ -24720,7 +24798,10 @@ var RecordedFeeder = class {
24720
24798
  bytes: fragmentLoad.rawBytes
24721
24799
  };
24722
24800
  }
24723
- if (this.disposed || token !== this.seekToken) return;
24801
+ if (this.disposed || token !== this.seekToken) {
24802
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24803
+ return;
24804
+ }
24724
24805
  const fragmentLoc = {
24725
24806
  kind: "segment",
24726
24807
  startMs: fragment.startMs,
@@ -24731,7 +24812,7 @@ var RecordedFeeder = class {
24731
24812
  prefixFed = true;
24732
24813
  gopFirst = true;
24733
24814
  firstFrameMs = performance.now() - seekStartedAt;
24734
- this.deps.onState(this.rate === 0 ? "paused" : "playing");
24815
+ this.emitState(this.rate === 0 ? "paused" : "playing", "seek-gop-prefix-landed", token, dragId);
24735
24816
  this.deps.onPosition(this.cursorMs);
24736
24817
  this.startPositionReports();
24737
24818
  stateAnnounced = true;
@@ -24746,6 +24827,7 @@ var RecordedFeeder = class {
24746
24827
  audio = [...await this.withTimeout(this.deps.demuxAudio(reusedWhole.bytes), AUDIO_DEMUX_TIMEOUT_MS)];
24747
24828
  } catch (err) {
24748
24829
  this.deps.onLog?.("recorded:seek-audio-dropped", {
24830
+ dragId,
24749
24831
  waitedMs: Date.now() - audioStartedAt,
24750
24832
  budgetMs: AUDIO_DEMUX_TIMEOUT_MS,
24751
24833
  bytes: reusedWhole.bytes.byteLength,
@@ -24773,7 +24855,21 @@ var RecordedFeeder = class {
24773
24855
  loadAusMs = performance.now() - loadAusStartedAt;
24774
24856
  }
24775
24857
  loadMs = performance.now() - loadStartedAt;
24776
- if (this.disposed || token !== this.seekToken || loaded === null) return;
24858
+ if (this.disposed || token !== this.seekToken || loaded === null) {
24859
+ this.deps.onLog?.("recorded:seek-abandoned", {
24860
+ dragId,
24861
+ requestedMs: epochMs,
24862
+ disposed: this.disposed,
24863
+ superseded: token !== this.seekToken,
24864
+ tokenAtStart: token,
24865
+ tokenNow: this.seekToken,
24866
+ loadedNull: loaded === null,
24867
+ announcedLoading: true,
24868
+ loadMs: Math.round(loadMs)
24869
+ });
24870
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24871
+ return;
24872
+ }
24777
24873
  if (!prefixFed) this.feedPrefix(loc, loaded.video, landMs);
24778
24874
  if (gopFirst) {
24779
24875
  this.currentSegment = {
@@ -24795,11 +24891,12 @@ var RecordedFeeder = class {
24795
24891
  }
24796
24892
  if (this.rate === 0) {
24797
24893
  if (!stateAnnounced) {
24798
- this.deps.onState("paused");
24894
+ this.emitState("paused", "seek-landed-paused", token, dragId);
24799
24895
  this.deps.onPosition(this.cursorMs);
24800
24896
  this.startPositionReports();
24801
24897
  }
24802
24898
  this.logSeekReady({
24899
+ dragId,
24803
24900
  epochMs,
24804
24901
  startedAt: seekStartedAt,
24805
24902
  locateMs,
@@ -24815,14 +24912,16 @@ var RecordedFeeder = class {
24815
24912
  loadAusMs: Math.round(loadAusMs)
24816
24913
  });
24817
24914
  this.startIdleWarm(this.cursorMs);
24915
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "paused");
24818
24916
  return;
24819
24917
  }
24820
24918
  if (!stateAnnounced) {
24821
- this.deps.onState("playing");
24919
+ this.emitState("playing", "seek-landed-playing", token, dragId);
24822
24920
  this.deps.onPosition(this.cursorMs);
24823
24921
  this.startPositionReports();
24824
24922
  }
24825
24923
  this.logSeekReady({
24924
+ dragId,
24826
24925
  epochMs,
24827
24926
  startedAt: seekStartedAt,
24828
24927
  locateMs,
@@ -24852,22 +24951,26 @@ var RecordedFeeder = class {
24852
24951
  resumeAfterPtsMs
24853
24952
  });
24854
24953
  this.startIdleWarm(this.cursorMs);
24954
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "playing");
24855
24955
  } catch (err) {
24856
24956
  if (!this.disposed && token === this.seekToken) {
24857
24957
  this.stopPositionReports();
24858
24958
  if (snapInfo !== null) {
24859
24959
  this.deps.onLog?.("recorded:seek-snap-load-failed", {
24960
+ dragId,
24860
24961
  requestedMs: snapInfo.requestedMs,
24861
24962
  snappedMs: snapInfo.snappedMs,
24862
24963
  error: require_dist.errMsg(err)
24863
24964
  });
24864
24965
  this.currentSegment = null;
24865
24966
  this.lastFedVideoIdx = -1;
24866
- this.deps.onState("gap");
24967
+ this.emitState("gap", "seek-snap-load-failed", token, dragId);
24968
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24867
24969
  return;
24868
24970
  }
24869
- this.deps.onState("error");
24870
- }
24971
+ this.emitState("error", "seek-error", token, dragId);
24972
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "error", "error");
24973
+ } else this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24871
24974
  } finally {
24872
24975
  if (token === this.seekToken) {
24873
24976
  this.inFlightSeekEpochMs = null;
@@ -24877,6 +24980,7 @@ var RecordedFeeder = class {
24877
24980
  }
24878
24981
  logSeekReady(fields) {
24879
24982
  this.deps.onLog?.("recorded:seek-ready", {
24983
+ dragId: fields.dragId,
24880
24984
  epochMs: fields.epochMs,
24881
24985
  state: fields.state,
24882
24986
  totalMs: Math.round(performance.now() - fields.startedAt),
@@ -24911,7 +25015,7 @@ var RecordedFeeder = class {
24911
25015
  if (next === 0) {
24912
25016
  this.advanceToken++;
24913
25017
  this.stopPositionReports();
24914
- this.deps.onState("paused");
25018
+ this.emitState("paused", "rate-zero", this.seekToken, this.scrubDragId);
24915
25019
  return;
24916
25020
  }
24917
25021
  this.pendingStepDirs.length = 0;
@@ -24933,9 +25037,16 @@ var RecordedFeeder = class {
24933
25037
  * (which flips `scrubbing` false and clears the session cache) or `dispose()`;
24934
25038
  * a genuinely NEW drag is therefore always preceded by that exit, so its
24935
25039
  * `beginScrub` runs the full reset below. (D-scrub-ux)
25040
+ *
25041
+ * `dragId` is the client's id for this gesture (optional — the viewer's main
25042
+ * session generates it; a caller that omits it gets identical behaviour,
25043
+ * just no id on the logs). Stored for the whole drag, including its commit:
25044
+ * every `scrub-feeder:*` line printed until the next `beginScrub` carries it
25045
+ * (C1).
24936
25046
  */
24937
- beginScrub() {
25047
+ beginScrub(dragId) {
24938
25048
  if (this.disposed || this.scrubbing) return;
25049
+ this.scrubDragId = dragId;
24939
25050
  this.seekToken++;
24940
25051
  this.advanceToken++;
24941
25052
  this.scrubbing = true;
@@ -24944,7 +25055,6 @@ var RecordedFeeder = class {
24944
25055
  this.scrubSegment = null;
24945
25056
  this.scrubCredit = SCRUB_CREDIT_CAP;
24946
25057
  this.scrubPrevTarget = null;
24947
- this.scrubDirRun = 0;
24948
25058
  this.scrubPrefetched = /* @__PURE__ */ new Set();
24949
25059
  this.scrubCursor = null;
24950
25060
  this.scrubNoFootageLogged = false;
@@ -25027,6 +25137,7 @@ var RecordedFeeder = class {
25027
25137
  this.scrubWarmBackMs = opts?.backMs ?? SCRUB_WARM_BACK_MS;
25028
25138
  this.scrubWarmFwdMs = opts?.fwdMs ?? SCRUB_WARM_FWD_MS;
25029
25139
  this.deps.onLog?.("scrub-feeder:warm-start", {
25140
+ dragId: this.scrubDragId,
25030
25141
  centreMs,
25031
25142
  fillGen: this.scrubWarmGen
25032
25143
  });
@@ -25052,6 +25163,7 @@ var RecordedFeeder = class {
25052
25163
  lastWorked = gen;
25053
25164
  if (gen === this.scrubWarmGen) {
25054
25165
  this.deps.onLog?.("scrub-feeder:warm-done", {
25166
+ dragId: this.scrubDragId,
25055
25167
  fillGen: gen,
25056
25168
  idle: this.idleWarm && !this.scrubbing,
25057
25169
  fetched,
@@ -25088,6 +25200,19 @@ var RecordedFeeder = class {
25088
25200
  if (Math.abs(targetMs - centre) <= SCRUB_RECENTER_MS) return;
25089
25201
  this.recenterScrubWarm(targetMs);
25090
25202
  }
25203
+ /**
25204
+ * Is the operator's finger actually moving right now?
25205
+ *
25206
+ * `scrubbing` alone is not the question — a drag stays open while the finger
25207
+ * rests, and those pauses are exactly when the warm should work. The signal
25208
+ * is the pointer sample clock, which `noteScrubSample` stamps on every
25209
+ * `scrubTo` before any velocity gating, so it is true even for a drag too
25210
+ * slow or too jittery for the velocity estimator.
25211
+ */
25212
+ fingerIsMoving() {
25213
+ if (!this.scrubbing) return false;
25214
+ return performance.now() - this.scrubLastSampleAtMs < SCRUB_WARM_QUIET_MS;
25215
+ }
25091
25216
  async warmScrubWindow(centreMs, dragGen, fillGen, backMs, fwdMs) {
25092
25217
  const spent = await this.warmScrubDirection(centreMs, -1, backMs, 0, dragGen, fillGen);
25093
25218
  return this.warmScrubDirection(centreMs, 1, fwdMs, spent, dragGen, fillGen);
@@ -25127,6 +25252,10 @@ var RecordedFeeder = class {
25127
25252
  durMs: warm.durMs,
25128
25253
  fetched: false
25129
25254
  };
25255
+ while (this.fingerIsMoving()) {
25256
+ if (!this.warmLoopAlive(dragGen) || fillGen !== this.scrubWarmGen) return null;
25257
+ await new Promise((r) => setTimeout(r, SCRUB_WARM_QUIET_MS));
25258
+ }
25130
25259
  if (this.idleWarm && !this.scrubbing) {
25131
25260
  await new Promise((r) => setTimeout(r, this.deps.idleWarmGapMs ?? IDLE_WARM_READ_GAP_MS));
25132
25261
  while (this.inFlightSeekEpochMs !== null) {
@@ -25146,7 +25275,7 @@ var RecordedFeeder = class {
25146
25275
  if (loc.kind !== "segment") return null;
25147
25276
  if (!await this.waitForTickReadIdle(fillGen)) return null;
25148
25277
  const load = await this.loadScrubFragment(loc, probeMs);
25149
- if (load !== null && this.scrubbing) this.scrubStats?.recordPrefetchRead(load.readBytes);
25278
+ if (load !== null && this.scrubbing) this.scrubStats?.recordWarmRead(load.readBytes);
25150
25279
  if (!this.warmLoopAlive(dragGen) || load === null) return null;
25151
25280
  if (fillGen !== this.scrubWarmGen) return null;
25152
25281
  this.scrubCache.put(this.deps.profile, load.seg);
@@ -25266,6 +25395,7 @@ var RecordedFeeder = class {
25266
25395
  if (!this.scrubAckTimeoutLogged) {
25267
25396
  this.scrubAckTimeoutLogged = true;
25268
25397
  this.deps.onLog?.("scrub-feeder:ack-timeout", {
25398
+ dragId: this.scrubDragId,
25269
25399
  pushes: expired,
25270
25400
  frames,
25271
25401
  timeoutMs: SCRUB_ACK_TIMEOUT_MS,
@@ -25297,6 +25427,7 @@ var RecordedFeeder = class {
25297
25427
  if (!this.scrubParkLogged) {
25298
25428
  this.scrubParkLogged = true;
25299
25429
  this.deps.onLog?.("scrub-feeder:parked", {
25430
+ dragId: this.scrubDragId,
25300
25431
  pendingEpochMs: this.scrubPendingEpochMs,
25301
25432
  outstanding: this.scrubOutstanding.length
25302
25433
  });
@@ -25329,7 +25460,7 @@ var RecordedFeeder = class {
25329
25460
  seg = this.scrubCache.covering(this.deps.profile, target);
25330
25461
  if (seg) {
25331
25462
  coverage = "cache";
25332
- if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordPrefetchHit();
25463
+ if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordWarmHit();
25333
25464
  }
25334
25465
  }
25335
25466
  if (!seg) {
@@ -25359,6 +25490,7 @@ var RecordedFeeder = class {
25359
25490
  if (!this.scrubDeadlineLogged) {
25360
25491
  this.scrubDeadlineLogged = true;
25361
25492
  this.deps.onLog?.("scrub-feeder:deadline-miss", {
25493
+ dragId: this.scrubDragId,
25362
25494
  epochMs: target,
25363
25495
  deadlineMs,
25364
25496
  first: !this.scrubPushedThisDrag,
@@ -25482,6 +25614,7 @@ var RecordedFeeder = class {
25482
25614
  if (this.scrubLastPushLogAtMs === 0 || pushedAtMs - this.scrubLastPushLogAtMs >= 1e3) {
25483
25615
  this.scrubLastPushLogAtMs = pushedAtMs;
25484
25616
  this.deps.onLog?.("scrub-feeder:pushed", {
25617
+ dragId: this.scrubDragId,
25485
25618
  epochMs: effTarget,
25486
25619
  mode,
25487
25620
  dir,
@@ -25499,10 +25632,11 @@ var RecordedFeeder = class {
25499
25632
  });
25500
25633
  }
25501
25634
  this.deps.onPosition(this.clampIntoSegment(seg, effTarget), pushSeq);
25502
- this.maybeScrubPrefetch(seg, effTarget, gen);
25635
+ this.scrubPrevTarget = effTarget;
25503
25636
  } catch (err) {
25504
25637
  this.scrubStats?.recordFailure();
25505
25638
  this.deps.onLog?.("scrub-feeder:tick-failed", {
25639
+ dragId: this.scrubDragId,
25506
25640
  epochMs: target,
25507
25641
  error: err instanceof Error ? err.message : String(err)
25508
25642
  });
@@ -25548,11 +25682,15 @@ var RecordedFeeder = class {
25548
25682
  if (!this.scrubNoFootageLogged) {
25549
25683
  this.scrubNoFootageLogged = true;
25550
25684
  if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25685
+ dragId: this.scrubDragId,
25551
25686
  epochMs: target,
25552
25687
  nearestMs: decision.snapMs,
25553
25688
  distanceMs: decision.distanceMs
25554
25689
  });
25555
- else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25690
+ else this.deps.onLog?.("scrub-feeder:no-footage", {
25691
+ dragId: this.scrubDragId,
25692
+ epochMs: target
25693
+ });
25556
25694
  }
25557
25695
  return null;
25558
25696
  }
@@ -25568,11 +25706,15 @@ var RecordedFeeder = class {
25568
25706
  if (snapped.kind !== "segment") {
25569
25707
  if (!this.scrubNoFootageLogged) {
25570
25708
  this.scrubNoFootageLogged = true;
25571
- this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25709
+ this.deps.onLog?.("scrub-feeder:no-footage", {
25710
+ dragId: this.scrubDragId,
25711
+ epochMs: target
25712
+ });
25572
25713
  }
25573
25714
  return null;
25574
25715
  }
25575
25716
  this.deps.onLog?.("scrub-feeder:snapped", {
25717
+ dragId: this.scrubDragId,
25576
25718
  requestedMs: target,
25577
25719
  snappedMs: decision.snapMs
25578
25720
  });
@@ -25705,11 +25847,12 @@ var RecordedFeeder = class {
25705
25847
  */
25706
25848
  readsWholeSegment(loc) {
25707
25849
  if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
25708
- if (loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES) return true;
25850
+ if (loc.bytes === void 0 || loc.bytes <= 15e5) return true;
25709
25851
  this.scrubStats?.recordOversizeSegment();
25710
25852
  if (!this.scrubOversizeLogged) {
25711
25853
  this.scrubOversizeLogged = true;
25712
25854
  this.deps.onLog?.("scrub-feeder:oversize-segment", {
25855
+ dragId: this.scrubDragId,
25713
25856
  profile: this.deps.profile,
25714
25857
  startMs: loc.startMs,
25715
25858
  durMs: loc.durMs,
@@ -25745,6 +25888,7 @@ var RecordedFeeder = class {
25745
25888
  if (this.scrubProfileLogged) return;
25746
25889
  this.scrubProfileLogged = true;
25747
25890
  this.deps.onLog?.("scrub-feeder:profile-degraded", {
25891
+ dragId: this.scrubDragId,
25748
25892
  profile: this.deps.profile,
25749
25893
  scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
25750
25894
  });
@@ -25760,17 +25904,6 @@ var RecordedFeeder = class {
25760
25904
  return sorted[Math.floor(sorted.length / 2)] ?? null;
25761
25905
  }
25762
25906
  /**
25763
- * Is the disk the problem right now? The recent-read median against
25764
- * {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
25765
- * noise and the decision must not swing on it; and never before a few
25766
- * samples, or a cold first read would suppress the whole drag.
25767
- */
25768
- scrubReadsAreSlow() {
25769
- const median = this.scrubReadMedianMs();
25770
- if (median === null) return false;
25771
- return median >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25772
- }
25773
- /**
25774
25907
  * Fold one pointer sample into the drag's media velocity (ms of timeline per
25775
25908
  * ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
25776
25909
  */
@@ -25806,7 +25939,7 @@ var RecordedFeeder = class {
25806
25939
  if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
25807
25940
  const readMs = this.scrubReadMedianMs();
25808
25941
  if (readMs === null) return 0;
25809
- return Math.round(velocity * readMs);
25942
+ return scrubLeadDecision(velocity, readMs).leadMs;
25810
25943
  }
25811
25944
  /**
25812
25945
  * A single slow `locate` gets its own line, throttled per drag. Reserved for
@@ -25822,6 +25955,7 @@ var RecordedFeeder = class {
25822
25955
  if (this.scrubLastSlowLocateLogAtMs !== 0 && now - this.scrubLastSlowLocateLogAtMs < SCRUB_PATHOLOGY_LOG_INTERVAL_MS) return;
25823
25956
  this.scrubLastSlowLocateLogAtMs = now;
25824
25957
  this.deps.onLog?.("scrub-feeder:slow-locate", {
25958
+ dragId: this.scrubDragId,
25825
25959
  epochMs,
25826
25960
  locateMs: Math.round(locateMs),
25827
25961
  thresholdMs: threshold
@@ -25838,7 +25972,7 @@ var RecordedFeeder = class {
25838
25972
  * interval comes out warm for the next drag. Aborts (without landing — the
25839
25973
  * caller owns that) the moment a newer seek bumps the token.
25840
25974
  */
25841
- async animateSeekTravel(fromMs, toMs, token) {
25975
+ async animateSeekTravel(fromMs, toMs, token, dragId) {
25842
25976
  const cfg = this.deps.seekAnimation;
25843
25977
  const budgetMs = cfg?.budgetMs ?? SEEK_ANIMATION_BUDGET_MS;
25844
25978
  const tickMs = cfg?.tickMs ?? SEEK_ANIMATION_TICK_MS;
@@ -25870,6 +26004,7 @@ var RecordedFeeder = class {
25870
26004
  if (this.disposed || token !== this.seekToken) return;
25871
26005
  }
25872
26006
  if (stills > 0) this.deps.onLog?.("recorded:seek-travel", {
26007
+ dragId,
25873
26008
  fromMs,
25874
26009
  toMs,
25875
26010
  stills,
@@ -25964,79 +26099,41 @@ var RecordedFeeder = class {
25964
26099
  return stills;
25965
26100
  }
25966
26101
  /**
25967
- * Update the drag-direction run from the just-served target and, once the drag
25968
- * shows a sustained direction, kick off a single background prefetch of the
25969
- * NEXT fragment in that direction. Prefetch fills the cache only — it never
25970
- * pushes, so crossing into the neighbour later is an instant cache hit.
25971
- */
25972
- maybeScrubPrefetch(seg, target, gen) {
25973
- const prev = this.scrubPrevTarget;
25974
- this.scrubPrevTarget = target;
25975
- if (prev === null) {
25976
- this.scrubDirRun = 0;
25977
- return;
25978
- }
25979
- const stepMs = target - prev;
25980
- const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
25981
- if (dir === 0) {
25982
- this.scrubDirRun = 0;
25983
- return;
25984
- }
25985
- this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
25986
- if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
25987
- if (this.scrubReadsAreSlow()) {
25988
- this.scrubStats?.recordPrefetchSuppressed();
25989
- return;
25990
- }
25991
- if (this.scrubFillLoopActive) return;
25992
- const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
25993
- const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
25994
- for (let horizon = 1; horizon <= horizons; horizon++) {
25995
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
25996
- const probeMs = target + dir * aheadMs * horizon;
25997
- if (probeMs < 0) continue;
25998
- if (this.scrubPrefetchProbes.has(probeMs)) continue;
25999
- if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
26000
- if (this.scrubWarmCentreMs !== null && Math.abs(probeMs - this.scrubWarmCentreMs) > SCRUB_WARM_AROUND_MS) continue;
26001
- this.scrubPrefetch(probeMs, gen);
26002
- }
26003
- }
26004
- /**
26005
- * Speculative read: locate + read + demux the fragment covering `probeMs` into
26006
- * `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
26007
- * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
26008
- * swallowed (the fragment just demuxes on demand). Never pushes and never
26009
- * mutates latest-wins state.
26102
+ * The directional prefetch is GONE (2026-08-26), not disabled.
26010
26103
  *
26011
- * Every read it issues is COUNTED (`prefetchReads` / `prefetchBytes`), and the
26012
- * fragment it lands is remembered so a later tick hitting it counts as a
26013
- * `prefetchHit`. Speculation that cannot be shown to pay for itself is just
26014
- * load on the disk the drag is already waiting for.
26015
- */
26016
- async scrubPrefetch(probeMs, gen) {
26017
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
26018
- this.scrubPrefetchInFlight += 1;
26019
- this.scrubPrefetchProbes.add(probeMs);
26020
- try {
26021
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
26022
- if (this.scrubCache.covering(this.deps.profile, probeMs)) return;
26023
- const loc = await this.withTimeout(this.deps.locate({
26024
- deviceId: this.deps.deviceId,
26025
- profile: this.deps.profile,
26026
- epochMs: probeMs
26027
- }));
26028
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
26029
- if (loc.kind !== "segment") return;
26030
- const load = await this.loadScrubFragment(loc, probeMs);
26031
- if (load !== null) this.scrubStats?.recordPrefetchRead(load.readBytes);
26032
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
26033
- this.scrubCache.put(this.deps.profile, load.seg);
26034
- this.scrubPrefetched.add(load.seg.startMs);
26035
- } catch {} finally {
26036
- this.scrubPrefetchInFlight -= 1;
26037
- this.scrubPrefetchProbes.delete(probeMs);
26038
- }
26039
- }
26104
+ * It guessed where the next tick would land from the drag's recent
26105
+ * direction. Measured on the operator's own drags the day it was removed:
26106
+ * **25 speculative reads bought 3 hits** 88% waste, and roughly 60% of all
26107
+ * disk work during a gesture, queued as random seeks ahead of the read the
26108
+ * finger was actually blocked on, on a 5400 rpm SMR disk.
26109
+ *
26110
+ * The regression tests it passed drove a drag at CONSTANT step size, which is
26111
+ * the one regime where direction predicts position. A real backward scrub is
26112
+ * exploratory — 26 of 29 jumps backward, step size set by where the operator
26113
+ * decided to look next — so the premise never held in the field.
26114
+ *
26115
+ * Scrypted has no predictive prefetch either: their read-ahead is a stream
26116
+ * `highWaterMark`, and a short forward move is a fast-forward on an open fd
26117
+ * rather than a seek. They deleted the problem instead of solving it.
26118
+ *
26119
+ * What REMAINS and must not be confused with this: the WINDOW warm
26120
+ * (`runScrubFillLoop`) — a sequential walk of the window around the playhead.
26121
+ *
26122
+ * It was described here, wrongly, as running "when no drag is live, so it
26123
+ * competes with nothing". It does not: its survival condition is
26124
+ * `this.scrubbing || this.idleWarm`, and within hours of this deletion it had
26125
+ * inherited the deleted predictor's role 1.9 MB and 2.4 MB of reads on two
26126
+ * measured drags, 38-45% of the gesture's I/O, zero cache hits. Deleting one
26127
+ * speculator without looking at the other left the contention exactly where
26128
+ * it was. It now waits out a moving finger (`fingerIsMoving`), which is what
26129
+ * the sentence above claimed all along.
26130
+ *
26131
+ * Cost of the removal, bounded and accepted: a careful short-step drag no
26132
+ * longer warms the fragment across the edge, so the first tick past a
26133
+ * boundary may pay one cold read. Its own regression test shows that case is
26134
+ * served by the MEMO (`coverMemo ≥ 5`) with the neighbour warm worth ≤2
26135
+ * reads.
26136
+ */
26040
26137
  /**
26041
26138
  * Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
26042
26139
  * paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
@@ -26220,7 +26317,7 @@ var RecordedFeeder = class {
26220
26317
  this.advanceLoop(gen, initial).catch(() => {
26221
26318
  if (!this.disposed && gen === this.advanceToken) {
26222
26319
  this.stopPositionReports();
26223
- this.deps.onState("error");
26320
+ this.emitState("error", "advance-loop-failed", this.seekToken, this.scrubDragId);
26224
26321
  }
26225
26322
  });
26226
26323
  }
@@ -26237,7 +26334,7 @@ var RecordedFeeder = class {
26237
26334
  if (prepared === null) return;
26238
26335
  if (prepared === "ended") {
26239
26336
  this.stopPositionReports();
26240
- this.deps.onState("ended");
26337
+ this.emitState("ended", "advance-ended", this.seekToken, this.scrubDragId);
26241
26338
  return;
26242
26339
  }
26243
26340
  if (isResumedSegment) {} else {
@@ -26514,7 +26611,10 @@ var RecordedFeeder = class {
26514
26611
  const mark = this.scrubDemuxMark;
26515
26612
  const demux = mark && this.deps.demuxTally ? this.deps.demuxTally.deltaSince(mark) : void 0;
26516
26613
  this.scrubDemuxMark = null;
26517
- this.deps.onLog?.("scrub-feeder:drag-end", stats.toLogFields(performance.now() - this.scrubStartedAtMs, demux));
26614
+ this.deps.onLog?.("scrub-feeder:drag-end", {
26615
+ dragId: this.scrubDragId,
26616
+ ...stats.toLogFields(performance.now() - this.scrubStartedAtMs, demux)
26617
+ });
26518
26618
  }
26519
26619
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
26520
26620
  dispose() {
@@ -26527,7 +26627,6 @@ var RecordedFeeder = class {
26527
26627
  this.scrubToken++;
26528
26628
  this.scrubSegment = null;
26529
26629
  this.scrubPrevTarget = null;
26530
- this.scrubDirRun = 0;
26531
26630
  this.scrubCursor = null;
26532
26631
  this.scrubNoFootageLogged = false;
26533
26632
  this.scrubParkLogged = false;
@@ -26619,10 +26718,11 @@ var TimelineSession = class {
26619
26718
  await this.playAt(msg.epoch, this.profile, { travelFromMs: this.lastPositionMs });
26620
26719
  break;
26621
26720
  case "scrubCommit":
26622
- await this.playAt(msg.epoch, this.profile);
26721
+ if (msg.dragId !== void 0) await this.playAt(msg.epoch, this.profile, { dragId: msg.dragId });
26722
+ else await this.playAt(msg.epoch, this.profile);
26623
26723
  break;
26624
26724
  case "scrubStart":
26625
- (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
26725
+ (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub(msg.dragId);
26626
26726
  break;
26627
26727
  case "scrubTo": {
26628
26728
  const hint = msg.m !== void 0 && msg.v !== void 0 ? {
@@ -26876,7 +26976,7 @@ function createOffThreadJsParse(deps) {
26876
26976
  if (!w) return deps.parseInProcess(bytes);
26877
26977
  const id = nextId;
26878
26978
  nextId += 1;
26879
- const copy = bytes.slice();
26979
+ const copy = new Uint8Array(bytes);
26880
26980
  const request = {
26881
26981
  id,
26882
26982
  bytes: copy
@@ -28313,14 +28413,21 @@ var BrokerWebrtcServer = class {
28313
28413
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
28314
28414
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
28315
28415
  },
28316
- onState: (s) => {
28317
- sessionLogger.info("recorded: feeder state", { meta: {
28318
- sessionId,
28319
- deviceId,
28320
- profile,
28321
- state: s
28322
- } });
28323
- timeline._emitState(s);
28416
+ onState: (t) => {
28417
+ sessionLogger.info("recorded: feeder state", {
28418
+ tags: { deviceId },
28419
+ meta: {
28420
+ sessionId,
28421
+ deviceId,
28422
+ profile,
28423
+ from: t.from,
28424
+ to: t.to,
28425
+ reason: t.reason,
28426
+ seekToken: t.seekToken,
28427
+ dragId: t.dragId
28428
+ }
28429
+ });
28430
+ timeline._emitState(t.to);
28324
28431
  },
28325
28432
  onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
28326
28433
  onLog: (msg, data) => sessionLogger.info(msg, {