@camstack/addon-pipeline 1.2.128 → 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-BnprivSs.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 +241 -95
  5. package/dist/detection-pipeline/index.mjs +239 -93
  6. package/dist/{dist-BZUXb5S5.js → dist-DOw-izoI.js} +243 -75
  7. package/dist/{dist-HLAOw6Pe.mjs → dist-Wt81pN5Y.mjs} +243 -75
  8. package/dist/{event-loop-stall-monitor-BdArVUlx.js → event-loop-stall-monitor-Bld7jNCb.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-BH-cL_hg.mjs → event-loop-stall-monitor-CtQCOZqm.mjs} +1 -1
  10. package/dist/{lazy-sharp-DTDXrhMG.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-CDPRLalN.mjs → process-memory-1_D3EZT9.mjs} +1 -1
  16. package/dist/{process-memory-Dh1SyzdJ.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-D-TLcHW5.mjs → segment-demux-js-BW-Q-j2A.mjs} +1 -1
  20. package/dist/{segment-demux-js-DRutggd6.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-BDR5z0Z9.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-Dmw5LWmP.mjs → hostInit-DEVCEYgu.mjs} +3 -3
  30. package/dist/stream-broker/index.js +906 -423
  31. package/dist/stream-broker/index.mjs +906 -423
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-BxkCoVjw.js → worker-protocol-nAZl_Rsv.js} +1 -1
  34. package/dist/{worker-protocol-h7Smb_0i.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-Cs8bmrWP.mjs +0 -26
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BZUXb5S5.js");
5
+ const require_dist = require("../dist-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-DRutggd6.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");
@@ -390,6 +390,83 @@ var STREAM_BROKER_RETIRED_ROOT_KEYS = [
390
390
  }
391
391
  ];
392
392
  //#endregion
393
+ //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
394
+ /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
395
+ function logBannerArgs(level) {
396
+ return [
397
+ "-hide_banner",
398
+ "-loglevel",
399
+ level
400
+ ];
401
+ }
402
+ //#endregion
403
+ //#region src/shared/ffmpeg-decode-args.ts
404
+ /**
405
+ * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
406
+ *
407
+ * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
408
+ * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
409
+ * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
410
+ * `qsv` first on Intel, so we re-rank its result through this table before
411
+ * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
412
+ * trail for NVIDIA. A method not listed here keeps its incoming relative order,
413
+ * placed AFTER every ranked one.
414
+ */
415
+ var DECODE_HWACCEL_RANK = [
416
+ "videotoolbox",
417
+ "vaapi",
418
+ "qsv",
419
+ "cuda",
420
+ "nvdec",
421
+ "d3d11va",
422
+ "dxva2",
423
+ "amf",
424
+ "vdpau",
425
+ "drm"
426
+ ];
427
+ /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
428
+ function decodeHwAccelRankIndex(method) {
429
+ const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
430
+ return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
431
+ }
432
+ /**
433
+ * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
434
+ * `'auto'` decode session, best-first. The session walks this chain on an
435
+ * early-exit-with-no-frames and only falls to software once it is exhausted.
436
+ *
437
+ * Strategy:
438
+ * - Seed candidates from the kernel-preferred order UNION the canonical rank
439
+ * UNION the build's supported list, so a method the kernel omitted but the
440
+ * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
441
+ * still attempted.
442
+ * - When the build's supported-method list is known (non-empty), keep only
443
+ * methods it actually offers; on a probe miss (empty) keep just the kernel
444
+ * preferences (avoid blindly spawning every backend).
445
+ * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
446
+ * `qsv`. A supported method not in the rank table keeps its position AFTER
447
+ * every ranked one (stable sort). Returns lowercased method names; empty ⇒
448
+ * pure software.
449
+ */
450
+ function rankDecodeHwAccels(preferred, supportedMethods) {
451
+ const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
452
+ const hasSupportList = supported.size > 0;
453
+ const pool = hasSupportList ? [
454
+ ...preferred,
455
+ ...DECODE_HWACCEL_RANK,
456
+ ...supportedMethods
457
+ ] : [...preferred];
458
+ const seen = /* @__PURE__ */ new Set();
459
+ const candidates = [];
460
+ for (const raw of pool) {
461
+ const method = raw.toLowerCase();
462
+ if (seen.has(method)) continue;
463
+ seen.add(method);
464
+ if (hasSupportList && !supported.has(method)) continue;
465
+ candidates.push(method);
466
+ }
467
+ return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
468
+ }
469
+ //#endregion
393
470
  //#region src/stream-broker/battery-wake-on-play.ts
394
471
  /**
395
472
  * Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
@@ -592,83 +669,6 @@ async function wakeBatteryDeviceForPlay(deviceId, deps) {
592
669
  throw new BatteryStreamUnavailableError(deviceId, outcome, wake.awoke ? "it acknowledged the wake but no stream had appeared yet — try again in a few seconds" : "it did not answer the wake in time — try again");
593
670
  }
594
671
  //#endregion
595
- //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
596
- /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
597
- function logBannerArgs(level) {
598
- return [
599
- "-hide_banner",
600
- "-loglevel",
601
- level
602
- ];
603
- }
604
- //#endregion
605
- //#region src/shared/ffmpeg-decode-args.ts
606
- /**
607
- * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
608
- *
609
- * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
610
- * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
611
- * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
612
- * `qsv` first on Intel, so we re-rank its result through this table before
613
- * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
614
- * trail for NVIDIA. A method not listed here keeps its incoming relative order,
615
- * placed AFTER every ranked one.
616
- */
617
- var DECODE_HWACCEL_RANK = [
618
- "videotoolbox",
619
- "vaapi",
620
- "qsv",
621
- "cuda",
622
- "nvdec",
623
- "d3d11va",
624
- "dxva2",
625
- "amf",
626
- "vdpau",
627
- "drm"
628
- ];
629
- /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
630
- function decodeHwAccelRankIndex(method) {
631
- const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
632
- return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
633
- }
634
- /**
635
- * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
636
- * `'auto'` decode session, best-first. The session walks this chain on an
637
- * early-exit-with-no-frames and only falls to software once it is exhausted.
638
- *
639
- * Strategy:
640
- * - Seed candidates from the kernel-preferred order UNION the canonical rank
641
- * UNION the build's supported list, so a method the kernel omitted but the
642
- * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
643
- * still attempted.
644
- * - When the build's supported-method list is known (non-empty), keep only
645
- * methods it actually offers; on a probe miss (empty) keep just the kernel
646
- * preferences (avoid blindly spawning every backend).
647
- * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
648
- * `qsv`. A supported method not in the rank table keeps its position AFTER
649
- * every ranked one (stable sort). Returns lowercased method names; empty ⇒
650
- * pure software.
651
- */
652
- function rankDecodeHwAccels(preferred, supportedMethods) {
653
- const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
654
- const hasSupportList = supported.size > 0;
655
- const pool = hasSupportList ? [
656
- ...preferred,
657
- ...DECODE_HWACCEL_RANK,
658
- ...supportedMethods
659
- ] : [...preferred];
660
- const seen = /* @__PURE__ */ new Set();
661
- const candidates = [];
662
- for (const raw of pool) {
663
- const method = raw.toLowerCase();
664
- if (seen.has(method)) continue;
665
- seen.add(method);
666
- if (hasSupportList && !supported.has(method)) continue;
667
- candidates.push(method);
668
- }
669
- return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
670
- }
671
- //#endregion
672
672
  //#region src/stream-broker/derived/cycle-check.ts
673
673
  /**
674
674
  * Would assigning `derivedCamStreamId` to `targetProfile` create a cycle?
@@ -16279,7 +16279,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16279
16279
  }
16280
16280
  let devices;
16281
16281
  try {
16282
- devices = await api.deviceManager.listAll.query({});
16282
+ devices = await api.deviceManager.listAll.query({ projection: "slim" });
16283
16283
  } catch (err) {
16284
16284
  this.logger.warn("profile-map prune skipped — deviceManager.listAll failed", { meta: {
16285
16285
  persistedCount,
@@ -16604,7 +16604,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16604
16604
  if (!this.api) return;
16605
16605
  let devices;
16606
16606
  try {
16607
- devices = await this.api.deviceManager.listAll.query({});
16607
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16608
16608
  } catch (err) {
16609
16609
  this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", { meta: { error: require_dist.errMsg(err) } });
16610
16610
  return;
@@ -16744,7 +16744,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16744
16744
  if (!this.api) return false;
16745
16745
  let devices;
16746
16746
  try {
16747
- devices = await this.api.deviceManager.listAll.query({});
16747
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16748
16748
  } catch {
16749
16749
  return false;
16750
16750
  }
@@ -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,7 +23702,13 @@ 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;
23709
+ deadlineMisses = 0;
23710
+ retargets = 0;
23711
+ resolveJoins = 0;
23800
23712
  failures = 0;
23801
23713
  firstPushMs = null;
23802
23714
  creditEndedAt = null;
@@ -23806,6 +23718,9 @@ var ScrubDragStats = class {
23806
23718
  demuxMs = new PhaseSamples();
23807
23719
  tickMs = new PhaseSamples();
23808
23720
  ackMs = new PhaseSamples();
23721
+ leadMs = new PhaseSamples();
23722
+ oversizeSegments = 0;
23723
+ degradedProfile = null;
23809
23724
  constructor(startedAtMs) {
23810
23725
  this.startedAtMs = startedAtMs;
23811
23726
  }
@@ -23856,10 +23771,43 @@ var ScrubDragStats = class {
23856
23771
  recordPrefetchHit() {
23857
23772
  this.prefetchHits += 1;
23858
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
+ }
23859
23783
  /** Pushes written off after the ack timeout — dropped work, never silent. */
