@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
@@ -1,9 +1,9 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { At as makeSourceBrokerId, C as RATE_CONTROL_TIGHT, Ct as CAM_PROFILE_ORDER, E as RingBuffer, Et as createEvent, Gt as string, H as egressTranscodeSharingKey, Ht as number, It as _enum, Kt as union, Lt as array, M as batteryCapability, N as cameraStreamsCapability, Nt as parseProfileBrokerId, O as addonWidgetsSourceCapability, Ot as isEvent, P as createHwAccelCache, R as deriveBatteryPresence, Rt as boolean, S as RATE_CONTROL_RELAXED, St as BaseAddon, Tt as DeviceType, U as egressTransportFromRequest, Ut as object, Vt as literal, Wt as record, Y as maskUrlCredentials, _t as AUDIO_PRESETS, bt as invocationFromEncodeProfile, ct as resolveEgressDecodeHwAccel, gt as errMsg, h as EncodeProfileSchema, ht as webrtcSessionCapability, i as BatteryStatusSchema, jt as nodePin, kt as makeProfileBrokerId, pt as streamBrokerCapability, qt as EventCategory, vt as Fmp4BoxSplitter, wt as DeviceFeature, xt as isSoftwareDecode, yt as buildFfmpegArgs$1, zt as discriminatedUnion } from "../dist-HLAOw6Pe.mjs";
2
+ import { At as makeSourceBrokerId, C as RATE_CONTROL_TIGHT, Ct as CAM_PROFILE_ORDER, E as RingBuffer, Et as createEvent, Gt as string, H as egressTranscodeSharingKey, Ht as number, It as _enum, Kt as union, Lt as array, M as batteryCapability, N as cameraStreamsCapability, Nt as parseProfileBrokerId, O as addonWidgetsSourceCapability, Ot as isEvent, P as createHwAccelCache, R as deriveBatteryPresence, Rt as boolean, S as RATE_CONTROL_RELAXED, St as BaseAddon, Tt as DeviceType, U as egressTransportFromRequest, Ut as object, Vt as literal, Wt as record, Y as maskUrlCredentials, _t as AUDIO_PRESETS, bt as invocationFromEncodeProfile, ct as resolveEgressDecodeHwAccel, gt as errMsg, h as EncodeProfileSchema, ht as webrtcSessionCapability, i as BatteryStatusSchema, jt as nodePin, kt as makeProfileBrokerId, pt as streamBrokerCapability, qt as EventCategory, vt as Fmp4BoxSplitter, wt as DeviceFeature, xt as isSoftwareDecode, yt as buildFfmpegArgs$1, zt as discriminatedUnion } from "../dist-Wt81pN5Y.mjs";
3
3
  import { t as parseRestreamPath } from "../restream-intent-B4BXZra7.mjs";
4
4
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
6
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-D-TLcHW5.mjs";
6
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-BW-Q-j2A.mjs";
7
7
  import { createRequire } from "node:module";
8
8
  import * as crypto$1 from "node:crypto";
9
9
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -385,6 +385,83 @@ var STREAM_BROKER_RETIRED_ROOT_KEYS = [
385
385
  }
386
386
  ];
387
387
  //#endregion
388
+ //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
389
+ /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
390
+ function logBannerArgs(level) {
391
+ return [
392
+ "-hide_banner",
393
+ "-loglevel",
394
+ level
395
+ ];
396
+ }
397
+ //#endregion
398
+ //#region src/shared/ffmpeg-decode-args.ts
399
+ /**
400
+ * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
401
+ *
402
+ * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
403
+ * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
404
+ * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
405
+ * `qsv` first on Intel, so we re-rank its result through this table before
406
+ * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
407
+ * trail for NVIDIA. A method not listed here keeps its incoming relative order,
408
+ * placed AFTER every ranked one.
409
+ */
410
+ var DECODE_HWACCEL_RANK = [
411
+ "videotoolbox",
412
+ "vaapi",
413
+ "qsv",
414
+ "cuda",
415
+ "nvdec",
416
+ "d3d11va",
417
+ "dxva2",
418
+ "amf",
419
+ "vdpau",
420
+ "drm"
421
+ ];
422
+ /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
423
+ function decodeHwAccelRankIndex(method) {
424
+ const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
425
+ return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
426
+ }
427
+ /**
428
+ * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
429
+ * `'auto'` decode session, best-first. The session walks this chain on an
430
+ * early-exit-with-no-frames and only falls to software once it is exhausted.
431
+ *
432
+ * Strategy:
433
+ * - Seed candidates from the kernel-preferred order UNION the canonical rank
434
+ * UNION the build's supported list, so a method the kernel omitted but the
435
+ * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
436
+ * still attempted.
437
+ * - When the build's supported-method list is known (non-empty), keep only
438
+ * methods it actually offers; on a probe miss (empty) keep just the kernel
439
+ * preferences (avoid blindly spawning every backend).
440
+ * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
441
+ * `qsv`. A supported method not in the rank table keeps its position AFTER
442
+ * every ranked one (stable sort). Returns lowercased method names; empty ⇒
443
+ * pure software.
444
+ */
445
+ function rankDecodeHwAccels(preferred, supportedMethods) {
446
+ const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
447
+ const hasSupportList = supported.size > 0;
448
+ const pool = hasSupportList ? [
449
+ ...preferred,
450
+ ...DECODE_HWACCEL_RANK,
451
+ ...supportedMethods
452
+ ] : [...preferred];
453
+ const seen = /* @__PURE__ */ new Set();
454
+ const candidates = [];
455
+ for (const raw of pool) {
456
+ const method = raw.toLowerCase();
457
+ if (seen.has(method)) continue;
458
+ seen.add(method);
459
+ if (hasSupportList && !supported.has(method)) continue;
460
+ candidates.push(method);
461
+ }
462
+ return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
463
+ }
464
+ //#endregion
388
465
  //#region src/stream-broker/battery-wake-on-play.ts
389
466
  /**
390
467
  * Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
@@ -587,83 +664,6 @@ async function wakeBatteryDeviceForPlay(deviceId, deps) {
587
664
  throw new BatteryStreamUnavailableError(deviceId, outcome, wake.awoke ? "it acknowledged the wake but no stream had appeared yet — try again in a few seconds" : "it did not answer the wake in time — try again");
588
665
  }
589
666
  //#endregion
590
- //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
591
- /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
592
- function logBannerArgs(level) {
593
- return [
594
- "-hide_banner",
595
- "-loglevel",
596
- level
597
- ];
598
- }
599
- //#endregion
600
- //#region src/shared/ffmpeg-decode-args.ts
601
- /**
602
- * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
603
- *
604
- * `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
605
- * child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
606
- * h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
607
- * `qsv` first on Intel, so we re-rank its result through this table before
608
- * building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
609
- * trail for NVIDIA. A method not listed here keeps its incoming relative order,
610
- * placed AFTER every ranked one.
611
- */
612
- var DECODE_HWACCEL_RANK = [
613
- "videotoolbox",
614
- "vaapi",
615
- "qsv",
616
- "cuda",
617
- "nvdec",
618
- "d3d11va",
619
- "dxva2",
620
- "amf",
621
- "vdpau",
622
- "drm"
623
- ];
624
- /** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
625
- function decodeHwAccelRankIndex(method) {
626
- const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
627
- return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
628
- }
629
- /**
630
- * Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
631
- * `'auto'` decode session, best-first. The session walks this chain on an
632
- * early-exit-with-no-frames and only falls to software once it is exhausted.
633
- *
634
- * Strategy:
635
- * - Seed candidates from the kernel-preferred order UNION the canonical rank
636
- * UNION the build's supported list, so a method the kernel omitted but the
637
- * build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
638
- * still attempted.
639
- * - When the build's supported-method list is known (non-empty), keep only
640
- * methods it actually offers; on a probe miss (empty) keep just the kernel
641
- * preferences (avoid blindly spawning every backend).
642
- * - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
643
- * `qsv`. A supported method not in the rank table keeps its position AFTER
644
- * every ranked one (stable sort). Returns lowercased method names; empty ⇒
645
- * pure software.
646
- */
647
- function rankDecodeHwAccels(preferred, supportedMethods) {
648
- const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
649
- const hasSupportList = supported.size > 0;
650
- const pool = hasSupportList ? [
651
- ...preferred,
652
- ...DECODE_HWACCEL_RANK,
653
- ...supportedMethods
654
- ] : [...preferred];
655
- const seen = /* @__PURE__ */ new Set();
656
- const candidates = [];
657
- for (const raw of pool) {
658
- const method = raw.toLowerCase();
659
- if (seen.has(method)) continue;
660
- seen.add(method);
661
- if (hasSupportList && !supported.has(method)) continue;
662
- candidates.push(method);
663
- }
664
- return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
665
- }
666
- //#endregion
667
667
  //#region src/stream-broker/derived/cycle-check.ts
668
668
  /**
669
669
  * Would assigning `derivedCamStreamId` to `targetProfile` create a cycle?
@@ -16274,7 +16274,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16274
16274
  }
16275
16275
  let devices;
16276
16276
  try {
16277
- devices = await api.deviceManager.listAll.query({});
16277
+ devices = await api.deviceManager.listAll.query({ projection: "slim" });
16278
16278
  } catch (err) {
16279
16279
  this.logger.warn("profile-map prune skipped — deviceManager.listAll failed", { meta: {
16280
16280
  persistedCount,
@@ -16599,7 +16599,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16599
16599
  if (!this.api) return;
16600
16600
  let devices;
16601
16601
  try {
16602
- devices = await this.api.deviceManager.listAll.query({});
16602
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16603
16603
  } catch (err) {
16604
16604
  this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", { meta: { error: errMsg(err) } });
16605
16605
  return;
@@ -16739,7 +16739,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16739
16739
  if (!this.api) return false;
16740
16740
  let devices;
16741
16741
  try {
16742
- devices = await this.api.deviceManager.listAll.query({});
16742
+ devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
16743
16743
  } catch {
16744
16744
  return false;
16745
16745
  }
@@ -23474,9 +23474,13 @@ var ControlMessageSchema = discriminatedUnion("t", [
23474
23474
  }),
23475
23475
  object({
23476
23476
  t: literal("scrubCommit"),
23477
- epoch: number()
23477
+ epoch: number(),
23478
+ dragId: string().optional()
23479
+ }),
23480
+ object({
23481
+ t: literal("scrubStart"),
23482
+ dragId: string().optional()
23478
23483
  }),
23479
- object({ t: literal("scrubStart") }),
23480
23484
  object({
23481
23485
  t: literal("scrubTo"),
23482
23486
  epoch: number(),
@@ -23628,104 +23632,6 @@ var MonotonicClock = class {
23628
23632
  this.rate = rate;
23629
23633
  }
23630
23634
  };
23631
- var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
23632
- var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
23633
- /** Demuxed bytes an entry holds — what the byte bound actually counts. */
23634
- function fragmentBytes(frag) {
23635
- let total = 0;
23636
- for (const au of frag.aus) total += au.data.length;
23637
- return total;
23638
- }
23639
- var ScrubFragmentCache = class {
23640
- maxBytes;
23641
- otherMaxBytes;
23642
- map = /* @__PURE__ */ new Map();
23643
- /**
23644
- * Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
23645
- * `high` entry must never be able to evict the `low` segments the drag is
23646
- * running on, and the reverse would be just as wrong.
23647
- */
23648
- bytesByProfile = /* @__PURE__ */ new Map();
23649
- constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
23650
- this.maxBytes = maxBytes;
23651
- this.otherMaxBytes = otherMaxBytes;
23652
- }
23653
- /** The byte budget for a profile — the scrub profile gets the real one. */
23654
- budgetFor(profile) {
23655
- return profile === "low" ? this.maxBytes : this.otherMaxBytes;
23656
- }
23657
- key(profile, startMs) {
23658
- return `${profile}|${startMs}`;
23659
- }
23660
- /** Exact-start lookup with LRU touch (re-insertion marks most recent). */
23661
- get(profile, startMs) {
23662
- const k = this.key(profile, startMs);
23663
- const frag = this.map.get(k);
23664
- if (!frag) return null;
23665
- this.map.delete(k);
23666
- this.map.set(k, frag);
23667
- return frag;
23668
- }
23669
- /**
23670
- * The cached fragment COVERING an instant, if any — the lookup that lets a
23671
- * warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
23672
- * saturated hub). Coverage is proof of footage: recorded fragments are
23673
- * immutable, so a hit needs no re-validation. LRU-touches the hit.
23674
- */
23675
- covering(profile, epochMs) {
23676
- for (const [k, frag] of this.map) {
23677
- if (!k.startsWith(`${profile}|`)) continue;
23678
- if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
23679
- this.map.delete(k);
23680
- this.map.set(k, frag);
23681
- return frag;
23682
- }
23683
- }
23684
- return null;
23685
- }
23686
- /** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
23687
- put(profile, frag) {
23688
- const k = this.key(profile, frag.startMs);
23689
- const held = this.map.get(k);
23690
- let bytes = this.bytesByProfile.get(profile) ?? 0;
23691
- if (held) bytes -= fragmentBytes(held);
23692
- this.map.delete(k);
23693
- this.map.set(k, frag);
23694
- bytes += fragmentBytes(frag);
23695
- this.bytesByProfile.set(profile, bytes);
23696
- const budget = this.budgetFor(profile);
23697
- const prefix = `${profile}|`;
23698
- while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
23699
- const oldest = this.oldestKeyWithPrefix(prefix);
23700
- if (oldest === void 0) break;
23701
- const evicted = this.map.get(oldest);
23702
- this.map.delete(oldest);
23703
- if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
23704
- }
23705
- }
23706
- oldestKeyWithPrefix(prefix) {
23707
- for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
23708
- }
23709
- entriesFor(profile) {
23710
- const prefix = `${profile}|`;
23711
- let n = 0;
23712
- for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
23713
- return n;
23714
- }
23715
- /** Demuxed bytes currently held across every profile. */
23716
- get byteSize() {
23717
- let total = 0;
23718
- for (const bytes of this.bytesByProfile.values()) total += bytes;
23719
- return total;
23720
- }
23721
- /** Demuxed bytes held for one profile — the number its MB bound governs. */
23722
- byteSizeFor(profile) {
23723
- return this.bytesByProfile.get(profile) ?? 0;
23724
- }
23725
- get size() {
23726
- return this.map.size;
23727
- }
23728
- };
23729
23635
  /**
23730
23636
  * A bounded numeric series. Exact count/sum/min/max forever; the median comes
23731
23637
  * from the first {@link SCRUB_PHASE_SAMPLE_CAP} samples.
@@ -23791,7 +23697,13 @@ var ScrubDragStats = class {
23791
23697
  prefetchBytes = 0;
23792
23698
  prefetchHits = 0;
23793
23699
  prefetchSuppressed = 0;
23700
+ warmReads = 0;
23701
+ warmBytes = 0;
23702
+ warmHits = 0;
23794
23703
  ackTimeouts = 0;
23704
+ deadlineMisses = 0;
23705
+ retargets = 0;
23706
+ resolveJoins = 0;
23795
23707
  failures = 0;
23796
23708
  firstPushMs = null;
23797
23709
  creditEndedAt = null;
@@ -23801,6 +23713,9 @@ var ScrubDragStats = class {
23801
23713
  demuxMs = new PhaseSamples();
23802
23714
  tickMs = new PhaseSamples();
23803
23715
  ackMs = new PhaseSamples();
23716
+ leadMs = new PhaseSamples();
23717
+ oversizeSegments = 0;
23718
+ degradedProfile = null;
23804
23719
  constructor(startedAtMs) {
23805
23720
  this.startedAtMs = startedAtMs;
23806
23721
  }
@@ -23851,10 +23766,43 @@ var ScrubDragStats = class {
23851
23766
  recordPrefetchHit() {
23852
23767
  this.prefetchHits += 1;
23853
23768
  }
23769
+ /** One window-warm read taken while the drag was live, and what it cost. */
23770
+ recordWarmRead(bytes) {
23771
+ this.warmReads += 1;
23772
+ this.warmBytes += bytes;
23773
+ }
23774
+ /** A tick served by a fragment the window warm had already landed. */
23775
+ recordWarmHit() {
23776
+ this.warmHits += 1;
23777
+ }
23854
23778
  /** Pushes written off after the ack timeout — dropped work, never silent. */
23855
23779
  recordAckTimeout(pushes) {
23856
23780
  this.ackTimeouts += pushes;
23857
23781
  }
23782
+ /** A frame abandoned on its deadline — dropped work, counted every time. */
23783
+ recordDeadlineMiss() {
23784
+ this.deadlineMisses += 1;
23785
+ }
23786
+ /** A landing re-aimed at the newer finger position it also covered. */
23787
+ recordRetarget() {
23788
+ this.retargets += 1;
23789
+ }
23790
+ /** A whole-segment read that had to degrade to a GOP range on size. */
23791
+ recordOversizeSegment() {
23792
+ this.oversizeSegments += 1;
23793
+ }
23794
+ /** This drag is not running on the scrub profile — every gate is off. */
23795
+ recordProfileDegraded(profile) {
23796
+ this.degradedProfile = profile;
23797
+ }
23798
+ /** One read aimed ahead of the finger, and by how much. */
23799
+ recordLead(leadMs) {
23800
+ this.leadMs.add(Math.abs(leadMs));
23801
+ }
23802
+ /** A tick served by the read another tick already had in flight. */
23803
+ recordResolveJoin() {
23804
+ this.resolveJoins += 1;
23805
+ }
23858
23806
  /** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
23859
23807
  recordFailure() {
23860
23808
  this.failures += 1;
@@ -23870,6 +23818,7 @@ var ScrubDragStats = class {
23870
23818
  const dmx = this.demuxMs.summary();
23871
23819
  const tick = this.tickMs.summary();
23872
23820
  const ack = this.ackMs.summary();
23821
+ const lead = this.leadMs.summary();
23873
23822
  return {
23874
23823
  ticks: this.ticks,
23875
23824
  dragMs: Math.round(dragMs),
@@ -23908,7 +23857,17 @@ var ScrubDragStats = class {
23908
23857
  prefetchBytes: this.prefetchBytes,
23909
23858
  prefetchHits: this.prefetchHits,
23910
23859
  prefetchSuppressed: this.prefetchSuppressed,
23860
+ warmReads: this.warmReads,
23861
+ warmBytes: this.warmBytes,
23862
+ warmHits: this.warmHits,
23911
23863
  ackTimeouts: this.ackTimeouts,
23864
+ deadlineMisses: this.deadlineMisses,
23865
+ retargets: this.retargets,
23866
+ resolveJoins: this.resolveJoins,
23867
+ leadMsP50: lead.p50Ms,
23868
+ leadMsMax: lead.maxMs,
23869
+ oversizeSegments: this.oversizeSegments,
23870
+ degradedProfile: this.degradedProfile,
23912
23871
  failures: this.failures,
23913
23872
  creditEndedAt: this.creditEndedAt,
23914
23873
  acks: ack.n,
@@ -23920,8 +23879,158 @@ var ScrubDragStats = class {
23920
23879
  };
23921
23880
  }
23922
23881
  };
23882
+ var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
23883
+ var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
23884
+ /** Demuxed bytes an entry holds — what the byte bound actually counts. */
23885
+ function fragmentBytes(frag) {
23886
+ let total = 0;
23887
+ for (const au of frag.aus) total += au.data.length;
23888
+ return total;
23889
+ }
23890
+ var ScrubFragmentCache = class {
23891
+ maxBytes;
23892
+ otherMaxBytes;
23893
+ map = /* @__PURE__ */ new Map();
23894
+ /**
23895
+ * Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
23896
+ * `high` entry must never be able to evict the `low` segments the drag is
23897
+ * running on, and the reverse would be just as wrong.
23898
+ */
23899
+ bytesByProfile = /* @__PURE__ */ new Map();
23900
+ constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
23901
+ this.maxBytes = maxBytes;
23902
+ this.otherMaxBytes = otherMaxBytes;
23903
+ }
23904
+ /** The byte budget for a profile — the scrub profile gets the real one. */
23905
+ budgetFor(profile) {
23906
+ return profile === "low" ? this.maxBytes : this.otherMaxBytes;
23907
+ }
23908
+ key(profile, startMs) {
23909
+ return `${profile}|${startMs}`;
23910
+ }
23911
+ /** Exact-start lookup with LRU touch (re-insertion marks most recent). */
23912
+ get(profile, startMs) {
23913
+ const k = this.key(profile, startMs);
23914
+ const frag = this.map.get(k);
23915
+ if (!frag) return null;
23916
+ this.map.delete(k);
23917
+ this.map.set(k, frag);
23918
+ return frag;
23919
+ }
23920
+ /**
23921
+ * The cached fragment COVERING an instant, if any — the lookup that lets a
23922
+ * warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
23923
+ * saturated hub). Coverage is proof of footage: recorded fragments are
23924
+ * immutable, so a hit needs no re-validation. LRU-touches the hit.
23925
+ */
23926
+ covering(profile, epochMs) {
23927
+ for (const [k, frag] of this.map) {
23928
+ if (!k.startsWith(`${profile}|`)) continue;
23929
+ if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
23930
+ this.map.delete(k);
23931
+ this.map.set(k, frag);
23932
+ return frag;
23933
+ }
23934
+ }
23935
+ return null;
23936
+ }
23937
+ /** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
23938
+ put(profile, frag) {
23939
+ const k = this.key(profile, frag.startMs);
23940
+ const held = this.map.get(k);
23941
+ let bytes = this.bytesByProfile.get(profile) ?? 0;
23942
+ if (held) bytes -= fragmentBytes(held);
23943
+ this.map.delete(k);
23944
+ this.map.set(k, frag);
23945
+ bytes += fragmentBytes(frag);
23946
+ this.bytesByProfile.set(profile, bytes);
23947
+ const budget = this.budgetFor(profile);
23948
+ const prefix = `${profile}|`;
23949
+ while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
23950
+ const oldest = this.oldestKeyWithPrefix(prefix);
23951
+ if (oldest === void 0) break;
23952
+ const evicted = this.map.get(oldest);
23953
+ this.map.delete(oldest);
23954
+ if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
23955
+ }
23956
+ }
23957
+ oldestKeyWithPrefix(prefix) {
23958
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
23959
+ }
23960
+ entriesFor(profile) {
23961
+ const prefix = `${profile}|`;
23962
+ let n = 0;
23963
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
23964
+ return n;
23965
+ }
23966
+ /** Demuxed bytes currently held across every profile. */
23967
+ get byteSize() {
23968
+ let total = 0;
23969
+ for (const bytes of this.bytesByProfile.values()) total += bytes;
23970
+ return total;
23971
+ }
23972
+ /** Demuxed bytes held for one profile — the number its MB bound governs. */
23973
+ byteSizeFor(profile) {
23974
+ return this.bytesByProfile.get(profile) ?? 0;
23975
+ }
23976
+ get size() {
23977
+ return this.map.size;
23978
+ }
23979
+ };
23980
+ /**
23981
+ * `velocity` is ms of timeline per ms of wall clock, signed. `readMs` is what a
23982
+ * read costs on this disk right now. Either being null means "not enough is
23983
+ * known to aim" — which is NOT saturation: an unknown velocity must not switch
23984
+ * speculation off, or the first ticks of every drag would lose it.
23985
+ */
23986
+ function scrubLeadDecision(velocity, readMs) {
23987
+ if (velocity === null || readMs === null) return {
23988
+ leadMs: 0,
23989
+ saturated: false
23990
+ };
23991
+ const raw = Math.round(velocity * readMs);
23992
+ if (Math.abs(raw) > 3e4) return {
23993
+ leadMs: 0,
23994
+ saturated: true
23995
+ };
23996
+ return {
23997
+ leadMs: raw,
23998
+ saturated: false
23999
+ };
24000
+ }
23923
24001
  //#endregion