23860
23784
  recordAckTimeout(pushes) {
23861
23785
  this.ackTimeouts += pushes;
23862
23786
  }
23787
+ /** A frame abandoned on its deadline — dropped work, counted every time. */
23788
+ recordDeadlineMiss() {
23789
+ this.deadlineMisses += 1;
23790
+ }
23791
+ /** A landing re-aimed at the newer finger position it also covered. */
23792
+ recordRetarget() {
23793
+ this.retargets += 1;
23794
+ }
23795
+ /** A whole-segment read that had to degrade to a GOP range on size. */
23796
+ recordOversizeSegment() {
23797
+ this.oversizeSegments += 1;
23798
+ }
23799
+ /** This drag is not running on the scrub profile — every gate is off. */
23800
+ recordProfileDegraded(profile) {
23801
+ this.degradedProfile = profile;
23802
+ }
23803
+ /** One read aimed ahead of the finger, and by how much. */
23804
+ recordLead(leadMs) {
23805
+ this.leadMs.add(Math.abs(leadMs));
23806
+ }
23807
+ /** A tick served by the read another tick already had in flight. */
23808
+ recordResolveJoin() {
23809
+ this.resolveJoins += 1;
23810
+ }
23863
23811
  /** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
23864
23812
  recordFailure() {
23865
23813
  this.failures += 1;
@@ -23875,6 +23823,7 @@ var ScrubDragStats = class {
23875
23823
  const dmx = this.demuxMs.summary();
23876
23824
  const tick = this.tickMs.summary();
23877
23825
  const ack = this.ackMs.summary();
23826
+ const lead = this.leadMs.summary();
23878
23827
  return {
23879
23828
  ticks: this.ticks,
23880
23829
  dragMs: Math.round(dragMs),
@@ -23913,7 +23862,17 @@ var ScrubDragStats = class {
23913
23862
  prefetchBytes: this.prefetchBytes,
23914
23863
  prefetchHits: this.prefetchHits,
23915
23864
  prefetchSuppressed: this.prefetchSuppressed,
23865
+ warmReads: this.warmReads,
23866
+ warmBytes: this.warmBytes,
23867
+ warmHits: this.warmHits,
23916
23868
  ackTimeouts: this.ackTimeouts,
23869
+ deadlineMisses: this.deadlineMisses,
23870
+ retargets: this.retargets,
23871
+ resolveJoins: this.resolveJoins,
23872
+ leadMsP50: lead.p50Ms,
23873
+ leadMsMax: lead.maxMs,
23874
+ oversizeSegments: this.oversizeSegments,
23875
+ degradedProfile: this.degradedProfile,
23917
23876
  failures: this.failures,
23918
23877
  creditEndedAt: this.creditEndedAt,
23919
23878
  acks: ack.n,
@@ -23925,8 +23884,158 @@ var ScrubDragStats = class {
23925
23884
  };
23926
23885
  }
23927
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
+ }
23928
24006
  //#endregion
23929
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
+ */
23930
24039
  var SEGMENT_TIMEOUT_MS = 8e3;
23931
24040
  /**
23932
24041
  * Deadline for the recorded-AUDIO demux on the `reusedWhole` seek path, and
@@ -24027,30 +24136,6 @@ var SEEK_ANIMATION_MIN_DISTANCE_MS = 8e3;
24027
24136
  */
24028
24137
  var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
24029
24138
  /**
24030
- * Consecutive same-direction `scrubTo` deltas required before a direction-aware
24031
- * prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
24032
- * sustained direction, so a jittery/reversing drag never wastes the slot.
24033
- */
24034
- var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
24035
- /**
24036
- * How many speculative GOP reads may be in flight at once, i.e. how many drag
24037
- * steps ahead the feeder reads.
24038
- *
24039
- * The scrub tick IS the read: measured on salone 590 over `low`,
24040
- * `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
24041
- * the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
24042
- * hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
24043
- * tens of seconds apart revisits nothing. Reading N steps ahead is the only
24044
- * thing that converts wall-clock the drag is ALREADY spending into pushes.
24045
- *
24046
- * 2 and not more: these reads land on the same spindles the recorder is writing
24047
- * every camera to, and past a small number concurrent random reads stop
24048
- * overlapping and start thrashing. Raise it only against a measured
24049
- * `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
24050
- * both halves of the evidence.
24051
- */
24052
- var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24053
- /**
24054
24139
  * The profile whose scrub read unit is the WHOLE SEGMENT.
24055
24140
  *
24056
24141
  * Every drag is pinned to `low` by the client, and a `low` segment is ~230 KB
@@ -24068,14 +24153,48 @@ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24068
24153
  */
24069
24154
  var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
24070
24155
  /**
24071
- * Safety ceiling on the whole-segment read. `low` is ~230 KB per 10 s, so this
24072
- * is ~6× headroom for a camera whose substream is unusually fat — but a
24073
- * segment past it (a mis-pinned `high` at ~5 MB) falls back to the GOP range,
24074
- * because reading 5 MB to push one keyframe is the trade this change exists to
24075
- * avoid, in the other direction.
24156
+ * Safety ceiling on ONE scrub read, in bytes.
24157
+ *
24158
+ * `low` is ~230 KB per 10 s, so this is ~6× headroom for a camera whose
24159
+ * substream is unusually fat but a segment past it (a mis-pinned `high` at
24160
+ * ~5 MB) falls back to the GOP range, because reading 5 MB to push one
24161
+ * keyframe is the trade this change exists to avoid, in the other direction.
24162
+ *
24163
+ * ⚠️ THIS NUMBER IS TIED TO THE SEGMENT DURATION AND THE TIE IS NOT ENFORCED.
24164
+ * It was tuned against a 10-second `low` segment. At the 60-second segments
24165
+ * the 2026-08-25 teardown proposes, a NATIVE `low` is ~1.4 MB (just inside,
24166
+ * and the whole-segment read gets six times better — one seek leaves a minute
24167
+ * of access units behind) while a DERIVED `low` is ~4.8 MB (outside, and every
24168
+ * scrub read silently degrades to a 3-seek GOP range that leaves nothing
24169
+ * behind). Same constant, opposite outcomes, no signal either way — which is
24170
+ * why the degradation is now COUNTED and LOGGED
24171
+ * (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
24172
+ * instead of being inferred from a falling hit rate.
24173
+ *
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.
24076
24187
  */
24077
24188
  var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
24078
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
+ /**
24079
24198
  * How far the entry warm-up reaches, backward and forward from the playhead the
24080
24199
  * drag starts on.
24081
24200
  *
@@ -24110,17 +24229,6 @@ var SCRUB_WARM_MAX_SEGMENTS = Math.max(1, Math.floor(SCRUB_FRAGMENT_CACHE_MAX_BY
24110
24229
  */
24111
24230
  var IDLE_WARM_READ_GAP_MS = 250;
24112
24231
  /**
24113
- * Median recent read time at or above which the feeder STOPS speculating.
24114
- *
24115
- * Speculation is a bet that spare I/O exists. When reads are this slow there is
24116
- * no spare I/O — the array is saturated — and a prefetch is not free work, it
24117
- * is another random seek queued ahead of the read the operator is waiting on.
24118
- * The field regime that forced this: 54 prefetch reads for 9 hits with the disk
24119
- * at 98% busy. Suppression is counted (`prefetchSuppressed`) so the decision is
24120
- * visible, and it lifts by itself as soon as reads come back.
24121
- */
24122
- var SCRUB_SLOW_READ_MS = 600;
24123
- /**
24124
24232
  * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
24125
24233
  * that SWEEPS predicted frames instead of jumping. A steady forward drag is
24126
24234
  * playback with a moving target: once a keyframe decoded, the predicted frames
@@ -24133,6 +24241,84 @@ var SCRUB_SLOW_READ_MS = 600;
24133
24241
  */
24134
24242
  var SCRUB_SWEEP_WINDOW_MS = 4e3;
24135
24243
  /**
24244
+ * THE DEADLINE ON ONE SCRUB FRAME — and the reason it is not
24245
+ * {@link SEGMENT_TIMEOUT_MS}.
24246
+ *
24247
+ * A scrub tick used to be bounded only by the 8 s per-RPC timeout, against a
24248
+ * cold read measured at p50 531 ms, p95 1.4–2 s and a 9.2 s max (census
24249
+ * 2026-08-25). So the feeder WAITED. The frame it eventually pushed was for a
24250
+ * finger position from seconds earlier, and the gesture spent its whole
24251
+ * duration on a handful of stale frames — the operator's "quasi 0 frame, e
24252
+ * l'immagine insegue il dito".
24253
+ *
24254
+ * The reference design (Scrypted NVR, teardown 2026-08-25) spends at most
24255
+ * ~1000 ms on the WHOLE scrub animation and gives every single frame the
24256
+ * REMAINING window as its deadline: a frame that cannot be produced in time is
24257
+ * dropped, never awaited. This is the same contract on our constraints.
24258
+ *
24259
+ * 700 ms and not 1000: the deadline must be shorter than the gesture's own
24260
+ * animation window or it can never fire twice inside one, and it must be above
24261
+ * the cold-read p50 (531 ms) or the median frame would be dropped — a deadline
24262
+ * that drops the median makes the picture WORSE, not fresher. 700 ms sits
24263
+ * between: it serves the p50–p60 of cold reads and cuts the p90 tail that used
24264
+ * to eat the gesture.
24265
+ *
24266
+ * Dropping is never silent: `deadlineMisses` on the drag aggregate plus one
24267
+ * `scrub-feeder:deadline-miss` line per drag.
24268
+ */
24269
+ var SCRUB_TICK_DEADLINE_MS = 700;
24270
+ /**
24271
+ * The FIRST frame of a drag gets a longer deadline, because nothing is painted
24272
+ * yet.
24273
+ *
24274
+ * Every later frame has a predecessor on screen, so dropping it costs
24275
+ * freshness. The first one has none: dropping it costs the whole picture, and
24276
+ * the operator reads a blank recorded view as "playback parte dopo una vita".
24277
+ * The reference design has the same asymmetry for the same reason — its
24278
+ * animation frames are deadline-dropped while the LANDING seek is unbounded.
24279
+ *
24280
+ * 2500 ms covers the cold-read p95 (1.4–2 s) and still cuts the 9.2 s tail.
24281
+ */
24282
+ var SCRUB_FIRST_TICK_DEADLINE_MS = 2500;
24283
+ /**
24284
+ * How far a new target may be from an in-flight read's target and still RIDE
24285
+ * it instead of starting a second one.
24286
+ *
24287
+ * A `low` segment is ~10 s of timeline and the read unit is the whole segment
24288
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}), so a target within one segment of the
24289
+ * one being read is very likely already inside the bytes on their way. Riding
24290
+ * costs nothing and saves a random seek on the spindle that is the bottleneck;
24291
+ * being wrong costs one wasted deadline, which is now cheap by construction.
24292
+ */
24293
+ var SCRUB_RIDE_WINDOW_MS = 1e4;
24294
+ /**
24295
+ * Hard bound on concurrent fragment resolutions for one drag.
24296
+ *
24297
+ * A deadline that drops frames is only safe if dropping does not multiply the
24298
+ * work: without this bound a drag over slow footage would abandon a read every
24299
+ * {@link SCRUB_TICK_DEADLINE_MS} and start another, turning one saturated
24300
+ * spindle into N. Two: the one that is landing, and the one the finger has
24301
+ * moved on to. At the cap, a tick rides the newest in-flight read instead.
24302
+ */
24303
+ var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
24304
+ var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
24305
+ /**
24306
+ * Smoothing for the media-velocity estimate (ms of timeline per ms of wall
24307
+ * clock), folded on every pointer sample. Half-and-half: fast enough to track
24308
+ * a fling that starts mid-gesture, slow enough that one jittery sample cannot
24309
+ * aim a read an hour away.
24310
+ */
24311
+ var SCRUB_VELOCITY_EMA = .5;
24312
+ /** Same-direction POINTER samples required before a read may aim ahead. */
24313
+ var SCRUB_LEAD_MIN_DIR_RUN = 2;
24314
+ /**
24315
+ * Pointer-sample gaps outside this range do not update the velocity estimate:
24316
+ * below it the delta is dominated by timer jitter, above it the finger has
24317
+ * effectively paused and the "velocity" is an artefact of the pause.
24318
+ */
24319
+ var SCRUB_VELOCITY_MIN_GAP_MS = 10;
24320
+ var SCRUB_VELOCITY_MAX_GAP_MS = 500;
24321
+ /**
24136
24322
  * Receiver-credit cap for scrub pushes: at most this many PUSHES may be
24137
24323
  * OUTSTANDING (on the track, not yet acknowledged by the viewer via
24138
24324
  * `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
@@ -24347,25 +24533,6 @@ var RecordedFeeder = class {
24347
24533
  scrubLastPushLogAtMs = 0;
24348
24534
  scrubLastSlowLocateLogAtMs = 0;
24349
24535
  /**
24350
- * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
24351
- * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
24352
- * a reversal or an in-fragment no-move resets it.
24353
- */
24354
- scrubDirRun = 0;
24355
- /**
24356
- * Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
24357
- * these are the SPECULATIVE concurrency slots, and they only fill
24358
- * `scrubCache` — they never push, never touch the pending/timer/latest-wins
24359
- * state, and never block the live coalesced tick.
24360
- *
24361
- * More than one because the read IS the tick (263 of 270 ms measured): with a
24362
- * single slot the drag can never go faster than one cold read, however well
24363
- * the speculation guesses.
24364
- */
24365
- scrubPrefetchInFlight = 0;
24366
- /** Probe instants currently being read, so two horizons never duplicate one. */
24367
- scrubPrefetchProbes = /* @__PURE__ */ new Set();
24368
- /**
24369
24536
  * Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
24370
24537
  * hits one of them is what makes speculation provably worth its I/O
24371
24538
  * (`prefetchHits`) — without this the feeder could double the load on the disk
@@ -24389,10 +24556,44 @@ var RecordedFeeder = class {
24389
24556
  scrubWarmGen = 0;
24390
24557
  /** Tick demand-read in flight: the fill waits so the finger wins the spindle. */
24391
24558
  scrubTickReadInFlight = false;
24559
+ /**
24560
+ * The newest fragment resolution in flight for this drag — what a later tick
24561
+ * rides instead of issuing its own read. Cleared by the resolution itself.
24562
+ */
24563
+ scrubResolve = null;
24564
+ /** Concurrent resolutions, bounded by {@link SCRUB_RESOLVE_MAX_INFLIGHT}. */
24565
+ scrubResolvesInFlight = 0;
24566
+ /** One `deadline-miss` line per drag; the count lives on the aggregate. */
24567
+ scrubDeadlineLogged = false;
24568
+ /** One `oversize-segment` line per drag; the count lives on the aggregate. */
24569
+ scrubOversizeLogged = false;
24570
+ /** One `profile-degraded` line per drag — the `low` pin is lost. */
24571
+ scrubProfileLogged = false;
24572
+ /** Has this drag put a frame on the wire? Gates the first-frame deadline. */
24573
+ scrubPushedThisDrag = false;
24574
+ /** Smoothed media-ms per wall-ms of the drag, signed. Null until measurable. */
24575
+ scrubVelocity = null;
24576
+ /** Previous pointer sample, for the velocity estimate above. */
24577
+ scrubLastSampleMs = null;
24578
+ scrubLastSampleAtMs = 0;
24579
+ /** Signed run of consecutive same-direction POINTER samples. */
24580
+ scrubVelRun = 0;
24392
24581
  /** One fill walker per drag — a fling retargets it, it does not spawn another. */
24393
24582
  scrubFillLoopActive = false;
24394
24583
  scrubWarmBackMs = SCRUB_WARM_BACK_MS;