23924
24002
  //#region src/stream-broker/recorded/recorded-feeder.ts
24003
+ /**
24004
+ * READ-AHEAD LEAD — the fix for "the picture chases the finger".
24005
+ *
24006
+ * A scrub frame is always the frame a read STARTED for, one read time ago.
24007
+ * On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
24008
+ * a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
24009
+ * the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
24010
+ * behind the finger. Nothing about that is I/O — it is aiming at where the
24011
+ * finger WAS.
24012
+ *
24013
+ * So a steady drag reads where the finger WILL BE: `mediaVelocity ×
24014
+ * medianRecentReadMs`. It costs nothing (the same one read, at a different
24015
+ * offset), and it is bounded by the two things that make it a prediction
24016
+ * rather than a guess:
24017
+ *
24018
+ * - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
24019
+ * samples, so a jittery or reversing drag never leads. Pointer samples and
24020
+ * not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
24021
+ * ~4 pushes, so a gate counted in pushes has no runway inside the gesture
24022
+ * it is supposed to help — measured in the drag bench, it engaged once, at
24023
+ * the very end;
24024
+ * - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
24025
+ * is derived from this disk right now and not from a constant. ONE is
24026
+ * enough: against a read cost that is unknown, a one-sample estimate is not
24027
+ * a good prediction — it is simply a far better one than assuming zero.
24028
+ *
24029
+ * Overshoot is self-correcting: the retarget below serves the newest target
24030
+ * the landed fragment covers, so a lead that is slightly long lands on the
24031
+ * finger exactly, and one that is wrong costs the same single read it would
24032
+ * have cost anyway.
24033
+ */
23925
24034
  var SEGMENT_TIMEOUT_MS = 8e3;
23926
24035
  /**
23927
24036
  * Deadline for the recorded-AUDIO demux on the `reusedWhole` seek path, and
@@ -24022,30 +24131,6 @@ var SEEK_ANIMATION_MIN_DISTANCE_MS = 8e3;
24022
24131
  */
24023
24132
  var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
24024
24133
  /**
24025
- * Consecutive same-direction `scrubTo` deltas required before a direction-aware
24026
- * prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
24027
- * sustained direction, so a jittery/reversing drag never wastes the slot.
24028
- */
24029
- var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
24030
- /**
24031
- * How many speculative GOP reads may be in flight at once, i.e. how many drag
24032
- * steps ahead the feeder reads.
24033
- *
24034
- * The scrub tick IS the read: measured on salone 590 over `low`,
24035
- * `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
24036
- * the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
24037
- * hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
24038
- * tens of seconds apart revisits nothing. Reading N steps ahead is the only
24039
- * thing that converts wall-clock the drag is ALREADY spending into pushes.
24040
- *
24041
- * 2 and not more: these reads land on the same spindles the recorder is writing
24042
- * every camera to, and past a small number concurrent random reads stop
24043
- * overlapping and start thrashing. Raise it only against a measured
24044
- * `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
24045
- * both halves of the evidence.
24046
- */
24047
- var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24048
- /**
24049
24134
  * The profile whose scrub read unit is the WHOLE SEGMENT.
24050
24135
  *
24051
24136
  * Every drag is pinned to `low` by the client, and a `low` segment is ~230 KB
@@ -24063,14 +24148,48 @@ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
24063
24148
  */
24064
24149
  var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
24065
24150
  /**
24066
- * Safety ceiling on the whole-segment read. `low` is ~230 KB per 10 s, so this
24067
- * is ~6× headroom for a camera whose substream is unusually fat — but a
24068
- * segment past it (a mis-pinned `high` at ~5 MB) falls back to the GOP range,
24069
- * because reading 5 MB to push one keyframe is the trade this change exists to
24070
- * avoid, in the other direction.
24151
+ * Safety ceiling on ONE scrub read, in bytes.
24152
+ *
24153
+ * `low` is ~230 KB per 10 s, so this is ~6× headroom for a camera whose
24154
+ * substream is unusually fat but a segment past it (a mis-pinned `high` at
24155
+ * ~5 MB) falls back to the GOP range, because reading 5 MB to push one
24156
+ * keyframe is the trade this change exists to avoid, in the other direction.
24157
+ *
24158
+ * ⚠️ THIS NUMBER IS TIED TO THE SEGMENT DURATION AND THE TIE IS NOT ENFORCED.
24159
+ * It was tuned against a 10-second `low` segment. At the 60-second segments
24160
+ * the 2026-08-25 teardown proposes, a NATIVE `low` is ~1.4 MB (just inside,
24161
+ * and the whole-segment read gets six times better — one seek leaves a minute
24162
+ * of access units behind) while a DERIVED `low` is ~4.8 MB (outside, and every
24163
+ * scrub read silently degrades to a 3-seek GOP range that leaves nothing
24164
+ * behind). Same constant, opposite outcomes, no signal either way — which is
24165
+ * why the degradation is now COUNTED and LOGGED
24166
+ * (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
24167
+ * instead of being inferred from a falling hit rate.
24168
+ *
24169
+ * The real fix, for when 60 s segments land, is a read unit that is a BYTE
24170
+ * BUDGET over the fragment's own keyframe table — "as many consecutive GOPs
24171
+ * as fit in N bytes" — computed from `tfra` offsets alone, no payload read
24172
+ * (`fragmentsWithinBudget`, `recorder/mfra-index.ts`). `readGopBytes` now uses
24173
+ * exactly this budget on the fallback path below: it no longer reads exactly
24174
+ * one GOP, it reads as many CONSECUTIVE fragments as fit this same ceiling, in
24175
+ * one range read (`gop-range-read.ts`'s `maxBytes`). The recorder-side handler
24176
+ * (`recording-provider.ts`) declares its OWN copy of this number
24177
+ * (`SCRUB_GOP_BUDGET_MAX_BYTES` in `gop-range-read.ts`) rather than importing
24178
+ * this module — the client feeder is a different addon-internal concern, and
24179
+ * pulling its whole dependency graph in for one number is the wrong
24180
+ * direction. Exported here so a future caller in THIS process can reuse it;
24181
+ * if D163's number moves, move both.
24071
24182
  */
24072
24183
  var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
24073
24184
  /**
24185
+ * How still the finger must be before the window warm may take the disk back.
24186
+ *
24187
+ * Long enough that it does not thrash on the gap between two ticks of a slow
24188
+ * drag (tickMsP50 was 120-129 ms on the measured drags), short enough that a
24189
+ * pause the operator would call "stopped" is warm again before they move.
24190
+ */
24191
+ var SCRUB_WARM_QUIET_MS = 300;
24192
+ /**
24074
24193
  * How far the entry warm-up reaches, backward and forward from the playhead the
24075
24194
  * drag starts on.
24076
24195
  *
@@ -24105,17 +24224,6 @@ var SCRUB_WARM_MAX_SEGMENTS = Math.max(1, Math.floor(SCRUB_FRAGMENT_CACHE_MAX_BY
24105
24224
  */
24106
24225
  var IDLE_WARM_READ_GAP_MS = 250;
24107
24226
  /**
24108
- * Median recent read time at or above which the feeder STOPS speculating.
24109
- *
24110
- * Speculation is a bet that spare I/O exists. When reads are this slow there is
24111
- * no spare I/O — the array is saturated — and a prefetch is not free work, it
24112
- * is another random seek queued ahead of the read the operator is waiting on.
24113
- * The field regime that forced this: 54 prefetch reads for 9 hits with the disk
24114
- * at 98% busy. Suppression is counted (`prefetchSuppressed`) so the decision is
24115
- * visible, and it lifts by itself as soon as reads come back.
24116
- */
24117
- var SCRUB_SLOW_READ_MS = 600;
24118
- /**
24119
24227
  * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
24120
24228
  * that SWEEPS predicted frames instead of jumping. A steady forward drag is
24121
24229
  * playback with a moving target: once a keyframe decoded, the predicted frames
@@ -24128,6 +24236,84 @@ var SCRUB_SLOW_READ_MS = 600;
24128
24236
  */
24129
24237
  var SCRUB_SWEEP_WINDOW_MS = 4e3;