24395
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;
24396
24597
  constructor(deps) {
24397
24598
  this.deps = deps;
24398
24599
  this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
@@ -24410,6 +24611,50 @@ var RecordedFeeder = class {
24410
24611
  if (typeof pcmu === "function") return (data, rtpTs) => pcmu.call(sink, data, rtpTs);
24411
24612
  }
24412
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
+ /**
24413
24658
  * Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
24414
24659
  * the FIRST located segment is fed; the forward `advance` loop is started
24415
24660
  * fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
@@ -24422,6 +24667,7 @@ var RecordedFeeder = class {
24422
24667
  let loadMs = 0;
24423
24668
  let firstFrameMs = 0;
24424
24669
  const token = ++this.seekToken;
24670
+ const dragId = opts?.dragId;
24425
24671
  const wasScrubbing = this.scrubbing;
24426
24672
  if (this.scrubbing) {
24427
24673
  this.emitDragEnd();
@@ -24433,7 +24679,6 @@ var RecordedFeeder = class {
24433
24679
  this.clearScrubAckTimeout();
24434
24680
  this.scrubPendingHint = null;
24435
24681
  this.scrubPrevTarget = null;
24436
- this.scrubDirRun = 0;
24437
24682
  if (this.scrubTimer) {
24438
24683
  clearTimeout(this.scrubTimer);
24439
24684
  this.scrubTimer = null;
@@ -24443,7 +24688,7 @@ var RecordedFeeder = class {
24443
24688
  this.inFlightSeekEpochMs = epochMs;
24444
24689
  let snapInfo = null;
24445
24690
  try {
24446
- this.deps.onState("loading");
24691
+ this.emitState("loading", "seek-start", token, dragId);
24447
24692
  if (this.clockHasAdvanced) {
24448
24693
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
24449
24694
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
@@ -24455,21 +24700,29 @@ var RecordedFeeder = class {
24455
24700
  epochMs
24456
24701
  }));
24457
24702
  locateMs += performance.now() - locateStartedAt;
24458
- 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
+ }
24459
24707
  let landMs = epochMs;
24460
24708
  if (loc.kind === "gap") {
24461
24709
  const decision = resolveSnapTarget(loc, epochMs);
24462
24710
  if (decision.kind !== "snap") {
24463
24711
  if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
24712
+ dragId,
24464
24713
  requestedMs: epochMs,
24465
24714
  nearestMs: decision.snapMs,
24466
24715
  distanceMs: decision.distanceMs
24467
24716
  });
24468
- 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
+ });
24469
24721
  this.currentSegment = null;
24470
24722
  this.lastFedVideoIdx = -1;
24471
24723
  this.stopPositionReports();
24472
- 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");
24473
24726
  return;
24474
24727
  }
24475
24728
  const snapLocateStartedAt = performance.now();
@@ -24479,19 +24732,25 @@ var RecordedFeeder = class {
24479
24732
  epochMs: decision.snapMs
24480
24733
  }));
24481
24734
  locateMs += performance.now() - snapLocateStartedAt;
24482
- 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
+ }
24483
24739
  if (snapped.kind !== "segment") {
24484
24740
  this.deps.onLog?.("recorded:seek-no-footage", {
24741
+ dragId,
24485
24742
  requestedMs: epochMs,
24486
24743
  snappedMs: decision.snapMs
24487
24744
  });
24488
24745
  this.currentSegment = null;
24489
24746
  this.lastFedVideoIdx = -1;
24490
24747
  this.stopPositionReports();
24491
- this.deps.onState("gap");
24748
+ this.emitState("gap", "seek-snap-target-no-footage", token, dragId);
24749
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24492
24750
  return;
24493
24751
  }
24494
24752
  this.deps.onLog?.("recorded:seek-snapped", {
24753
+ dragId,
24495
24754
  requestedMs: epochMs,
24496
24755
  snappedMs: decision.snapMs
24497
24756
  });
@@ -24505,9 +24764,12 @@ var RecordedFeeder = class {
24505
24764
  const travelFromMs = opts?.travelFromMs ?? (this.clockHasAdvanced ? this.cursorMs : null);
24506
24765
  if (!wasScrubbing && travelFromMs !== null) {
24507
24766
  const travelStartedAt = performance.now();
24508
- await this.animateSeekTravel(travelFromMs, landMs, token);
24767
+ await this.animateSeekTravel(travelFromMs, landMs, token, dragId);
24509
24768
  travelMs = performance.now() - travelStartedAt;
24510
- 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
+ }
24511
24773
  }
24512
24774
  this.cursorMs = loc.startMs;
24513
24775
  const loadStartedAt = performance.now();
@@ -24524,7 +24786,10 @@ var RecordedFeeder = class {
24524
24786
  prefixSource = "cache";
24525
24787
  } else {
24526
24788
  const fragmentLoad = await this.loadScrubFragment(loc, landMs);
24527
- if (fragmentLoad === null) return;
24789
+ if (fragmentLoad === null) {
24790
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24791
+ return;
24792
+ }
24528
24793
  fragment = fragmentLoad.seg;
24529
24794
  this.scrubCache.put(this.deps.profile, fragment);
24530
24795
  prefixSource = fragmentLoad.wholeSegmentRead ? "whole" : "gop";
@@ -24533,7 +24798,10 @@ var RecordedFeeder = class {
24533
24798
  bytes: fragmentLoad.rawBytes
24534
24799
  };
24535
24800
  }
24536
- 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
+ }
24537
24805
  const fragmentLoc = {
24538
24806
  kind: "segment",
24539
24807
  startMs: fragment.startMs,
@@ -24544,7 +24812,7 @@ var RecordedFeeder = class {
24544
24812
  prefixFed = true;
24545
24813
  gopFirst = true;
24546
24814
  firstFrameMs = performance.now() - seekStartedAt;
24547
- this.deps.onState(this.rate === 0 ? "paused" : "playing");
24815
+ this.emitState(this.rate === 0 ? "paused" : "playing", "seek-gop-prefix-landed", token, dragId);
24548
24816
  this.deps.onPosition(this.cursorMs);
24549
24817
  this.startPositionReports();
24550
24818
  stateAnnounced = true;
@@ -24559,6 +24827,7 @@ var RecordedFeeder = class {
24559
24827
  audio = [...await this.withTimeout(this.deps.demuxAudio(reusedWhole.bytes), AUDIO_DEMUX_TIMEOUT_MS)];
24560
24828
  } catch (err) {
24561
24829
  this.deps.onLog?.("recorded:seek-audio-dropped", {
24830
+ dragId,
24562
24831
  waitedMs: Date.now() - audioStartedAt,
24563
24832
  budgetMs: AUDIO_DEMUX_TIMEOUT_MS,
24564
24833
  bytes: reusedWhole.bytes.byteLength,
@@ -24586,7 +24855,21 @@ var RecordedFeeder = class {
24586
24855
  loadAusMs = performance.now() - loadAusStartedAt;
24587
24856
  }
24588
24857
  loadMs = performance.now() - loadStartedAt;
24589
- 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
+ }
24590
24873
  if (!prefixFed) this.feedPrefix(loc, loaded.video, landMs);
24591
24874
  if (gopFirst) {
24592
24875
  this.currentSegment = {
@@ -24608,11 +24891,12 @@ var RecordedFeeder = class {
24608
24891
  }
24609
24892
  if (this.rate === 0) {
24610
24893
  if (!stateAnnounced) {
24611
- this.deps.onState("paused");
24894
+ this.emitState("paused", "seek-landed-paused", token, dragId);
24612
24895
  this.deps.onPosition(this.cursorMs);
24613
24896
  this.startPositionReports();
24614
24897
  }
24615
24898
  this.logSeekReady({
24899
+ dragId,
24616
24900
  epochMs,
24617
24901
  startedAt: seekStartedAt,
24618
24902
  locateMs,
@@ -24628,14 +24912,16 @@ var RecordedFeeder = class {
24628
24912
  loadAusMs: Math.round(loadAusMs)
24629
24913
  });
24630
24914
  this.startIdleWarm(this.cursorMs);
24915
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "paused");
24631
24916
  return;
24632
24917
  }
24633
24918
  if (!stateAnnounced) {
24634
- this.deps.onState("playing");
24919
+ this.emitState("playing", "seek-landed-playing", token, dragId);
24635
24920
  this.deps.onPosition(this.cursorMs);
24636
24921
  this.startPositionReports();
24637
24922
  }
24638
24923
  this.logSeekReady({
24924
+ dragId,
24639
24925
  epochMs,
24640
24926
  startedAt: seekStartedAt,
24641
24927
  locateMs,
@@ -24665,22 +24951,26 @@ var RecordedFeeder = class {
24665
24951
  resumeAfterPtsMs
24666
24952
  });
24667
24953
  this.startIdleWarm(this.cursorMs);
24954
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "playing");
24668
24955
  } catch (err) {
24669
24956
  if (!this.disposed && token === this.seekToken) {
24670
24957
  this.stopPositionReports();
24671
24958
  if (snapInfo !== null) {
24672
24959
  this.deps.onLog?.("recorded:seek-snap-load-failed", {
24960
+ dragId,
24673
24961
  requestedMs: snapInfo.requestedMs,
24674
24962
  snappedMs: snapInfo.snappedMs,
24675
24963
  error: require_dist.errMsg(err)
24676
24964
  });
24677
24965
  this.currentSegment = null;
24678
24966
  this.lastFedVideoIdx = -1;
24679
- this.deps.onState("gap");
24967
+ this.emitState("gap", "seek-snap-load-failed", token, dragId);
24968
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24680
24969
  return;
24681
24970
  }
24682
- this.deps.onState("error");
24683
- }
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);
24684
24974
  } finally {
24685
24975
  if (token === this.seekToken) {
24686
24976
  this.inFlightSeekEpochMs = null;
@@ -24690,6 +24980,7 @@ var RecordedFeeder = class {
24690
24980
  }
24691
24981
  logSeekReady(fields) {
24692
24982
  this.deps.onLog?.("recorded:seek-ready", {
24983
+ dragId: fields.dragId,
24693
24984
  epochMs: fields.epochMs,
24694
24985
  state: fields.state,
24695
24986
  totalMs: Math.round(performance.now() - fields.startedAt),
@@ -24724,7 +25015,7 @@ var RecordedFeeder = class {
24724
25015
  if (next === 0) {
24725
25016
  this.advanceToken++;
24726
25017
  this.stopPositionReports();
24727
- this.deps.onState("paused");
25018
+ this.emitState("paused", "rate-zero", this.seekToken, this.scrubDragId);
24728
25019
  return;
24729
25020
  }
24730
25021
  this.pendingStepDirs.length = 0;
@@ -24746,9 +25037,16 @@ var RecordedFeeder = class {
24746
25037
  * (which flips `scrubbing` false and clears the session cache) or `dispose()`;
24747
25038
  * a genuinely NEW drag is therefore always preceded by that exit, so its
24748
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).
24749
25046
  */
24750
- beginScrub() {
25047
+ beginScrub(dragId) {
24751
25048
  if (this.disposed || this.scrubbing) return;
25049
+ this.scrubDragId = dragId;
24752
25050
  this.seekToken++;
24753
25051
  this.advanceToken++;
24754
25052
  this.scrubbing = true;
@@ -24757,17 +25055,25 @@ var RecordedFeeder = class {
24757
25055
  this.scrubSegment = null;
24758
25056
  this.scrubCredit = SCRUB_CREDIT_CAP;
24759
25057
  this.scrubPrevTarget = null;
24760
- this.scrubDirRun = 0;
24761
25058
  this.scrubPrefetched = /* @__PURE__ */ new Set();
24762
25059
  this.scrubCursor = null;
24763
25060
  this.scrubNoFootageLogged = false;
24764
25061
  this.scrubParkLogged = false;
25062
+ this.scrubDeadlineLogged = false;
25063
+ this.scrubOversizeLogged = false;
25064
+ this.scrubProfileLogged = false;
25065
+ this.scrubPushedThisDrag = false;
25066
+ this.scrubResolve = null;
25067
+ this.scrubVelocity = null;
25068
+ this.scrubLastSampleMs = null;
25069
+ this.scrubVelRun = 0;
24765
25070
  this.scrubOutstanding = [];
24766
25071
  this.clearScrubAckTimeout();
24767
25072
  this.scrubAckTimeoutLogged = false;
24768
25073
  this.scrubPendingHint = null;
24769
25074
  this.scrubStartedAtMs = performance.now();
24770
25075
  this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
25076
+ this.reportProfileDegradation();
24771
25077
  this.scrubDemuxMark = this.deps.demuxTally?.counts() ?? null;
24772
25078
  this.scrubLastPushLogAtMs = 0;
24773
25079
  this.scrubLastSlowLocateLogAtMs = 0;
@@ -24831,6 +25137,7 @@ var RecordedFeeder = class {
24831
25137
  this.scrubWarmBackMs = opts?.backMs ?? SCRUB_WARM_BACK_MS;
24832
25138
  this.scrubWarmFwdMs = opts?.fwdMs ?? SCRUB_WARM_FWD_MS;
24833
25139
  this.deps.onLog?.("scrub-feeder:warm-start", {
25140
+ dragId: this.scrubDragId,
24834
25141
  centreMs,
24835
25142
  fillGen: this.scrubWarmGen
24836
25143
  });
@@ -24856,6 +25163,7 @@ var RecordedFeeder = class {
24856
25163
  lastWorked = gen;
24857
25164
  if (gen === this.scrubWarmGen) {
24858
25165
  this.deps.onLog?.("scrub-feeder:warm-done", {
25166
+ dragId: this.scrubDragId,
24859
25167
  fillGen: gen,
24860
25168
  idle: this.idleWarm && !this.scrubbing,
24861
25169
  fetched,
@@ -24892,6 +25200,19 @@ var RecordedFeeder = class {
24892
25200
  if (Math.abs(targetMs - centre) <= SCRUB_RECENTER_MS) return;
24893
25201
  this.recenterScrubWarm(targetMs);
24894
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
+ }
24895
25216
  async warmScrubWindow(centreMs, dragGen, fillGen, backMs, fwdMs) {
24896
25217
  const spent = await this.warmScrubDirection(centreMs, -1, backMs, 0, dragGen, fillGen);
24897
25218
  return this.warmScrubDirection(centreMs, 1, fwdMs, spent, dragGen, fillGen);
@@ -24931,6 +25252,10 @@ var RecordedFeeder = class {
24931
25252
  durMs: warm.durMs,
24932
25253
  fetched: false
24933
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
+ }
24934
25259
  if (this.idleWarm && !this.scrubbing) {
24935
25260
  await new Promise((r) => setTimeout(r, this.deps.idleWarmGapMs ?? IDLE_WARM_READ_GAP_MS));
24936
25261
  while (this.inFlightSeekEpochMs !== null) {
@@ -24950,7 +25275,7 @@ var RecordedFeeder = class {
24950
25275
  if (loc.kind !== "segment") return null;
24951
25276
  if (!await this.waitForTickReadIdle(fillGen)) return null;
24952
25277
  const load = await this.loadScrubFragment(loc, probeMs);
24953
- if (load !== null && this.scrubbing) this.scrubStats?.recordPrefetchRead(load.readBytes);
25278
+ if (load !== null && this.scrubbing) this.scrubStats?.recordWarmRead(load.readBytes);
24954
25279
  if (!this.warmLoopAlive(dragGen) || load === null) return null;
24955
25280
  if (fillGen !== this.scrubWarmGen) return null;
24956
25281
  this.scrubCache.put(this.deps.profile, load.seg);
@@ -24982,6 +25307,7 @@ var RecordedFeeder = class {
24982
25307
  */
24983
25308
  scrubTo(epochMs, hint) {
24984
25309
  if (this.disposed || !this.scrubbing) return;
25310
+ this.noteScrubSample(epochMs);
24985
25311
  this.scrubPendingEpochMs = epochMs;
24986
25312
  this.scrubPendingHint = hint ?? null;
24987
25313
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
@@ -25069,6 +25395,7 @@ var RecordedFeeder = class {
25069
25395
  if (!this.scrubAckTimeoutLogged) {
25070
25396
  this.scrubAckTimeoutLogged = true;
25071
25397
  this.deps.onLog?.("scrub-feeder:ack-timeout", {
25398
+ dragId: this.scrubDragId,
25072
25399
  pushes: expired,
25073
25400
  frames,
25074
25401
  timeoutMs: SCRUB_ACK_TIMEOUT_MS,
@@ -25100,6 +25427,7 @@ var RecordedFeeder = class {
25100
25427
  if (!this.scrubParkLogged) {
25101
25428
  this.scrubParkLogged = true;
25102
25429
  this.deps.onLog?.("scrub-feeder:parked", {
25430
+ dragId: this.scrubDragId,
25103
25431
  pendingEpochMs: this.scrubPendingEpochMs,
25104
25432
  outstanding: this.scrubOutstanding.length
25105
25433
  });
@@ -25107,7 +25435,7 @@ var RecordedFeeder = class {
25107
25435
  return;
25108
25436
  }
25109
25437
  const target = this.scrubPendingEpochMs;
25110
- const hint = this.scrubPendingHint;
25438
+ let hint = this.scrubPendingHint;
25111
25439
  this.scrubPendingEpochMs = null;
25112
25440
  this.scrubPendingHint = null;
25113
25441
  if (target === null) {
@@ -25132,74 +25460,62 @@ var RecordedFeeder = class {
25132
25460
  seg = this.scrubCache.covering(this.deps.profile, target);
25133
25461
  if (seg) {
25134
25462
  coverage = "cache";
25135
- if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordPrefetchHit();
25463
+ if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordWarmHit();
25136
25464
  }
25137
25465
  }
25138
25466
  if (!seg) {
25139
- this.scrubTickReadInFlight = true;
25140
- try {
25141
- this.maybeRecenterScrubWarm(target);
25142
- const locateStartMs = performance.now();
25143
- let loc = await this.withTimeout(this.deps.locate({
25144
- deviceId: this.deps.deviceId,
25145
- profile: this.deps.profile,
25146
- epochMs: target
25147
- }));
25148
- locateCalls += 1;
25149
- locateMs = performance.now() - locateStartMs;
25150
- this.maybeLogSlowLocate(target, locateMs);
25151
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25152
- if (loc.kind === "gap") {
25153
- const decision = resolveSnapTarget(loc, target);
25154
- if (decision.kind !== "snap") {
25155
- if (!this.scrubNoFootageLogged) {
25156
- this.scrubNoFootageLogged = true;
25157
- if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25158
- epochMs: target,
25159
- nearestMs: decision.snapMs,
25160
- distanceMs: decision.distanceMs
25161
- });
25162
- else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25163
- }
25164
- return;
25165
- }
25166
- const snapStartMs = performance.now();
25167
- const snapped = await this.withTimeout(this.deps.locate({
25168
- deviceId: this.deps.deviceId,
25169
- profile: this.deps.profile,
25170
- epochMs: decision.snapMs
25171
- }));
25172
- locateCalls += 1;
25173
- locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25174
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25175
- if (snapped.kind !== "segment") {
25176
- if (!this.scrubNoFootageLogged) {
25177
- this.scrubNoFootageLogged = true;
25178
- this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25179
- }
25180
- return;
25181
- }
25182
- this.deps.onLog?.("scrub-feeder:snapped", {
25183
- requestedMs: target,
25184
- snappedMs: decision.snapMs
25467
+ const cfg = this.deps.scrubDeadline;
25468
+ const rideWindowMs = cfg?.rideWindowMs ?? SCRUB_RIDE_WINDOW_MS;
25469
+ const inFlight = this.scrubResolve;
25470
+ let entry;
25471
+ if (inFlight !== null && (Math.abs(target - inFlight.target) <= rideWindowMs || this.scrubResolvesInFlight >= SCRUB_RESOLVE_MAX_INFLIGHT)) {
25472
+ entry = inFlight;
25473
+ this.scrubStats?.recordResolveJoin();
25474
+ } else {
25475
+ const leadMs = this.scrubLeadMs();
25476
+ if (leadMs !== 0) this.scrubStats?.recordLead(leadMs);
25477
+ const readTarget = target + leadMs;
25478
+ entry = {
25479
+ target: readTarget,
25480
+ promise: this.startScrubResolve(readTarget, gen)
25481
+ };
25482
+ this.scrubResolve = entry;
25483
+ }
25484
+ const deadlineMs = this.scrubPushedThisDrag ? cfg?.tickMs ?? SCRUB_TICK_DEADLINE_MS : cfg?.firstTickMs ?? SCRUB_FIRST_TICK_DEADLINE_MS;
25485
+ const res = await this.raceDeadline(entry.promise, performance.now() + deadlineMs);
25486
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25487
+ if (res === "deadline") {
25488
+ this.scrubStats?.recordDeadlineMiss();
25489
+ if (this.scrubPendingEpochMs === null) this.scrubPendingEpochMs = target;
25490
+ if (!this.scrubDeadlineLogged) {
25491
+ this.scrubDeadlineLogged = true;
25492
+ this.deps.onLog?.("scrub-feeder:deadline-miss", {
25493
+ dragId: this.scrubDragId,
25494
+ epochMs: target,
25495
+ deadlineMs,
25496
+ first: !this.scrubPushedThisDrag,
25497
+ inFlight: this.scrubResolvesInFlight
25185
25498
  });
25186
- loc = snapped;
25187
- effTarget = decision.snapMs;
25188
- }
25189
- seg = this.scrubCache.covering(this.deps.profile, effTarget);
25190
- if (!seg) {
25191
- const load = await this.loadScrubFragment(loc, effTarget);
25192
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
25193
- seg = load.seg;
25194
- readMs = load.readMs;
25195
- readBytes = load.readBytes;
25196
- demuxMs = load.demuxMs;
25197
- wholeSegmentRead = load.wholeSegmentRead;
25198
- this.scrubCache.put(this.deps.profile, seg);
25199
25499
  }
25200
- } finally {
25201
- this.scrubTickReadInFlight = false;
25500
+ return;
25202
25501
  }
25502
+ if (res === null) return;
25503
+ seg = res.seg;
25504
+ const newest = this.scrubPendingEpochMs;
25505
+ if (res.effTarget !== res.requested) effTarget = res.effTarget;
25506
+ else if (newest !== null && fragmentCovers(seg, newest)) {
25507
+ effTarget = newest;
25508
+ hint = this.scrubPendingHint;
25509
+ this.scrubPendingEpochMs = null;
25510
+ this.scrubPendingHint = null;
25511
+ } else effTarget = newest ?? target;
25512
+ if (effTarget !== res.effTarget) this.scrubStats?.recordRetarget();
25513
+ locateCalls = res.locateCalls;
25514
+ locateMs = res.locateMs;
25515
+ readMs = res.readMs;
25516
+ readBytes = res.readBytes;
25517
+ demuxMs = res.demuxMs;
25518
+ wholeSegmentRead = res.wholeSegmentRead;
25203
25519
  }
25204
25520
  this.scrubSegment = seg;
25205
25521
  const prevTarget = this.scrubPrevTarget;
@@ -25266,6 +25582,7 @@ var RecordedFeeder = class {
25266
25582
  pushedAtMs
25267
25583
  }];
25268
25584
  this.scrubCredit -= 1;
25585
+ this.scrubPushedThisDrag = true;
25269
25586
  this.armScrubAckTimeout();
25270
25587
  this.clockHasAdvanced = true;
25271
25588
  let runBytes = 0;
@@ -25297,6 +25614,7 @@ var RecordedFeeder = class {
25297
25614
  if (this.scrubLastPushLogAtMs === 0 || pushedAtMs - this.scrubLastPushLogAtMs >= 1e3) {
25298
25615
  this.scrubLastPushLogAtMs = pushedAtMs;
25299
25616
  this.deps.onLog?.("scrub-feeder:pushed", {
25617
+ dragId: this.scrubDragId,
25300
25618
  epochMs: effTarget,
25301
25619
  mode,
25302
25620
  dir,
@@ -25314,10 +25632,11 @@ var RecordedFeeder = class {
25314
25632
  });
25315
25633
  }
25316
25634
  this.deps.onPosition(this.clampIntoSegment(seg, effTarget), pushSeq);
25317
- this.maybeScrubPrefetch(seg, effTarget, gen);
25635
+ this.scrubPrevTarget = effTarget;
25318
25636
  } catch (err) {
25319
25637
  this.scrubStats?.recordFailure();
25320
25638
  this.deps.onLog?.("scrub-feeder:tick-failed", {
25639
+ dragId: this.scrubDragId,
25321
25640
  epochMs: target,
25322
25641
  error: err instanceof Error ? err.message : String(err)
25323
25642
  });
@@ -25327,6 +25646,115 @@ var RecordedFeeder = class {
25327
25646
  }
25328
25647
  }
25329
25648
  /**
25649
+ * ONE fragment resolution for the drag: locate (with gap snapping), read,
25650
+ * demux, cache. Deliberately NOT bound to the tick that started it.
25651
+ *
25652
+ * The tick races this with the frame's deadline and may walk away; this
25653
+ * promise then finishes on its own and puts the fragment in the session
25654
+ * cache, because the read is already on the wire and abandoning its RESULT
25655
+ * as well would turn a dropped frame into wasted disk. That is what makes
25656
+ * the deadline cheap: the tick after a miss is usually a cache hit.
25657
+ *
25658
+ * Null ⇒ nothing to serve (dead zone, superseded generation, or a load the
25659
+ * seek token invalidated). Throws propagate to the tick's `tick-failed`.
25660
+ */
25661
+ async startScrubResolve(target, gen) {
25662
+ this.scrubResolvesInFlight += 1;
25663
+ this.scrubTickReadInFlight = true;
25664
+ let locateCalls = 0;
25665
+ let locateMs = null;
25666
+ try {
25667
+ this.maybeRecenterScrubWarm(target);
25668
+ const locateStartMs = performance.now();
25669
+ let loc = await this.withTimeout(this.deps.locate({
25670
+ deviceId: this.deps.deviceId,
25671
+ profile: this.deps.profile,
25672
+ epochMs: target
25673
+ }));
25674
+ locateCalls += 1;
25675
+ locateMs = performance.now() - locateStartMs;
25676
+ this.maybeLogSlowLocate(target, locateMs);
25677
+ if (this.disposed || gen !== this.scrubToken) return null;
25678
+ let effTarget = target;
25679
+ if (loc.kind === "gap") {
25680
+ const decision = resolveSnapTarget(loc, target);
25681
+ if (decision.kind !== "snap") {
25682
+ if (!this.scrubNoFootageLogged) {
25683
+ this.scrubNoFootageLogged = true;
25684
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25685
+ dragId: this.scrubDragId,
25686
+ epochMs: target,
25687
+ nearestMs: decision.snapMs,
25688
+ distanceMs: decision.distanceMs
25689
+ });
25690
+ else this.deps.onLog?.("scrub-feeder:no-footage", {
25691
+ dragId: this.scrubDragId,
25692
+ epochMs: target
25693
+ });
25694
+ }
25695
+ return null;
25696
+ }
25697
+ const snapStartMs = performance.now();
25698
+ const snapped = await this.withTimeout(this.deps.locate({
25699
+ deviceId: this.deps.deviceId,
25700
+ profile: this.deps.profile,
25701
+ epochMs: decision.snapMs
25702
+ }));
25703
+ locateCalls += 1;
25704
+ locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25705
+ if (this.disposed || gen !== this.scrubToken) return null;
25706
+ if (snapped.kind !== "segment") {
25707
+ if (!this.scrubNoFootageLogged) {
25708
+ this.scrubNoFootageLogged = true;
25709
+ this.deps.onLog?.("scrub-feeder:no-footage", {
25710
+ dragId: this.scrubDragId,
25711
+ epochMs: target
25712
+ });
25713
+ }
25714
+ return null;
25715
+ }
25716
+ this.deps.onLog?.("scrub-feeder:snapped", {
25717
+ dragId: this.scrubDragId,
25718
+ requestedMs: target,
25719
+ snappedMs: decision.snapMs
25720
+ });
25721
+ loc = snapped;
25722
+ effTarget = decision.snapMs;
25723
+ }
25724
+ const warm = this.scrubCache.covering(this.deps.profile, effTarget);
25725
+ if (warm) return {
25726
+ seg: warm,
25727
+ requested: target,
25728
+ effTarget,
25729
+ locateCalls,
25730
+ locateMs,
25731
+ readMs: null,
25732
+ readBytes: null,
25733
+ demuxMs: null,
25734
+ wholeSegmentRead: false
25735
+ };
25736
+ const load = await this.loadScrubFragment(loc, effTarget);
25737
+ if (this.disposed || load === null) return null;
25738
+ this.scrubCache.put(this.deps.profile, load.seg);
25739
+ if (gen !== this.scrubToken) return null;
25740
+ return {
25741
+ seg: load.seg,
25742
+ requested: target,
25743
+ effTarget,
25744
+ locateCalls,
25745
+ locateMs,
25746
+ readMs: load.readMs,
25747
+ readBytes: load.readBytes,
25748
+ demuxMs: load.demuxMs,
25749
+ wholeSegmentRead: load.wholeSegmentRead
25750
+ };
25751
+ } finally {
25752
+ this.scrubResolvesInFlight -= 1;
25753
+ if (this.scrubResolvesInFlight === 0) this.scrubTickReadInFlight = false;
25754
+ if (this.scrubResolve?.target === target) this.scrubResolve = null;
25755
+ }
25756
+ }
25757
+ /**
25330
25758
  * Index of the nearest AU at-or-before the drag target within `seg`
25331
25759
  * (default 0 — the fragment's params-fused IDR, which the demux contract
25332
25760
  * guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
@@ -25419,7 +25847,21 @@ var RecordedFeeder = class {
25419
25847
  */
25420
25848
  readsWholeSegment(loc) {
25421
25849
  if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
25422
- return loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES;
25850
+ if (loc.bytes === void 0 || loc.bytes <= 15e5) return true;
25851
+ this.scrubStats?.recordOversizeSegment();
25852
+ if (!this.scrubOversizeLogged) {
25853
+ this.scrubOversizeLogged = true;
25854
+ this.deps.onLog?.("scrub-feeder:oversize-segment", {
25855
+ dragId: this.scrubDragId,
25856
+ profile: this.deps.profile,
25857
+ startMs: loc.startMs,
25858
+ durMs: loc.durMs,
25859
+ bytes: loc.bytes,
25860
+ maxBytes: SCRUB_WHOLE_SEGMENT_MAX_BYTES,
25861
+ bytesPerSecond: loc.durMs > 0 ? Math.round(loc.bytes / (loc.durMs / 1e3)) : null
25862
+ });
25863
+ }
25864
+ return false;
25423
25865
  }
25424
25866
  /**
25425
25867
  * Fold one read into the recent-read window that governs speculation. The
@@ -25430,15 +25872,74 @@ var RecordedFeeder = class {
25430
25872
  this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
25431
25873
  }
25432
25874
  /**
25433
- * Is the disk the problem right now? Median of the recent reads against
25434
- * {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
25435
- * noise and the decision must not swing on it; and never before a few
25436
- * samples, or a cold first read would suppress the whole drag.
25875
+ * A drag that is NOT on the scrub profile runs with every scrub optimisation
25876
+ * off: the whole-segment read unit, the 48 MB fragment-cache budget and the
25877
+ * entry warm are all gated on the exact string `low`
25878
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}). A momentary hole in `low` therefore
25879
+ * turns a drag into 3-seek GOP reads over ~5 MB `high` segments — measured at
25880
+ * 2 642 ms cold — and the client cannot repair it.
25881
+ *
25882
+ * It used to be indistinguishable from a slow disk. Now it says so, once per
25883
+ * drag, and the census carries the flag beside the numbers it explains.
25884
+ */
25885
+ reportProfileDegradation() {
25886
+ if (this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) return;
25887
+ this.scrubStats?.recordProfileDegraded(this.deps.profile);
25888
+ if (this.scrubProfileLogged) return;
25889
+ this.scrubProfileLogged = true;
25890
+ this.deps.onLog?.("scrub-feeder:profile-degraded", {
25891
+ dragId: this.scrubDragId,
25892
+ profile: this.deps.profile,
25893
+ scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
25894
+ });
25895
+ }
25896
+ /**
25897
+ * Median of the recent reads — what a read costs on this disk RIGHT NOW.
25898
+ * Null until there are enough samples to call it anything: a single cold
25899
+ * first read is not a median, and acting on it would aim a whole gesture.
25437
25900
  */
25438
- scrubReadsAreSlow() {
25439
- if (this.scrubReadMsWindow.length < 3) return false;
25901
+ scrubReadMedianMs() {
25902
+ if (this.scrubReadMsWindow.length < SCRUB_LEAD_MIN_READ_SAMPLES) return null;
25440
25903
  const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
25441
- return (sorted[Math.floor(sorted.length / 2)] ?? 0) >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25904
+ return sorted[Math.floor(sorted.length / 2)] ?? null;
25905
+ }
25906
+ /**
25907
+ * Fold one pointer sample into the drag's media velocity (ms of timeline per
25908
+ * ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
25909
+ */
25910
+ noteScrubSample(epochMs) {
25911
+ const now = performance.now();
25912
+ const prev = this.scrubLastSampleMs;
25913
+ const prevAt = this.scrubLastSampleAtMs;
25914
+ this.scrubLastSampleMs = epochMs;
25915
+ this.scrubLastSampleAtMs = now;
25916
+ if (prev === null) return;
25917
+ const gapMs = now - prevAt;
25918
+ if (gapMs < SCRUB_VELOCITY_MIN_GAP_MS || gapMs > SCRUB_VELOCITY_MAX_GAP_MS) return;
25919
+ const deltaMs = epochMs - prev;
25920
+ const dir = Math.sign(deltaMs);
25921
+ if (dir === 0) {
25922
+ this.scrubVelRun = 0;
25923
+ return;
25924
+ }
25925
+ this.scrubVelRun = Math.sign(this.scrubVelRun) === dir ? this.scrubVelRun + dir : dir;
25926
+ const v = deltaMs / gapMs;
25927
+ this.scrubVelocity = this.scrubVelocity === null ? v : this.scrubVelocity * (1 - SCRUB_VELOCITY_EMA) + v * SCRUB_VELOCITY_EMA;
25928
+ }
25929
+ /**
25930
+ * How far AHEAD of the finger this read should aim — see the
25931
+ * {@link SCRUB_LEAD_MIN_READ_SAMPLES} docblock for why this exists and what
25932
+ * bounds it. Zero whenever the prediction is not earned: no measured reads,
25933
+ * no sustained direction, or a velocity whose sign disagrees with the run.
25934
+ */
25935
+ scrubLeadMs() {
25936
+ const velocity = this.scrubVelocity;
25937
+ if (velocity === null) return 0;
25938
+ if (Math.abs(this.scrubVelRun) < SCRUB_LEAD_MIN_DIR_RUN) return 0;
25939
+ if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
25940
+ const readMs = this.scrubReadMedianMs();
25941
+ if (readMs === null) return 0;
25942
+ return scrubLeadDecision(velocity, readMs).leadMs;
25442
25943
  }
25443
25944
  /**
25444
25945
  * A single slow `locate` gets its own line, throttled per drag. Reserved for
@@ -25454,6 +25955,7 @@ var RecordedFeeder = class {
25454
25955
  if (this.scrubLastSlowLocateLogAtMs !== 0 && now - this.scrubLastSlowLocateLogAtMs < SCRUB_PATHOLOGY_LOG_INTERVAL_MS) return;
25455
25956
  this.scrubLastSlowLocateLogAtMs = now;
25456
25957
  this.deps.onLog?.("scrub-feeder:slow-locate", {
25958
+ dragId: this.scrubDragId,
25457
25959
  epochMs,
25458
25960
  locateMs: Math.round(locateMs),
25459
25961
  thresholdMs: threshold
@@ -25470,7 +25972,7 @@ var RecordedFeeder = class {
25470
25972
  * interval comes out warm for the next drag. Aborts (without landing — the
25471
25973
  * caller owns that) the moment a newer seek bumps the token.
25472
25974
  */
25473
- async animateSeekTravel(fromMs, toMs, token) {
25975
+ async animateSeekTravel(fromMs, toMs, token, dragId) {
25474
25976
  const cfg = this.deps.seekAnimation;
25475
25977
  const budgetMs = cfg?.budgetMs ?? SEEK_ANIMATION_BUDGET_MS;
25476
25978
  const tickMs = cfg?.tickMs ?? SEEK_ANIMATION_TICK_MS;
@@ -25502,6 +26004,7 @@ var RecordedFeeder = class {
25502
26004
  if (this.disposed || token !== this.seekToken) return;
25503
26005
  }
25504
26006
  if (stills > 0) this.deps.onLog?.("recorded:seek-travel", {
26007
+ dragId,
25505
26008
  fromMs,
25506
26009
  toMs,
25507
26010
  stills,
@@ -25596,79 +26099,41 @@ var RecordedFeeder = class {
25596
26099
  return stills;
25597
26100
  }
25598
26101
  /**
25599
- * Update the drag-direction run from the just-served target and, once the drag
25600
- * shows a sustained direction, kick off a single background prefetch of the
25601
- * NEXT fragment in that direction. Prefetch fills the cache only — it never
25602
- * pushes, so crossing into the neighbour later is an instant cache hit.
25603
- */
25604
- maybeScrubPrefetch(seg, target, gen) {
25605
- const prev = this.scrubPrevTarget;
25606
- this.scrubPrevTarget = target;
25607
- if (prev === null) {
25608
- this.scrubDirRun = 0;
25609
- return;
25610
- }
25611
- const stepMs = target - prev;
25612
- const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
25613
- if (dir === 0) {
25614
- this.scrubDirRun = 0;
25615
- return;
25616
- }
25617
- this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
25618
- if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
25619
- if (this.scrubReadsAreSlow()) {
25620
- this.scrubStats?.recordPrefetchSuppressed();
25621
- return;
25622
- }
25623
- if (this.scrubFillLoopActive) return;
25624
- const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
25625
- const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
25626
- for (let horizon = 1; horizon <= horizons; horizon++) {
25627
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
25628
- const probeMs = target + dir * aheadMs * horizon;
25629
- if (probeMs < 0) continue;
25630
- if (this.scrubPrefetchProbes.has(probeMs)) continue;
25631
- if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
25632
- if (this.scrubWarmCentreMs !== null && Math.abs(probeMs - this.scrubWarmCentreMs) > SCRUB_WARM_AROUND_MS) continue;
25633
- this.scrubPrefetch(probeMs, gen);
25634
- }
25635
- }
25636
- /**
25637
- * Speculative read: locate + read + demux the fragment covering `probeMs` into
25638
- * `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
25639
- * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
25640
- * swallowed (the fragment just demuxes on demand). Never pushes and never
25641
- * mutates latest-wins state.
26102
+ * The directional prefetch is GONE (2026-08-26), not disabled.
25642
26103
  *
25643
- * Every read it issues is COUNTED (`prefetchReads` / `prefetchBytes`), and the
25644
- * fragment it lands is remembered so a later tick hitting it counts as a
25645
- * `prefetchHit`. Speculation that cannot be shown to pay for itself is just
25646
- * load on the disk the drag is already waiting for.
25647
- */
25648
- async scrubPrefetch(probeMs, gen) {
25649
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
25650
- this.scrubPrefetchInFlight += 1;
25651
- this.scrubPrefetchProbes.add(probeMs);
25652
- try {
25653
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25654
- if (this.scrubCache.covering(this.deps.profile, probeMs)) return;
25655
- const loc = await this.withTimeout(this.deps.locate({
25656
- deviceId: this.deps.deviceId,
25657
- profile: this.deps.profile,
25658
- epochMs: probeMs
25659
- }));
25660
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25661
- if (loc.kind !== "segment") return;
25662
- const load = await this.loadScrubFragment(loc, probeMs);
25663
- if (load !== null) this.scrubStats?.recordPrefetchRead(load.readBytes);
25664
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
25665
- this.scrubCache.put(this.deps.profile, load.seg);
25666
- this.scrubPrefetched.add(load.seg.startMs);
25667
- } catch {} finally {
25668
- this.scrubPrefetchInFlight -= 1;
25669
- this.scrubPrefetchProbes.delete(probeMs);
25670
- }
25671
- }
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
+ */
25672
26137
  /**
25673
26138
  * Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
25674
26139
  * paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
@@ -25852,7 +26317,7 @@ var RecordedFeeder = class {
25852
26317
  this.advanceLoop(gen, initial).catch(() => {
25853
26318
  if (!this.disposed && gen === this.advanceToken) {
25854
26319
  this.stopPositionReports();
25855
- this.deps.onState("error");
26320
+ this.emitState("error", "advance-loop-failed", this.seekToken, this.scrubDragId);
25856
26321
  }
25857
26322
  });
25858
26323
  }
@@ -25869,7 +26334,7 @@ var RecordedFeeder = class {
25869
26334
  if (prepared === null) return;
25870
26335
  if (prepared === "ended") {
25871
26336
  this.stopPositionReports();
25872
- this.deps.onState("ended");
26337
+ this.emitState("ended", "advance-ended", this.seekToken, this.scrubDragId);
25873
26338
  return;
25874
26339
  }
25875
26340
  if (isResumedSegment) {} else {
@@ -26146,7 +26611,10 @@ var RecordedFeeder = class {
26146
26611
  const mark = this.scrubDemuxMark;
26147
26612
  const demux = mark && this.deps.demuxTally ? this.deps.demuxTally.deltaSince(mark) : void 0;
26148
26613
  this.scrubDemuxMark = null;
26149
- 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
+ });
26150
26618
  }
26151
26619
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
26152
26620
  dispose() {
@@ -26159,10 +26627,17 @@ var RecordedFeeder = class {
26159
26627
  this.scrubToken++;
26160
26628
  this.scrubSegment = null;
26161
26629
  this.scrubPrevTarget = null;
26162
- this.scrubDirRun = 0;
26163
26630
  this.scrubCursor = null;
26164
26631
  this.scrubNoFootageLogged = false;
26165
26632
  this.scrubParkLogged = false;
26633
+ this.scrubDeadlineLogged = false;
26634
+ this.scrubOversizeLogged = false;
26635
+ this.scrubProfileLogged = false;
26636
+ this.scrubPushedThisDrag = false;
26637
+ this.scrubResolve = null;
26638
+ this.scrubVelocity = null;
26639
+ this.scrubLastSampleMs = null;
26640
+ this.scrubVelRun = 0;
26166
26641
  this.scrubOutstanding = [];
26167
26642
  this.clearScrubAckTimeout();
26168
26643
  this.scrubPendingHint = null;
@@ -26243,10 +26718,11 @@ var TimelineSession = class {
26243
26718
  await this.playAt(msg.epoch, this.profile, { travelFromMs: this.lastPositionMs });
26244
26719
  break;
26245
26720
  case "scrubCommit":
26246
- 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);
26247
26723
  break;
26248
26724
  case "scrubStart":
26249
- (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
26725
+ (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub(msg.dragId);
26250
26726
  break;
26251
26727
  case "scrubTo": {
26252
26728
  const hint = msg.m !== void 0 && msg.v !== void 0 ? {
@@ -26500,7 +26976,7 @@ function createOffThreadJsParse(deps) {
26500
26976
  if (!w) return deps.parseInProcess(bytes);
26501
26977
  const id = nextId;
26502
26978
  nextId += 1;
26503
- const copy = bytes.slice();
26979
+ const copy = new Uint8Array(bytes);
26504
26980
  const request = {
26505
26981
  id,
26506
26982
  bytes: copy
@@ -27937,14 +28413,21 @@ var BrokerWebrtcServer = class {
27937
28413
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
27938
28414
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
27939
28415
  },
27940
- onState: (s) => {
27941
- sessionLogger.info("recorded: feeder state", { meta: {
27942
- sessionId,
27943
- deviceId,
27944
- profile,
27945
- state: s
27946
- } });
27947
- 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);
27948
28431
  },
27949
28432
  onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
27950
28433
  onLog: (msg, data) => sessionLogger.info(msg, {