24130
24238
  /**
24239
+ * THE DEADLINE ON ONE SCRUB FRAME — and the reason it is not
24240
+ * {@link SEGMENT_TIMEOUT_MS}.
24241
+ *
24242
+ * A scrub tick used to be bounded only by the 8 s per-RPC timeout, against a
24243
+ * cold read measured at p50 531 ms, p95 1.4–2 s and a 9.2 s max (census
24244
+ * 2026-08-25). So the feeder WAITED. The frame it eventually pushed was for a
24245
+ * finger position from seconds earlier, and the gesture spent its whole
24246
+ * duration on a handful of stale frames — the operator's "quasi 0 frame, e
24247
+ * l'immagine insegue il dito".
24248
+ *
24249
+ * The reference design (Scrypted NVR, teardown 2026-08-25) spends at most
24250
+ * ~1000 ms on the WHOLE scrub animation and gives every single frame the
24251
+ * REMAINING window as its deadline: a frame that cannot be produced in time is
24252
+ * dropped, never awaited. This is the same contract on our constraints.
24253
+ *
24254
+ * 700 ms and not 1000: the deadline must be shorter than the gesture's own
24255
+ * animation window or it can never fire twice inside one, and it must be above
24256
+ * the cold-read p50 (531 ms) or the median frame would be dropped — a deadline
24257
+ * that drops the median makes the picture WORSE, not fresher. 700 ms sits
24258
+ * between: it serves the p50–p60 of cold reads and cuts the p90 tail that used
24259
+ * to eat the gesture.
24260
+ *
24261
+ * Dropping is never silent: `deadlineMisses` on the drag aggregate plus one
24262
+ * `scrub-feeder:deadline-miss` line per drag.
24263
+ */
24264
+ var SCRUB_TICK_DEADLINE_MS = 700;
24265
+ /**
24266
+ * The FIRST frame of a drag gets a longer deadline, because nothing is painted
24267
+ * yet.
24268
+ *
24269
+ * Every later frame has a predecessor on screen, so dropping it costs
24270
+ * freshness. The first one has none: dropping it costs the whole picture, and
24271
+ * the operator reads a blank recorded view as "playback parte dopo una vita".
24272
+ * The reference design has the same asymmetry for the same reason — its
24273
+ * animation frames are deadline-dropped while the LANDING seek is unbounded.
24274
+ *
24275
+ * 2500 ms covers the cold-read p95 (1.4–2 s) and still cuts the 9.2 s tail.
24276
+ */
24277
+ var SCRUB_FIRST_TICK_DEADLINE_MS = 2500;
24278
+ /**
24279
+ * How far a new target may be from an in-flight read's target and still RIDE
24280
+ * it instead of starting a second one.
24281
+ *
24282
+ * A `low` segment is ~10 s of timeline and the read unit is the whole segment
24283
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}), so a target within one segment of the
24284
+ * one being read is very likely already inside the bytes on their way. Riding
24285
+ * costs nothing and saves a random seek on the spindle that is the bottleneck;
24286
+ * being wrong costs one wasted deadline, which is now cheap by construction.
24287
+ */
24288
+ var SCRUB_RIDE_WINDOW_MS = 1e4;
24289
+ /**
24290
+ * Hard bound on concurrent fragment resolutions for one drag.
24291
+ *
24292
+ * A deadline that drops frames is only safe if dropping does not multiply the
24293
+ * work: without this bound a drag over slow footage would abandon a read every
24294
+ * {@link SCRUB_TICK_DEADLINE_MS} and start another, turning one saturated
24295
+ * spindle into N. Two: the one that is landing, and the one the finger has
24296
+ * moved on to. At the cap, a tick rides the newest in-flight read instead.
24297
+ */
24298
+ var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
24299
+ var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
24300
+ /**
24301
+ * Smoothing for the media-velocity estimate (ms of timeline per ms of wall
24302
+ * clock), folded on every pointer sample. Half-and-half: fast enough to track
24303
+ * a fling that starts mid-gesture, slow enough that one jittery sample cannot
24304
+ * aim a read an hour away.
24305
+ */
24306
+ var SCRUB_VELOCITY_EMA = .5;
24307
+ /** Same-direction POINTER samples required before a read may aim ahead. */
24308
+ var SCRUB_LEAD_MIN_DIR_RUN = 2;
24309
+ /**
24310
+ * Pointer-sample gaps outside this range do not update the velocity estimate:
24311
+ * below it the delta is dominated by timer jitter, above it the finger has
24312
+ * effectively paused and the "velocity" is an artefact of the pause.
24313
+ */
24314
+ var SCRUB_VELOCITY_MIN_GAP_MS = 10;
24315
+ var SCRUB_VELOCITY_MAX_GAP_MS = 500;
24316
+ /**
24131
24317
  * Receiver-credit cap for scrub pushes: at most this many PUSHES may be
24132
24318
  * OUTSTANDING (on the track, not yet acknowledged by the viewer via
24133
24319
  * `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
@@ -24342,25 +24528,6 @@ var RecordedFeeder = class {
24342
24528
  scrubLastPushLogAtMs = 0;
24343
24529
  scrubLastSlowLocateLogAtMs = 0;
24344
24530
  /**
24345
- * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
24346
- * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
24347
- * a reversal or an in-fragment no-move resets it.
24348
- */
24349
- scrubDirRun = 0;
24350
- /**
24351
- * Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
24352
- * these are the SPECULATIVE concurrency slots, and they only fill
24353
- * `scrubCache` — they never push, never touch the pending/timer/latest-wins
24354
- * state, and never block the live coalesced tick.
24355
- *
24356
- * More than one because the read IS the tick (263 of 270 ms measured): with a
24357
- * single slot the drag can never go faster than one cold read, however well
24358
- * the speculation guesses.
24359
- */
24360
- scrubPrefetchInFlight = 0;
24361
- /** Probe instants currently being read, so two horizons never duplicate one. */
24362
- scrubPrefetchProbes = /* @__PURE__ */ new Set();
24363
- /**
24364
24531
  * Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
24365
24532
  * hits one of them is what makes speculation provably worth its I/O
24366
24533
  * (`prefetchHits`) — without this the feeder could double the load on the disk
@@ -24384,10 +24551,44 @@ var RecordedFeeder = class {
24384
24551
  scrubWarmGen = 0;
24385
24552
  /** Tick demand-read in flight: the fill waits so the finger wins the spindle. */
24386
24553
  scrubTickReadInFlight = false;
24554
+ /**
24555
+ * The newest fragment resolution in flight for this drag — what a later tick
24556
+ * rides instead of issuing its own read. Cleared by the resolution itself.
24557
+ */
24558
+ scrubResolve = null;
24559
+ /** Concurrent resolutions, bounded by {@link SCRUB_RESOLVE_MAX_INFLIGHT}. */
24560
+ scrubResolvesInFlight = 0;
24561
+ /** One `deadline-miss` line per drag; the count lives on the aggregate. */
24562
+ scrubDeadlineLogged = false;
24563
+ /** One `oversize-segment` line per drag; the count lives on the aggregate. */
24564
+ scrubOversizeLogged = false;
24565
+ /** One `profile-degraded` line per drag — the `low` pin is lost. */
24566
+ scrubProfileLogged = false;
24567
+ /** Has this drag put a frame on the wire? Gates the first-frame deadline. */
24568
+ scrubPushedThisDrag = false;
24569
+ /** Smoothed media-ms per wall-ms of the drag, signed. Null until measurable. */
24570
+ scrubVelocity = null;
24571
+ /** Previous pointer sample, for the velocity estimate above. */
24572
+ scrubLastSampleMs = null;
24573
+ scrubLastSampleAtMs = 0;
24574
+ /** Signed run of consecutive same-direction POINTER samples. */
24575
+ scrubVelRun = 0;
24387
24576
  /** One fill walker per drag — a fling retargets it, it does not spawn another. */
24388
24577
  scrubFillLoopActive = false;
24389
24578
  scrubWarmBackMs = SCRUB_WARM_BACK_MS;
24390
24579
  scrubWarmFwdMs = SCRUB_WARM_FWD_MS;
24580
+ /**
24581
+ * The client-supplied id of the CURRENT (or most recently active) scrub
24582
+ * gesture, set by `beginScrub(dragId)`. Printed on every `scrub-feeder:*`
24583
+ * line — ticks, warm fill, park/deadline/ack pathologies, and the drag-end
24584
+ * aggregate — so a drag can be isolated in the logs without depending on
24585
+ * wall-clock proximity. `undefined` outside a drag (and for a feeder whose
24586
+ * caller never sends one — additive, optional, see D1 in the plan).
24587
+ */
24588
+ scrubDragId;
24589
+ /** This feeder's own last-announced state — the `from` half of every
24590
+ * {@link FeederStateTransition}. `null` before the first transition. */
24591
+ lastAnnouncedState = null;
24391
24592
  constructor(deps) {
24392
24593
  this.deps = deps;
24393
24594
  this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
@@ -24405,6 +24606,50 @@ var RecordedFeeder = class {
24405
24606
  if (typeof pcmu === "function") return (data, rtpTs) => pcmu.call(sink, data, rtpTs);
24406
24607
  }
24407
24608
  /**
24609
+ * Emit one state transition through `onState` — see
24610
+ * {@link FeederStateTransition} for why `from`/`reason`/`seekToken` exist.
24611
+ * The ONLY place `this.lastAnnouncedState` is written, so every announced
24612
+ * state (wherever it is announced from) is captured as the next
24613
+ * transition's `from`.
24614
+ */
24615
+ emitState(to, reason, seekToken, dragId) {
24616
+ const from = this.lastAnnouncedState;
24617
+ this.lastAnnouncedState = to;
24618
+ this.deps.onState({
24619
+ from,
24620
+ to,
24621
+ reason,
24622
+ seekToken,
24623
+ dragId
24624
+ });
24625
+ }
24626
+ /**
24627
+ * ONE line per `seek()` call, unconditionally — the C3 rendiconto. Before
24628
+ * this, a commit's outcome was told by WHICHEVER of several lines happened
24629
+ * to fire: `recorded:seek-ready` only on success, `recorded:seek-abandoned`
24630
+ * only when a later token/dispose superseded this one, `recorded:seek-no-footage`
24631
+ * / `recorded:snap-too-far` only on a gap — and a raw thrown error (or an
24632
+ * error on an ALREADY-abandoned commit) logged NOTHING at all. That silent
24633
+ * shape is exactly the 2026-08-26 stall: two commits reached
24634
+ * `playback:intent` and never `playback:ready`, with nothing in the feeder's
24635
+ * own logs saying why. Every return path in `seek()` now calls this before
24636
+ * it exits, so a commit that never lands still leaves one line naming what
24637
+ * happened to it.
24638
+ */
24639
+ logSeekOutcome(token, seekStartedAt, epochMs, dragId, outcome, state) {
24640
+ this.deps.onLog?.("recorded:seek-outcome", {
24641
+ dragId,
24642
+ epochMs,
24643
+ outcome,
24644
+ state,
24645
+ disposed: this.disposed,
24646
+ superseded: token !== this.seekToken,
24647
+ tokenAtStart: token,
24648
+ tokenNow: this.seekToken,
24649
+ totalMs: Math.round(performance.now() - seekStartedAt)
24650
+ });
24651
+ }
24652
+ /**
24408
24653
  * Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
24409
24654
  * the FIRST located segment is fed; the forward `advance` loop is started
24410
24655
  * fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
@@ -24417,6 +24662,7 @@ var RecordedFeeder = class {
24417
24662
  let loadMs = 0;
24418
24663
  let firstFrameMs = 0;
24419
24664
  const token = ++this.seekToken;
24665
+ const dragId = opts?.dragId;
24420
24666
  const wasScrubbing = this.scrubbing;
24421
24667
  if (this.scrubbing) {
24422
24668
  this.emitDragEnd();
@@ -24428,7 +24674,6 @@ var RecordedFeeder = class {
24428
24674
  this.clearScrubAckTimeout();
24429
24675
  this.scrubPendingHint = null;
24430
24676
  this.scrubPrevTarget = null;
24431
- this.scrubDirRun = 0;
24432
24677
  if (this.scrubTimer) {
24433
24678
  clearTimeout(this.scrubTimer);
24434
24679
  this.scrubTimer = null;
@@ -24438,7 +24683,7 @@ var RecordedFeeder = class {
24438
24683
  this.inFlightSeekEpochMs = epochMs;
24439
24684
  let snapInfo = null;
24440
24685
  try {
24441
- this.deps.onState("loading");
24686
+ this.emitState("loading", "seek-start", token, dragId);
24442
24687
  if (this.clockHasAdvanced) {
24443
24688
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
24444
24689
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
@@ -24450,21 +24695,29 @@ var RecordedFeeder = class {
24450
24695
  epochMs
24451
24696
  }));
24452
24697
  locateMs += performance.now() - locateStartedAt;
24453
- if (this.disposed || token !== this.seekToken) return;
24698
+ if (this.disposed || token !== this.seekToken) {
24699
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24700
+ return;
24701
+ }
24454
24702
  let landMs = epochMs;
24455
24703
  if (loc.kind === "gap") {
24456
24704
  const decision = resolveSnapTarget(loc, epochMs);
24457
24705
  if (decision.kind !== "snap") {
24458
24706
  if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
24707
+ dragId,
24459
24708
  requestedMs: epochMs,
24460
24709
  nearestMs: decision.snapMs,
24461
24710
  distanceMs: decision.distanceMs
24462
24711
  });
24463
- else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
24712
+ else this.deps.onLog?.("recorded:seek-no-footage", {
24713
+ dragId,
24714
+ requestedMs: epochMs
24715
+ });
24464
24716
  this.currentSegment = null;
24465
24717
  this.lastFedVideoIdx = -1;
24466
24718
  this.stopPositionReports();
24467
- this.deps.onState("gap");
24719
+ this.emitState("gap", decision.kind === "too-far" ? "seek-snap-too-far" : "seek-no-footage", token, dragId);
24720
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24468
24721
  return;
24469
24722
  }
24470
24723
  const snapLocateStartedAt = performance.now();
@@ -24474,19 +24727,25 @@ var RecordedFeeder = class {
24474
24727
  epochMs: decision.snapMs
24475
24728
  }));
24476
24729
  locateMs += performance.now() - snapLocateStartedAt;
24477
- if (this.disposed || token !== this.seekToken) return;
24730
+ if (this.disposed || token !== this.seekToken) {
24731
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24732
+ return;
24733
+ }
24478
24734
  if (snapped.kind !== "segment") {
24479
24735
  this.deps.onLog?.("recorded:seek-no-footage", {
24736
+ dragId,
24480
24737
  requestedMs: epochMs,
24481
24738
  snappedMs: decision.snapMs
24482
24739
  });
24483
24740
  this.currentSegment = null;
24484
24741
  this.lastFedVideoIdx = -1;
24485
24742
  this.stopPositionReports();
24486
- this.deps.onState("gap");
24743
+ this.emitState("gap", "seek-snap-target-no-footage", token, dragId);
24744
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24487
24745
  return;
24488
24746
  }
24489
24747
  this.deps.onLog?.("recorded:seek-snapped", {
24748
+ dragId,
24490
24749
  requestedMs: epochMs,
24491
24750
  snappedMs: decision.snapMs
24492
24751
  });
@@ -24500,9 +24759,12 @@ var RecordedFeeder = class {
24500
24759
  const travelFromMs = opts?.travelFromMs ?? (this.clockHasAdvanced ? this.cursorMs : null);
24501
24760
  if (!wasScrubbing && travelFromMs !== null) {
24502
24761
  const travelStartedAt = performance.now();
24503
- await this.animateSeekTravel(travelFromMs, landMs, token);
24762
+ await this.animateSeekTravel(travelFromMs, landMs, token, dragId);
24504
24763
  travelMs = performance.now() - travelStartedAt;
24505
- if (this.disposed || token !== this.seekToken) return;
24764
+ if (this.disposed || token !== this.seekToken) {
24765
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24766
+ return;
24767
+ }
24506
24768
  }
24507
24769
  this.cursorMs = loc.startMs;
24508
24770
  const loadStartedAt = performance.now();
@@ -24519,7 +24781,10 @@ var RecordedFeeder = class {
24519
24781
  prefixSource = "cache";
24520
24782
  } else {
24521
24783
  const fragmentLoad = await this.loadScrubFragment(loc, landMs);
24522
- if (fragmentLoad === null) return;
24784
+ if (fragmentLoad === null) {
24785
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24786
+ return;
24787
+ }
24523
24788
  fragment = fragmentLoad.seg;
24524
24789
  this.scrubCache.put(this.deps.profile, fragment);
24525
24790
  prefixSource = fragmentLoad.wholeSegmentRead ? "whole" : "gop";
@@ -24528,7 +24793,10 @@ var RecordedFeeder = class {
24528
24793
  bytes: fragmentLoad.rawBytes
24529
24794
  };
24530
24795
  }
24531
- if (this.disposed || token !== this.seekToken) return;
24796
+ if (this.disposed || token !== this.seekToken) {
24797
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24798
+ return;
24799
+ }
24532
24800
  const fragmentLoc = {
24533
24801
  kind: "segment",
24534
24802
  startMs: fragment.startMs,
@@ -24539,7 +24807,7 @@ var RecordedFeeder = class {
24539
24807
  prefixFed = true;
24540
24808
  gopFirst = true;
24541
24809
  firstFrameMs = performance.now() - seekStartedAt;
24542
- this.deps.onState(this.rate === 0 ? "paused" : "playing");
24810
+ this.emitState(this.rate === 0 ? "paused" : "playing", "seek-gop-prefix-landed", token, dragId);
24543
24811
  this.deps.onPosition(this.cursorMs);
24544
24812
  this.startPositionReports();
24545
24813
  stateAnnounced = true;
@@ -24554,6 +24822,7 @@ var RecordedFeeder = class {
24554
24822
  audio = [...await this.withTimeout(this.deps.demuxAudio(reusedWhole.bytes), AUDIO_DEMUX_TIMEOUT_MS)];
24555
24823
  } catch (err) {
24556
24824
  this.deps.onLog?.("recorded:seek-audio-dropped", {
24825
+ dragId,
24557
24826
  waitedMs: Date.now() - audioStartedAt,
24558
24827
  budgetMs: AUDIO_DEMUX_TIMEOUT_MS,
24559
24828
  bytes: reusedWhole.bytes.byteLength,
@@ -24581,7 +24850,21 @@ var RecordedFeeder = class {
24581
24850
  loadAusMs = performance.now() - loadAusStartedAt;
24582
24851
  }
24583
24852
  loadMs = performance.now() - loadStartedAt;
24584
- if (this.disposed || token !== this.seekToken || loaded === null) return;
24853
+ if (this.disposed || token !== this.seekToken || loaded === null) {
24854
+ this.deps.onLog?.("recorded:seek-abandoned", {
24855
+ dragId,
24856
+ requestedMs: epochMs,
24857
+ disposed: this.disposed,
24858
+ superseded: token !== this.seekToken,
24859
+ tokenAtStart: token,
24860
+ tokenNow: this.seekToken,
24861
+ loadedNull: loaded === null,
24862
+ announcedLoading: true,
24863
+ loadMs: Math.round(loadMs)
24864
+ });
24865
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24866
+ return;
24867
+ }
24585
24868
  if (!prefixFed) this.feedPrefix(loc, loaded.video, landMs);
24586
24869
  if (gopFirst) {
24587
24870
  this.currentSegment = {
@@ -24603,11 +24886,12 @@ var RecordedFeeder = class {
24603
24886
  }
24604
24887
  if (this.rate === 0) {
24605
24888
  if (!stateAnnounced) {
24606
- this.deps.onState("paused");
24889
+ this.emitState("paused", "seek-landed-paused", token, dragId);
24607
24890
  this.deps.onPosition(this.cursorMs);
24608
24891
  this.startPositionReports();
24609
24892
  }
24610
24893
  this.logSeekReady({
24894
+ dragId,
24611
24895
  epochMs,
24612
24896
  startedAt: seekStartedAt,
24613
24897
  locateMs,
@@ -24623,14 +24907,16 @@ var RecordedFeeder = class {
24623
24907
  loadAusMs: Math.round(loadAusMs)
24624
24908
  });
24625
24909
  this.startIdleWarm(this.cursorMs);
24910
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "paused");
24626
24911
  return;
24627
24912
  }
24628
24913
  if (!stateAnnounced) {
24629
- this.deps.onState("playing");
24914
+ this.emitState("playing", "seek-landed-playing", token, dragId);
24630
24915
  this.deps.onPosition(this.cursorMs);
24631
24916
  this.startPositionReports();
24632
24917
  }
24633
24918
  this.logSeekReady({
24919
+ dragId,
24634
24920
  epochMs,
24635
24921
  startedAt: seekStartedAt,
24636
24922
  locateMs,
@@ -24660,22 +24946,26 @@ var RecordedFeeder = class {
24660
24946
  resumeAfterPtsMs
24661
24947
  });
24662
24948
  this.startIdleWarm(this.cursorMs);
24949
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "playing");
24663
24950
  } catch (err) {
24664
24951
  if (!this.disposed && token === this.seekToken) {
24665
24952
  this.stopPositionReports();
24666
24953
  if (snapInfo !== null) {
24667
24954
  this.deps.onLog?.("recorded:seek-snap-load-failed", {
24955
+ dragId,
24668
24956
  requestedMs: snapInfo.requestedMs,
24669
24957
  snappedMs: snapInfo.snappedMs,
24670
24958
  error: errMsg(err)
24671
24959
  });
24672
24960
  this.currentSegment = null;
24673
24961
  this.lastFedVideoIdx = -1;
24674
- this.deps.onState("gap");
24962
+ this.emitState("gap", "seek-snap-load-failed", token, dragId);
24963
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
24675
24964
  return;
24676
24965
  }
24677
- this.deps.onState("error");
24678
- }
24966
+ this.emitState("error", "seek-error", token, dragId);
24967
+ this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "error", "error");
24968
+ } else this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
24679
24969
  } finally {
24680
24970
  if (token === this.seekToken) {
24681
24971
  this.inFlightSeekEpochMs = null;
@@ -24685,6 +24975,7 @@ var RecordedFeeder = class {
24685
24975
  }
24686
24976
  logSeekReady(fields) {
24687
24977
  this.deps.onLog?.("recorded:seek-ready", {
24978
+ dragId: fields.dragId,
24688
24979
  epochMs: fields.epochMs,
24689
24980
  state: fields.state,
24690
24981
  totalMs: Math.round(performance.now() - fields.startedAt),
@@ -24719,7 +25010,7 @@ var RecordedFeeder = class {
24719
25010
  if (next === 0) {
24720
25011
  this.advanceToken++;
24721
25012
  this.stopPositionReports();
24722
- this.deps.onState("paused");
25013
+ this.emitState("paused", "rate-zero", this.seekToken, this.scrubDragId);
24723
25014
  return;
24724
25015
  }
24725
25016
  this.pendingStepDirs.length = 0;
@@ -24741,9 +25032,16 @@ var RecordedFeeder = class {
24741
25032
  * (which flips `scrubbing` false and clears the session cache) or `dispose()`;
24742
25033
  * a genuinely NEW drag is therefore always preceded by that exit, so its
24743
25034
  * `beginScrub` runs the full reset below. (D-scrub-ux)
25035
+ *
25036
+ * `dragId` is the client's id for this gesture (optional — the viewer's main
25037
+ * session generates it; a caller that omits it gets identical behaviour,
25038
+ * just no id on the logs). Stored for the whole drag, including its commit:
25039
+ * every `scrub-feeder:*` line printed until the next `beginScrub` carries it
25040
+ * (C1).
24744
25041
  */
24745
- beginScrub() {
25042
+ beginScrub(dragId) {
24746
25043
  if (this.disposed || this.scrubbing) return;
25044
+ this.scrubDragId = dragId;
24747
25045
  this.seekToken++;
24748
25046
  this.advanceToken++;
24749
25047
  this.scrubbing = true;
@@ -24752,17 +25050,25 @@ var RecordedFeeder = class {
24752
25050
  this.scrubSegment = null;
24753
25051
  this.scrubCredit = SCRUB_CREDIT_CAP;
24754
25052
  this.scrubPrevTarget = null;
24755
- this.scrubDirRun = 0;
24756
25053
  this.scrubPrefetched = /* @__PURE__ */ new Set();
24757
25054
  this.scrubCursor = null;
24758
25055
  this.scrubNoFootageLogged = false;
24759
25056
  this.scrubParkLogged = false;
25057
+ this.scrubDeadlineLogged = false;
25058
+ this.scrubOversizeLogged = false;
25059
+ this.scrubProfileLogged = false;
25060
+ this.scrubPushedThisDrag = false;
25061
+ this.scrubResolve = null;
25062
+ this.scrubVelocity = null;
25063
+ this.scrubLastSampleMs = null;
25064
+ this.scrubVelRun = 0;
24760
25065
  this.scrubOutstanding = [];
24761
25066
  this.clearScrubAckTimeout();
24762
25067
  this.scrubAckTimeoutLogged = false;
24763
25068
  this.scrubPendingHint = null;
24764
25069
  this.scrubStartedAtMs = performance.now();
24765
25070
  this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
25071
+ this.reportProfileDegradation();
24766
25072
  this.scrubDemuxMark = this.deps.demuxTally?.counts() ?? null;
24767
25073
  this.scrubLastPushLogAtMs = 0;
24768
25074
  this.scrubLastSlowLocateLogAtMs = 0;
@@ -24826,6 +25132,7 @@ var RecordedFeeder = class {
24826
25132
  this.scrubWarmBackMs = opts?.backMs ?? SCRUB_WARM_BACK_MS;
24827
25133
  this.scrubWarmFwdMs = opts?.fwdMs ?? SCRUB_WARM_FWD_MS;
24828
25134
  this.deps.onLog?.("scrub-feeder:warm-start", {
25135
+ dragId: this.scrubDragId,
24829
25136
  centreMs,
24830
25137
  fillGen: this.scrubWarmGen
24831
25138
  });
@@ -24851,6 +25158,7 @@ var RecordedFeeder = class {
24851
25158
  lastWorked = gen;
24852
25159
  if (gen === this.scrubWarmGen) {
24853
25160
  this.deps.onLog?.("scrub-feeder:warm-done", {
25161
+ dragId: this.scrubDragId,
24854
25162
  fillGen: gen,
24855
25163
  idle: this.idleWarm && !this.scrubbing,
24856
25164
  fetched,
@@ -24887,6 +25195,19 @@ var RecordedFeeder = class {
24887
25195
  if (Math.abs(targetMs - centre) <= SCRUB_RECENTER_MS) return;
24888
25196
  this.recenterScrubWarm(targetMs);
24889
25197
  }
25198
+ /**
25199
+ * Is the operator's finger actually moving right now?
25200
+ *
25201
+ * `scrubbing` alone is not the question — a drag stays open while the finger
25202
+ * rests, and those pauses are exactly when the warm should work. The signal
25203
+ * is the pointer sample clock, which `noteScrubSample` stamps on every
25204
+ * `scrubTo` before any velocity gating, so it is true even for a drag too
25205
+ * slow or too jittery for the velocity estimator.
25206
+ */
25207
+ fingerIsMoving() {
25208
+ if (!this.scrubbing) return false;
25209
+ return performance.now() - this.scrubLastSampleAtMs < SCRUB_WARM_QUIET_MS;
25210
+ }
24890
25211
  async warmScrubWindow(centreMs, dragGen, fillGen, backMs, fwdMs) {
24891
25212
  const spent = await this.warmScrubDirection(centreMs, -1, backMs, 0, dragGen, fillGen);
24892
25213
  return this.warmScrubDirection(centreMs, 1, fwdMs, spent, dragGen, fillGen);
@@ -24926,6 +25247,10 @@ var RecordedFeeder = class {
24926
25247
  durMs: warm.durMs,
24927
25248
  fetched: false
24928
25249
  };
25250
+ while (this.fingerIsMoving()) {
25251
+ if (!this.warmLoopAlive(dragGen) || fillGen !== this.scrubWarmGen) return null;
25252
+ await new Promise((r) => setTimeout(r, SCRUB_WARM_QUIET_MS));
25253
+ }
24929
25254
  if (this.idleWarm && !this.scrubbing) {
24930
25255
  await new Promise((r) => setTimeout(r, this.deps.idleWarmGapMs ?? IDLE_WARM_READ_GAP_MS));
24931
25256
  while (this.inFlightSeekEpochMs !== null) {
@@ -24945,7 +25270,7 @@ var RecordedFeeder = class {
24945
25270
  if (loc.kind !== "segment") return null;
24946
25271
  if (!await this.waitForTickReadIdle(fillGen)) return null;
24947
25272
  const load = await this.loadScrubFragment(loc, probeMs);
24948
- if (load !== null && this.scrubbing) this.scrubStats?.recordPrefetchRead(load.readBytes);
25273
+ if (load !== null && this.scrubbing) this.scrubStats?.recordWarmRead(load.readBytes);
24949
25274
  if (!this.warmLoopAlive(dragGen) || load === null) return null;
24950
25275
  if (fillGen !== this.scrubWarmGen) return null;
24951
25276
  this.scrubCache.put(this.deps.profile, load.seg);
@@ -24977,6 +25302,7 @@ var RecordedFeeder = class {
24977
25302
  */
24978
25303
  scrubTo(epochMs, hint) {
24979
25304
  if (this.disposed || !this.scrubbing) return;
25305
+ this.noteScrubSample(epochMs);
24980
25306
  this.scrubPendingEpochMs = epochMs;
24981
25307
  this.scrubPendingHint = hint ?? null;
24982
25308
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
@@ -25064,6 +25390,7 @@ var RecordedFeeder = class {
25064
25390
  if (!this.scrubAckTimeoutLogged) {
25065
25391
  this.scrubAckTimeoutLogged = true;
25066
25392
  this.deps.onLog?.("scrub-feeder:ack-timeout", {
25393
+ dragId: this.scrubDragId,
25067
25394
  pushes: expired,
25068
25395
  frames,
25069
25396
  timeoutMs: SCRUB_ACK_TIMEOUT_MS,
@@ -25095,6 +25422,7 @@ var RecordedFeeder = class {
25095
25422
  if (!this.scrubParkLogged) {
25096
25423
  this.scrubParkLogged = true;
25097
25424
  this.deps.onLog?.("scrub-feeder:parked", {
25425
+ dragId: this.scrubDragId,
25098
25426
  pendingEpochMs: this.scrubPendingEpochMs,
25099
25427
  outstanding: this.scrubOutstanding.length
25100
25428
  });
@@ -25102,7 +25430,7 @@ var RecordedFeeder = class {
25102
25430
  return;
25103
25431
  }
25104
25432
  const target = this.scrubPendingEpochMs;
25105
- const hint = this.scrubPendingHint;
25433
+ let hint = this.scrubPendingHint;
25106
25434
  this.scrubPendingEpochMs = null;
25107
25435
  this.scrubPendingHint = null;
25108
25436
  if (target === null) {
@@ -25127,74 +25455,62 @@ var RecordedFeeder = class {
25127
25455
  seg = this.scrubCache.covering(this.deps.profile, target);
25128
25456
  if (seg) {
25129
25457
  coverage = "cache";
25130
- if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordPrefetchHit();
25458
+ if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordWarmHit();
25131
25459
  }
25132
25460
  }
25133
25461
  if (!seg) {
25134
- this.scrubTickReadInFlight = true;
25135
- try {
25136
- this.maybeRecenterScrubWarm(target);
25137
- const locateStartMs = performance.now();
25138
- let loc = await this.withTimeout(this.deps.locate({
25139
- deviceId: this.deps.deviceId,
25140
- profile: this.deps.profile,
25141
- epochMs: target
25142
- }));
25143
- locateCalls += 1;
25144
- locateMs = performance.now() - locateStartMs;
25145
- this.maybeLogSlowLocate(target, locateMs);
25146
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25147
- if (loc.kind === "gap") {
25148
- const decision = resolveSnapTarget(loc, target);
25149
- if (decision.kind !== "snap") {
25150
- if (!this.scrubNoFootageLogged) {
25151
- this.scrubNoFootageLogged = true;
25152
- if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25153
- epochMs: target,
25154
- nearestMs: decision.snapMs,
25155
- distanceMs: decision.distanceMs
25156
- });
25157
- else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25158
- }
25159
- return;
25160
- }
25161
- const snapStartMs = performance.now();
25162
- const snapped = await this.withTimeout(this.deps.locate({
25163
- deviceId: this.deps.deviceId,
25164
- profile: this.deps.profile,
25165
- epochMs: decision.snapMs
25166
- }));
25167
- locateCalls += 1;
25168
- locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25169
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25170
- if (snapped.kind !== "segment") {
25171
- if (!this.scrubNoFootageLogged) {
25172
- this.scrubNoFootageLogged = true;
25173
- this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
25174
- }
25175
- return;
25176
- }
25177
- this.deps.onLog?.("scrub-feeder:snapped", {
25178
- requestedMs: target,
25179
- snappedMs: decision.snapMs
25462
+ const cfg = this.deps.scrubDeadline;
25463
+ const rideWindowMs = cfg?.rideWindowMs ?? SCRUB_RIDE_WINDOW_MS;
25464
+ const inFlight = this.scrubResolve;
25465
+ let entry;
25466
+ if (inFlight !== null && (Math.abs(target - inFlight.target) <= rideWindowMs || this.scrubResolvesInFlight >= SCRUB_RESOLVE_MAX_INFLIGHT)) {
25467
+ entry = inFlight;
25468
+ this.scrubStats?.recordResolveJoin();
25469
+ } else {
25470
+ const leadMs = this.scrubLeadMs();
25471
+ if (leadMs !== 0) this.scrubStats?.recordLead(leadMs);
25472
+ const readTarget = target + leadMs;
25473
+ entry = {
25474
+ target: readTarget,
25475
+ promise: this.startScrubResolve(readTarget, gen)
25476
+ };
25477
+ this.scrubResolve = entry;
25478
+ }
25479
+ const deadlineMs = this.scrubPushedThisDrag ? cfg?.tickMs ?? SCRUB_TICK_DEADLINE_MS : cfg?.firstTickMs ?? SCRUB_FIRST_TICK_DEADLINE_MS;
25480
+ const res = await this.raceDeadline(entry.promise, performance.now() + deadlineMs);
25481
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25482
+ if (res === "deadline") {
25483
+ this.scrubStats?.recordDeadlineMiss();
25484
+ if (this.scrubPendingEpochMs === null) this.scrubPendingEpochMs = target;
25485
+ if (!this.scrubDeadlineLogged) {
25486
+ this.scrubDeadlineLogged = true;
25487
+ this.deps.onLog?.("scrub-feeder:deadline-miss", {
25488
+ dragId: this.scrubDragId,
25489
+ epochMs: target,
25490
+ deadlineMs,
25491
+ first: !this.scrubPushedThisDrag,
25492
+ inFlight: this.scrubResolvesInFlight
25180
25493
  });
25181
- loc = snapped;
25182
- effTarget = decision.snapMs;
25183
- }
25184
- seg = this.scrubCache.covering(this.deps.profile, effTarget);
25185
- if (!seg) {
25186
- const load = await this.loadScrubFragment(loc, effTarget);
25187
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
25188
- seg = load.seg;
25189
- readMs = load.readMs;
25190
- readBytes = load.readBytes;
25191
- demuxMs = load.demuxMs;
25192
- wholeSegmentRead = load.wholeSegmentRead;
25193
- this.scrubCache.put(this.deps.profile, seg);
25194
25494
  }
25195
- } finally {
25196
- this.scrubTickReadInFlight = false;
25495
+ return;
25197
25496
  }
25497
+ if (res === null) return;
25498
+ seg = res.seg;
25499
+ const newest = this.scrubPendingEpochMs;
25500
+ if (res.effTarget !== res.requested) effTarget = res.effTarget;
25501
+ else if (newest !== null && fragmentCovers(seg, newest)) {
25502
+ effTarget = newest;
25503
+ hint = this.scrubPendingHint;
25504
+ this.scrubPendingEpochMs = null;
25505
+ this.scrubPendingHint = null;
25506
+ } else effTarget = newest ?? target;
25507
+ if (effTarget !== res.effTarget) this.scrubStats?.recordRetarget();
25508
+ locateCalls = res.locateCalls;
25509
+ locateMs = res.locateMs;
25510
+ readMs = res.readMs;
25511
+ readBytes = res.readBytes;
25512
+ demuxMs = res.demuxMs;
25513
+ wholeSegmentRead = res.wholeSegmentRead;
25198
25514
  }
25199
25515
  this.scrubSegment = seg;
25200
25516
  const prevTarget = this.scrubPrevTarget;
@@ -25261,6 +25577,7 @@ var RecordedFeeder = class {
25261
25577
  pushedAtMs
25262
25578
  }];
25263
25579
  this.scrubCredit -= 1;
25580
+ this.scrubPushedThisDrag = true;
25264
25581
  this.armScrubAckTimeout();
25265
25582
  this.clockHasAdvanced = true;
25266
25583
  let runBytes = 0;
@@ -25292,6 +25609,7 @@ var RecordedFeeder = class {
25292
25609
  if (this.scrubLastPushLogAtMs === 0 || pushedAtMs - this.scrubLastPushLogAtMs >= 1e3) {
25293
25610
  this.scrubLastPushLogAtMs = pushedAtMs;
25294
25611
  this.deps.onLog?.("scrub-feeder:pushed", {
25612
+ dragId: this.scrubDragId,
25295
25613
  epochMs: effTarget,
25296
25614
  mode,
25297
25615
  dir,
@@ -25309,10 +25627,11 @@ var RecordedFeeder = class {
25309
25627
  });
25310
25628
  }
25311
25629
  this.deps.onPosition(this.clampIntoSegment(seg, effTarget), pushSeq);
25312
- this.maybeScrubPrefetch(seg, effTarget, gen);
25630
+ this.scrubPrevTarget = effTarget;
25313
25631
  } catch (err) {
25314
25632
  this.scrubStats?.recordFailure();
25315
25633
  this.deps.onLog?.("scrub-feeder:tick-failed", {
25634
+ dragId: this.scrubDragId,
25316
25635
  epochMs: target,
25317
25636
  error: err instanceof Error ? err.message : String(err)
25318
25637
  });
@@ -25322,6 +25641,115 @@ var RecordedFeeder = class {
25322
25641
  }
25323
25642
  }
25324
25643
  /**
25644
+ * ONE fragment resolution for the drag: locate (with gap snapping), read,
25645
+ * demux, cache. Deliberately NOT bound to the tick that started it.
25646
+ *
25647
+ * The tick races this with the frame's deadline and may walk away; this
25648
+ * promise then finishes on its own and puts the fragment in the session
25649
+ * cache, because the read is already on the wire and abandoning its RESULT
25650
+ * as well would turn a dropped frame into wasted disk. That is what makes
25651
+ * the deadline cheap: the tick after a miss is usually a cache hit.
25652
+ *
25653
+ * Null ⇒ nothing to serve (dead zone, superseded generation, or a load the
25654
+ * seek token invalidated). Throws propagate to the tick's `tick-failed`.
25655
+ */
25656
+ async startScrubResolve(target, gen) {
25657
+ this.scrubResolvesInFlight += 1;
25658
+ this.scrubTickReadInFlight = true;
25659
+ let locateCalls = 0;
25660
+ let locateMs = null;
25661
+ try {
25662
+ this.maybeRecenterScrubWarm(target);
25663
+ const locateStartMs = performance.now();
25664
+ let loc = await this.withTimeout(this.deps.locate({
25665
+ deviceId: this.deps.deviceId,
25666
+ profile: this.deps.profile,
25667
+ epochMs: target
25668
+ }));
25669
+ locateCalls += 1;
25670
+ locateMs = performance.now() - locateStartMs;
25671
+ this.maybeLogSlowLocate(target, locateMs);
25672
+ if (this.disposed || gen !== this.scrubToken) return null;
25673
+ let effTarget = target;
25674
+ if (loc.kind === "gap") {
25675
+ const decision = resolveSnapTarget(loc, target);
25676
+ if (decision.kind !== "snap") {
25677
+ if (!this.scrubNoFootageLogged) {
25678
+ this.scrubNoFootageLogged = true;
25679
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
25680
+ dragId: this.scrubDragId,
25681
+ epochMs: target,
25682
+ nearestMs: decision.snapMs,
25683
+ distanceMs: decision.distanceMs
25684
+ });
25685
+ else this.deps.onLog?.("scrub-feeder:no-footage", {
25686
+ dragId: this.scrubDragId,
25687
+ epochMs: target
25688
+ });
25689
+ }
25690
+ return null;
25691
+ }
25692
+ const snapStartMs = performance.now();
25693
+ const snapped = await this.withTimeout(this.deps.locate({
25694
+ deviceId: this.deps.deviceId,
25695
+ profile: this.deps.profile,
25696
+ epochMs: decision.snapMs
25697
+ }));
25698
+ locateCalls += 1;
25699
+ locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
25700
+ if (this.disposed || gen !== this.scrubToken) return null;
25701
+ if (snapped.kind !== "segment") {
25702
+ if (!this.scrubNoFootageLogged) {
25703
+ this.scrubNoFootageLogged = true;
25704
+ this.deps.onLog?.("scrub-feeder:no-footage", {
25705
+ dragId: this.scrubDragId,
25706
+ epochMs: target
25707
+ });
25708
+ }
25709
+ return null;
25710
+ }
25711
+ this.deps.onLog?.("scrub-feeder:snapped", {
25712
+ dragId: this.scrubDragId,
25713
+ requestedMs: target,
25714
+ snappedMs: decision.snapMs
25715
+ });
25716
+ loc = snapped;
25717
+ effTarget = decision.snapMs;
25718
+ }
25719
+ const warm = this.scrubCache.covering(this.deps.profile, effTarget);
25720
+ if (warm) return {
25721
+ seg: warm,
25722
+ requested: target,
25723
+ effTarget,
25724
+ locateCalls,
25725
+ locateMs,
25726
+ readMs: null,
25727
+ readBytes: null,
25728
+ demuxMs: null,
25729
+ wholeSegmentRead: false
25730
+ };
25731
+ const load = await this.loadScrubFragment(loc, effTarget);
25732
+ if (this.disposed || load === null) return null;
25733
+ this.scrubCache.put(this.deps.profile, load.seg);
25734
+ if (gen !== this.scrubToken) return null;
25735
+ return {
25736
+ seg: load.seg,
25737
+ requested: target,
25738
+ effTarget,
25739
+ locateCalls,
25740
+ locateMs,
25741
+ readMs: load.readMs,
25742
+ readBytes: load.readBytes,
25743
+ demuxMs: load.demuxMs,
25744
+ wholeSegmentRead: load.wholeSegmentRead
25745
+ };
25746
+ } finally {
25747
+ this.scrubResolvesInFlight -= 1;
25748
+ if (this.scrubResolvesInFlight === 0) this.scrubTickReadInFlight = false;
25749
+ if (this.scrubResolve?.target === target) this.scrubResolve = null;
25750
+ }
25751
+ }
25752
+ /**
25325
25753
  * Index of the nearest AU at-or-before the drag target within `seg`
25326
25754
  * (default 0 — the fragment's params-fused IDR, which the demux contract
25327
25755
  * guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
@@ -25414,7 +25842,21 @@ var RecordedFeeder = class {
25414
25842
  */
25415
25843
  readsWholeSegment(loc) {
25416
25844
  if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
25417
- return loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES;
25845
+ if (loc.bytes === void 0 || loc.bytes <= 15e5) return true;
25846
+ this.scrubStats?.recordOversizeSegment();
25847
+ if (!this.scrubOversizeLogged) {
25848
+ this.scrubOversizeLogged = true;
25849
+ this.deps.onLog?.("scrub-feeder:oversize-segment", {
25850
+ dragId: this.scrubDragId,
25851
+ profile: this.deps.profile,
25852
+ startMs: loc.startMs,
25853
+ durMs: loc.durMs,
25854
+ bytes: loc.bytes,
25855
+ maxBytes: SCRUB_WHOLE_SEGMENT_MAX_BYTES,
25856
+ bytesPerSecond: loc.durMs > 0 ? Math.round(loc.bytes / (loc.durMs / 1e3)) : null
25857
+ });
25858
+ }
25859
+ return false;
25418
25860
  }
25419
25861
  /**
25420
25862
  * Fold one read into the recent-read window that governs speculation. The
@@ -25425,15 +25867,74 @@ var RecordedFeeder = class {
25425
25867
  this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
25426
25868
  }
25427
25869
  /**
25428
- * Is the disk the problem right now? Median of the recent reads against
25429
- * {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
25430
- * noise and the decision must not swing on it; and never before a few
25431
- * samples, or a cold first read would suppress the whole drag.
25870
+ * A drag that is NOT on the scrub profile runs with every scrub optimisation
25871
+ * off: the whole-segment read unit, the 48 MB fragment-cache budget and the
25872
+ * entry warm are all gated on the exact string `low`
25873
+ * ({@link SCRUB_WHOLE_SEGMENT_PROFILE}). A momentary hole in `low` therefore
25874
+ * turns a drag into 3-seek GOP reads over ~5 MB `high` segments — measured at
25875
+ * 2 642 ms cold — and the client cannot repair it.
25876
+ *
25877
+ * It used to be indistinguishable from a slow disk. Now it says so, once per
25878
+ * drag, and the census carries the flag beside the numbers it explains.
25879
+ */
25880
+ reportProfileDegradation() {
25881
+ if (this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) return;
25882
+ this.scrubStats?.recordProfileDegraded(this.deps.profile);
25883
+ if (this.scrubProfileLogged) return;
25884
+ this.scrubProfileLogged = true;
25885
+ this.deps.onLog?.("scrub-feeder:profile-degraded", {
25886
+ dragId: this.scrubDragId,
25887
+ profile: this.deps.profile,
25888
+ scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
25889
+ });
25890
+ }
25891
+ /**
25892
+ * Median of the recent reads — what a read costs on this disk RIGHT NOW.
25893
+ * Null until there are enough samples to call it anything: a single cold
25894
+ * first read is not a median, and acting on it would aim a whole gesture.
25432
25895
  */
25433
- scrubReadsAreSlow() {
25434
- if (this.scrubReadMsWindow.length < 3) return false;
25896
+ scrubReadMedianMs() {
25897
+ if (this.scrubReadMsWindow.length < SCRUB_LEAD_MIN_READ_SAMPLES) return null;
25435
25898
  const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
25436
- return (sorted[Math.floor(sorted.length / 2)] ?? 0) >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
25899
+ return sorted[Math.floor(sorted.length / 2)] ?? null;
25900
+ }
25901
+ /**
25902
+ * Fold one pointer sample into the drag's media velocity (ms of timeline per
25903
+ * ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
25904
+ */
25905
+ noteScrubSample(epochMs) {
25906
+ const now = performance.now();
25907
+ const prev = this.scrubLastSampleMs;
25908
+ const prevAt = this.scrubLastSampleAtMs;
25909
+ this.scrubLastSampleMs = epochMs;
25910
+ this.scrubLastSampleAtMs = now;
25911
+ if (prev === null) return;
25912
+ const gapMs = now - prevAt;
25913
+ if (gapMs < SCRUB_VELOCITY_MIN_GAP_MS || gapMs > SCRUB_VELOCITY_MAX_GAP_MS) return;
25914
+ const deltaMs = epochMs - prev;
25915
+ const dir = Math.sign(deltaMs);
25916
+ if (dir === 0) {
25917
+ this.scrubVelRun = 0;
25918
+ return;
25919
+ }
25920
+ this.scrubVelRun = Math.sign(this.scrubVelRun) === dir ? this.scrubVelRun + dir : dir;
25921
+ const v = deltaMs / gapMs;
25922
+ this.scrubVelocity = this.scrubVelocity === null ? v : this.scrubVelocity * (1 - SCRUB_VELOCITY_EMA) + v * SCRUB_VELOCITY_EMA;
25923
+ }
25924
+ /**
25925
+ * How far AHEAD of the finger this read should aim — see the
25926
+ * {@link SCRUB_LEAD_MIN_READ_SAMPLES} docblock for why this exists and what
25927
+ * bounds it. Zero whenever the prediction is not earned: no measured reads,
25928
+ * no sustained direction, or a velocity whose sign disagrees with the run.
25929
+ */
25930
+ scrubLeadMs() {
25931
+ const velocity = this.scrubVelocity;
25932
+ if (velocity === null) return 0;
25933
+ if (Math.abs(this.scrubVelRun) < SCRUB_LEAD_MIN_DIR_RUN) return 0;
25934
+ if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
25935
+ const readMs = this.scrubReadMedianMs();
25936
+ if (readMs === null) return 0;
25937
+ return scrubLeadDecision(velocity, readMs).leadMs;
25437
25938
  }
25438
25939
  /**
25439
25940
  * A single slow `locate` gets its own line, throttled per drag. Reserved for
@@ -25449,6 +25950,7 @@ var RecordedFeeder = class {
25449
25950
  if (this.scrubLastSlowLocateLogAtMs !== 0 && now - this.scrubLastSlowLocateLogAtMs < SCRUB_PATHOLOGY_LOG_INTERVAL_MS) return;
25450
25951
  this.scrubLastSlowLocateLogAtMs = now;
25451
25952
  this.deps.onLog?.("scrub-feeder:slow-locate", {
25953
+ dragId: this.scrubDragId,
25452
25954
  epochMs,
25453
25955
  locateMs: Math.round(locateMs),
25454
25956
  thresholdMs: threshold
@@ -25465,7 +25967,7 @@ var RecordedFeeder = class {
25465
25967
  * interval comes out warm for the next drag. Aborts (without landing — the
25466
25968
  * caller owns that) the moment a newer seek bumps the token.
25467
25969
  */
25468
- async animateSeekTravel(fromMs, toMs, token) {
25970
+ async animateSeekTravel(fromMs, toMs, token, dragId) {
25469
25971
  const cfg = this.deps.seekAnimation;
25470
25972
  const budgetMs = cfg?.budgetMs ?? SEEK_ANIMATION_BUDGET_MS;
25471
25973
  const tickMs = cfg?.tickMs ?? SEEK_ANIMATION_TICK_MS;
@@ -25497,6 +25999,7 @@ var RecordedFeeder = class {
25497
25999
  if (this.disposed || token !== this.seekToken) return;
25498
26000
  }
25499
26001
  if (stills > 0) this.deps.onLog?.("recorded:seek-travel", {
26002
+ dragId,
25500
26003
  fromMs,
25501
26004
  toMs,
25502
26005
  stills,
@@ -25591,79 +26094,41 @@ var RecordedFeeder = class {
25591
26094
  return stills;
25592
26095
  }
25593
26096
  /**
25594
- * Update the drag-direction run from the just-served target and, once the drag
25595
- * shows a sustained direction, kick off a single background prefetch of the
25596
- * NEXT fragment in that direction. Prefetch fills the cache only — it never
25597
- * pushes, so crossing into the neighbour later is an instant cache hit.
25598
- */
25599
- maybeScrubPrefetch(seg, target, gen) {
25600
- const prev = this.scrubPrevTarget;
25601
- this.scrubPrevTarget = target;
25602
- if (prev === null) {
25603
- this.scrubDirRun = 0;
25604
- return;
25605
- }
25606
- const stepMs = target - prev;
25607
- const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
25608
- if (dir === 0) {
25609
- this.scrubDirRun = 0;
25610
- return;
25611
- }
25612
- this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
25613
- if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
25614
- if (this.scrubReadsAreSlow()) {
25615
- this.scrubStats?.recordPrefetchSuppressed();
25616
- return;
25617
- }
25618
- if (this.scrubFillLoopActive) return;
25619
- const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
25620
- const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
25621
- for (let horizon = 1; horizon <= horizons; horizon++) {
25622
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
25623
- const probeMs = target + dir * aheadMs * horizon;
25624
- if (probeMs < 0) continue;
25625
- if (this.scrubPrefetchProbes.has(probeMs)) continue;
25626
- if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
25627
- if (this.scrubWarmCentreMs !== null && Math.abs(probeMs - this.scrubWarmCentreMs) > SCRUB_WARM_AROUND_MS) continue;
25628
- this.scrubPrefetch(probeMs, gen);
25629
- }
25630
- }
25631
- /**
25632
- * Speculative read: locate + read + demux the fragment covering `probeMs` into
25633
- * `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
25634
- * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
25635
- * swallowed (the fragment just demuxes on demand). Never pushes and never
25636
- * mutates latest-wins state.
26097
+ * The directional prefetch is GONE (2026-08-26), not disabled.
25637
26098
  *
25638
- * Every read it issues is COUNTED (`prefetchReads` / `prefetchBytes`), and the
25639
- * fragment it lands is remembered so a later tick hitting it counts as a
25640
- * `prefetchHit`. Speculation that cannot be shown to pay for itself is just
25641
- * load on the disk the drag is already waiting for.
25642
- */
25643
- async scrubPrefetch(probeMs, gen) {
25644
- if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
25645
- this.scrubPrefetchInFlight += 1;
25646
- this.scrubPrefetchProbes.add(probeMs);
25647
- try {
25648
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25649
- if (this.scrubCache.covering(this.deps.profile, probeMs)) return;
25650
- const loc = await this.withTimeout(this.deps.locate({
25651
- deviceId: this.deps.deviceId,
25652
- profile: this.deps.profile,
25653
- epochMs: probeMs
25654
- }));
25655
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
25656
- if (loc.kind !== "segment") return;
25657
- const load = await this.loadScrubFragment(loc, probeMs);
25658
- if (load !== null) this.scrubStats?.recordPrefetchRead(load.readBytes);
25659
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
25660
- this.scrubCache.put(this.deps.profile, load.seg);
25661
- this.scrubPrefetched.add(load.seg.startMs);
25662
- } catch {} finally {
25663
- this.scrubPrefetchInFlight -= 1;
25664
- this.scrubPrefetchProbes.delete(probeMs);
25665
- }
25666
- }
26099
+ * It guessed where the next tick would land from the drag's recent
26100
+ * direction. Measured on the operator's own drags the day it was removed:
26101
+ * **25 speculative reads bought 3 hits** 88% waste, and roughly 60% of all
26102
+ * disk work during a gesture, queued as random seeks ahead of the read the
26103
+ * finger was actually blocked on, on a 5400 rpm SMR disk.
26104
+ *
26105
+ * The regression tests it passed drove a drag at CONSTANT step size, which is
26106
+ * the one regime where direction predicts position. A real backward scrub is
26107
+ * exploratory — 26 of 29 jumps backward, step size set by where the operator
26108
+ * decided to look next — so the premise never held in the field.
26109
+ *
26110
+ * Scrypted has no predictive prefetch either: their read-ahead is a stream
26111
+ * `highWaterMark`, and a short forward move is a fast-forward on an open fd
26112
+ * rather than a seek. They deleted the problem instead of solving it.
26113
+ *
26114
+ * What REMAINS and must not be confused with this: the WINDOW warm
26115
+ * (`runScrubFillLoop`) — a sequential walk of the window around the playhead.
26116
+ *
26117
+ * It was described here, wrongly, as running "when no drag is live, so it
26118
+ * competes with nothing". It does not: its survival condition is
26119
+ * `this.scrubbing || this.idleWarm`, and within hours of this deletion it had
26120
+ * inherited the deleted predictor's role 1.9 MB and 2.4 MB of reads on two
26121
+ * measured drags, 38-45% of the gesture's I/O, zero cache hits. Deleting one
26122
+ * speculator without looking at the other left the contention exactly where
26123
+ * it was. It now waits out a moving finger (`fingerIsMoving`), which is what
26124
+ * the sentence above claimed all along.
26125
+ *
26126
+ * Cost of the removal, bounded and accepted: a careful short-step drag no
26127
+ * longer warms the fragment across the edge, so the first tick past a
26128
+ * boundary may pay one cold read. Its own regression test shows that case is
26129
+ * served by the MEMO (`coverMemo ≥ 5`) with the neighbour warm worth ≤2
26130
+ * reads.
26131
+ */
25667
26132
  /**
25668
26133
  * Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
25669
26134
  * paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
@@ -25847,7 +26312,7 @@ var RecordedFeeder = class {
25847
26312
  this.advanceLoop(gen, initial).catch(() => {
25848
26313
  if (!this.disposed && gen === this.advanceToken) {
25849
26314
  this.stopPositionReports();
25850
- this.deps.onState("error");
26315
+ this.emitState("error", "advance-loop-failed", this.seekToken, this.scrubDragId);
25851
26316
  }
25852
26317
  });
25853
26318
  }
@@ -25864,7 +26329,7 @@ var RecordedFeeder = class {
25864
26329
  if (prepared === null) return;
25865
26330
  if (prepared === "ended") {
25866
26331
  this.stopPositionReports();
25867
- this.deps.onState("ended");
26332
+ this.emitState("ended", "advance-ended", this.seekToken, this.scrubDragId);
25868
26333
  return;
25869
26334
  }
25870
26335
  if (isResumedSegment) {} else {
@@ -26141,7 +26606,10 @@ var RecordedFeeder = class {
26141
26606
  const mark = this.scrubDemuxMark;
26142
26607
  const demux = mark && this.deps.demuxTally ? this.deps.demuxTally.deltaSince(mark) : void 0;
26143
26608
  this.scrubDemuxMark = null;
26144
- this.deps.onLog?.("scrub-feeder:drag-end", stats.toLogFields(performance.now() - this.scrubStartedAtMs, demux));
26609
+ this.deps.onLog?.("scrub-feeder:drag-end", {
26610
+ dragId: this.scrubDragId,
26611
+ ...stats.toLogFields(performance.now() - this.scrubStartedAtMs, demux)
26612
+ });
26145
26613
  }
26146
26614
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
26147
26615
  dispose() {
@@ -26154,10 +26622,17 @@ var RecordedFeeder = class {
26154
26622
  this.scrubToken++;
26155
26623
  this.scrubSegment = null;
26156
26624
  this.scrubPrevTarget = null;
26157
- this.scrubDirRun = 0;
26158
26625
  this.scrubCursor = null;
26159
26626
  this.scrubNoFootageLogged = false;
26160
26627
  this.scrubParkLogged = false;
26628
+ this.scrubDeadlineLogged = false;
26629
+ this.scrubOversizeLogged = false;
26630
+ this.scrubProfileLogged = false;
26631
+ this.scrubPushedThisDrag = false;
26632
+ this.scrubResolve = null;
26633
+ this.scrubVelocity = null;
26634
+ this.scrubLastSampleMs = null;
26635
+ this.scrubVelRun = 0;
26161
26636
  this.scrubOutstanding = [];
26162
26637
  this.clearScrubAckTimeout();
26163
26638
  this.scrubPendingHint = null;
@@ -26238,10 +26713,11 @@ var TimelineSession = class {
26238
26713
  await this.playAt(msg.epoch, this.profile, { travelFromMs: this.lastPositionMs });
26239
26714
  break;
26240
26715
  case "scrubCommit":
26241
- await this.playAt(msg.epoch, this.profile);
26716
+ if (msg.dragId !== void 0) await this.playAt(msg.epoch, this.profile, { dragId: msg.dragId });
26717
+ else await this.playAt(msg.epoch, this.profile);
26242
26718
  break;
26243
26719
  case "scrubStart":
26244
- (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
26720
+ (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub(msg.dragId);
26245
26721
  break;
26246
26722
  case "scrubTo": {
26247
26723
  const hint = msg.m !== void 0 && msg.v !== void 0 ? {
@@ -26495,7 +26971,7 @@ function createOffThreadJsParse(deps) {
26495
26971
  if (!w) return deps.parseInProcess(bytes);
26496
26972
  const id = nextId;
26497
26973
  nextId += 1;
26498
- const copy = bytes.slice();
26974
+ const copy = new Uint8Array(bytes);
26499
26975
  const request = {
26500
26976
  id,
26501
26977
  bytes: copy
@@ -27932,14 +28408,21 @@ var BrokerWebrtcServer = class {
27932
28408
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
27933
28409
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
27934
28410
  },
27935
- onState: (s) => {
27936
- sessionLogger.info("recorded: feeder state", { meta: {
27937
- sessionId,
27938
- deviceId,
27939
- profile,
27940
- state: s
27941
- } });
27942
- timeline._emitState(s);
28411
+ onState: (t) => {
28412
+ sessionLogger.info("recorded: feeder state", {
28413
+ tags: { deviceId },
28414
+ meta: {
28415
+ sessionId,
28416
+ deviceId,
28417
+ profile,
28418
+ from: t.from,
28419
+ to: t.to,
28420
+ reason: t.reason,
28421
+ seekToken: t.seekToken,
28422
+ dragId: t.dragId
28423
+ }
28424
+ });
28425
+ timeline._emitState(t.to);
27943
28426
  },
27944
28427
  onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
27945
28428
  onLog: (msg, data) => sessionLogger.info(msg, {