@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.
- package/dist/{addon-utils-BnprivSs.js → addon-utils-DH78p79i.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +241 -95
- package/dist/detection-pipeline/index.mjs +239 -93
- package/dist/{dist-BZUXb5S5.js → dist-DOw-izoI.js} +243 -75
- package/dist/{dist-HLAOw6Pe.mjs → dist-Wt81pN5Y.mjs} +243 -75
- package/dist/{event-loop-stall-monitor-BdArVUlx.js → event-loop-stall-monitor-Bld7jNCb.js} +1 -1
- package/dist/{event-loop-stall-monitor-BH-cL_hg.mjs → event-loop-stall-monitor-CtQCOZqm.mjs} +1 -1
- package/dist/{lazy-sharp-DTDXrhMG.js → lazy-sharp-BbNI2ZD-.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-CDPRLalN.mjs → process-memory-1_D3EZT9.mjs} +1 -1
- package/dist/{process-memory-Dh1SyzdJ.js → process-memory-D9H5qjDl.js} +1 -1
- package/dist/recorder/index.js +467 -29
- package/dist/recorder/index.mjs +465 -28
- package/dist/{segment-demux-js-D-TLcHW5.mjs → segment-demux-js-BW-Q-j2A.mjs} +1 -1
- package/dist/{segment-demux-js-DRutggd6.js → segment-demux-js-C2jBBqGW.js} +1 -1
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-Dmw5LWmP.mjs → hostInit-DEVCEYgu.mjs} +3 -3
- package/dist/stream-broker/index.js +906 -423
- package/dist/stream-broker/index.mjs +906 -423
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-BxkCoVjw.js → worker-protocol-nAZl_Rsv.js} +1 -1
- package/dist/{worker-protocol-h7Smb_0i.mjs → worker-protocol-rpnr7Ldd.mjs} +1 -1
- package/package.json +1 -1
- package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_backpressure.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_backpressure.cpython-312.pyc +0 -0
- package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312.pyc +0 -0
- package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_device_selection.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_device_selection.cpython-312.pyc +0 -0
- package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_layout.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_memstats.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_memstats.cpython-312.pyc +0 -0
- package/python/__pycache__/test_inference_pool_memstats.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312.pyc +0 -0
- package/python/__pycache__/test_inference_pool_ov_ppp.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_preprocess.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_reconfigure.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_static_batch.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/__pycache__/test_inference_pool_static_batch.cpython-312.pyc +0 -0
- package/python/__pycache__/test_inference_pool_static_batch.cpython-314.pyc +0 -0
- package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/plate_slots.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/plate_slots.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/rfdetr.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/rfdetr.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_plate_slots.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_rfdetr.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_rfdetr.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_softmax.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolonas.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolonas.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolonas.cpython-314.pyc +0 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cs8bmrWP.mjs +0 -26
|
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-DOw-izoI.js");
|
|
6
6
|
const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
|
|
7
7
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
8
8
|
const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
|
|
9
|
-
const require_segment_demux_js = require("../segment-demux-js-
|
|
9
|
+
const require_segment_demux_js = require("../segment-demux-js-C2jBBqGW.js");
|
|
10
10
|
let node_crypto = require("node:crypto");
|
|
11
11
|
node_crypto = require_dist.__toESM(node_crypto, 1);
|
|
12
12
|
let node_child_process = require("node:child_process");
|
|
@@ -390,6 +390,83 @@ var STREAM_BROKER_RETIRED_ROOT_KEYS = [
|
|
|
390
390
|
}
|
|
391
391
|
];
|
|
392
392
|
//#endregion
|
|
393
|
+
//#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
|
|
394
|
+
/** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
|
|
395
|
+
function logBannerArgs(level) {
|
|
396
|
+
return [
|
|
397
|
+
"-hide_banner",
|
|
398
|
+
"-loglevel",
|
|
399
|
+
level
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/shared/ffmpeg-decode-args.ts
|
|
404
|
+
/**
|
|
405
|
+
* Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
|
|
406
|
+
*
|
|
407
|
+
* `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
|
|
408
|
+
* child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
|
|
409
|
+
* h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
|
|
410
|
+
* `qsv` first on Intel, so we re-rank its result through this table before
|
|
411
|
+
* building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
|
|
412
|
+
* trail for NVIDIA. A method not listed here keeps its incoming relative order,
|
|
413
|
+
* placed AFTER every ranked one.
|
|
414
|
+
*/
|
|
415
|
+
var DECODE_HWACCEL_RANK = [
|
|
416
|
+
"videotoolbox",
|
|
417
|
+
"vaapi",
|
|
418
|
+
"qsv",
|
|
419
|
+
"cuda",
|
|
420
|
+
"nvdec",
|
|
421
|
+
"d3d11va",
|
|
422
|
+
"dxva2",
|
|
423
|
+
"amf",
|
|
424
|
+
"vdpau",
|
|
425
|
+
"drm"
|
|
426
|
+
];
|
|
427
|
+
/** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
|
|
428
|
+
function decodeHwAccelRankIndex(method) {
|
|
429
|
+
const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
|
|
430
|
+
return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
|
|
434
|
+
* `'auto'` decode session, best-first. The session walks this chain on an
|
|
435
|
+
* early-exit-with-no-frames and only falls to software once it is exhausted.
|
|
436
|
+
*
|
|
437
|
+
* Strategy:
|
|
438
|
+
* - Seed candidates from the kernel-preferred order UNION the canonical rank
|
|
439
|
+
* UNION the build's supported list, so a method the kernel omitted but the
|
|
440
|
+
* build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
|
|
441
|
+
* still attempted.
|
|
442
|
+
* - When the build's supported-method list is known (non-empty), keep only
|
|
443
|
+
* methods it actually offers; on a probe miss (empty) keep just the kernel
|
|
444
|
+
* preferences (avoid blindly spawning every backend).
|
|
445
|
+
* - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
|
|
446
|
+
* `qsv`. A supported method not in the rank table keeps its position AFTER
|
|
447
|
+
* every ranked one (stable sort). Returns lowercased method names; empty ⇒
|
|
448
|
+
* pure software.
|
|
449
|
+
*/
|
|
450
|
+
function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
451
|
+
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
452
|
+
const hasSupportList = supported.size > 0;
|
|
453
|
+
const pool = hasSupportList ? [
|
|
454
|
+
...preferred,
|
|
455
|
+
...DECODE_HWACCEL_RANK,
|
|
456
|
+
...supportedMethods
|
|
457
|
+
] : [...preferred];
|
|
458
|
+
const seen = /* @__PURE__ */ new Set();
|
|
459
|
+
const candidates = [];
|
|
460
|
+
for (const raw of pool) {
|
|
461
|
+
const method = raw.toLowerCase();
|
|
462
|
+
if (seen.has(method)) continue;
|
|
463
|
+
seen.add(method);
|
|
464
|
+
if (hasSupportList && !supported.has(method)) continue;
|
|
465
|
+
candidates.push(method);
|
|
466
|
+
}
|
|
467
|
+
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
393
470
|
//#region src/stream-broker/battery-wake-on-play.ts
|
|
394
471
|
/**
|
|
395
472
|
* Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
|
|
@@ -592,83 +669,6 @@ async function wakeBatteryDeviceForPlay(deviceId, deps) {
|
|
|
592
669
|
throw new BatteryStreamUnavailableError(deviceId, outcome, wake.awoke ? "it acknowledged the wake but no stream had appeared yet — try again in a few seconds" : "it did not answer the wake in time — try again");
|
|
593
670
|
}
|
|
594
671
|
//#endregion
|
|
595
|
-
//#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
|
|
596
|
-
/** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
|
|
597
|
-
function logBannerArgs(level) {
|
|
598
|
-
return [
|
|
599
|
-
"-hide_banner",
|
|
600
|
-
"-loglevel",
|
|
601
|
-
level
|
|
602
|
-
];
|
|
603
|
-
}
|
|
604
|
-
//#endregion
|
|
605
|
-
//#region src/shared/ffmpeg-decode-args.ts
|
|
606
|
-
/**
|
|
607
|
-
* Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
|
|
608
|
-
*
|
|
609
|
-
* `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
|
|
610
|
-
* child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
|
|
611
|
-
* h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
|
|
612
|
-
* `qsv` first on Intel, so we re-rank its result through this table before
|
|
613
|
-
* building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
|
|
614
|
-
* trail for NVIDIA. A method not listed here keeps its incoming relative order,
|
|
615
|
-
* placed AFTER every ranked one.
|
|
616
|
-
*/
|
|
617
|
-
var DECODE_HWACCEL_RANK = [
|
|
618
|
-
"videotoolbox",
|
|
619
|
-
"vaapi",
|
|
620
|
-
"qsv",
|
|
621
|
-
"cuda",
|
|
622
|
-
"nvdec",
|
|
623
|
-
"d3d11va",
|
|
624
|
-
"dxva2",
|
|
625
|
-
"amf",
|
|
626
|
-
"vdpau",
|
|
627
|
-
"drm"
|
|
628
|
-
];
|
|
629
|
-
/** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
|
|
630
|
-
function decodeHwAccelRankIndex(method) {
|
|
631
|
-
const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
|
|
632
|
-
return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
|
|
633
|
-
}
|
|
634
|
-
/**
|
|
635
|
-
* Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
|
|
636
|
-
* `'auto'` decode session, best-first. The session walks this chain on an
|
|
637
|
-
* early-exit-with-no-frames and only falls to software once it is exhausted.
|
|
638
|
-
*
|
|
639
|
-
* Strategy:
|
|
640
|
-
* - Seed candidates from the kernel-preferred order UNION the canonical rank
|
|
641
|
-
* UNION the build's supported list, so a method the kernel omitted but the
|
|
642
|
-
* build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
|
|
643
|
-
* still attempted.
|
|
644
|
-
* - When the build's supported-method list is known (non-empty), keep only
|
|
645
|
-
* methods it actually offers; on a probe miss (empty) keep just the kernel
|
|
646
|
-
* preferences (avoid blindly spawning every backend).
|
|
647
|
-
* - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
|
|
648
|
-
* `qsv`. A supported method not in the rank table keeps its position AFTER
|
|
649
|
-
* every ranked one (stable sort). Returns lowercased method names; empty ⇒
|
|
650
|
-
* pure software.
|
|
651
|
-
*/
|
|
652
|
-
function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
653
|
-
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
654
|
-
const hasSupportList = supported.size > 0;
|
|
655
|
-
const pool = hasSupportList ? [
|
|
656
|
-
...preferred,
|
|
657
|
-
...DECODE_HWACCEL_RANK,
|
|
658
|
-
...supportedMethods
|
|
659
|
-
] : [...preferred];
|
|
660
|
-
const seen = /* @__PURE__ */ new Set();
|
|
661
|
-
const candidates = [];
|
|
662
|
-
for (const raw of pool) {
|
|
663
|
-
const method = raw.toLowerCase();
|
|
664
|
-
if (seen.has(method)) continue;
|
|
665
|
-
seen.add(method);
|
|
666
|
-
if (hasSupportList && !supported.has(method)) continue;
|
|
667
|
-
candidates.push(method);
|
|
668
|
-
}
|
|
669
|
-
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
670
|
-
}
|
|
671
|
-
//#endregion
|
|
672
672
|
//#region src/stream-broker/derived/cycle-check.ts
|
|
673
673
|
/**
|
|
674
674
|
* Would assigning `derivedCamStreamId` to `targetProfile` create a cycle?
|
|
@@ -16279,7 +16279,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16279
16279
|
}
|
|
16280
16280
|
let devices;
|
|
16281
16281
|
try {
|
|
16282
|
-
devices = await api.deviceManager.listAll.query({});
|
|
16282
|
+
devices = await api.deviceManager.listAll.query({ projection: "slim" });
|
|
16283
16283
|
} catch (err) {
|
|
16284
16284
|
this.logger.warn("profile-map prune skipped — deviceManager.listAll failed", { meta: {
|
|
16285
16285
|
persistedCount,
|
|
@@ -16604,7 +16604,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16604
16604
|
if (!this.api) return;
|
|
16605
16605
|
let devices;
|
|
16606
16606
|
try {
|
|
16607
|
-
devices = await this.api.deviceManager.listAll.query({});
|
|
16607
|
+
devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
|
|
16608
16608
|
} catch (err) {
|
|
16609
16609
|
this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", { meta: { error: require_dist.errMsg(err) } });
|
|
16610
16610
|
return;
|
|
@@ -16744,7 +16744,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16744
16744
|
if (!this.api) return false;
|
|
16745
16745
|
let devices;
|
|
16746
16746
|
try {
|
|
16747
|
-
devices = await this.api.deviceManager.listAll.query({});
|
|
16747
|
+
devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
|
|
16748
16748
|
} catch {
|
|
16749
16749
|
return false;
|
|
16750
16750
|
}
|
|
@@ -23479,9 +23479,13 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
|
23479
23479
|
}),
|
|
23480
23480
|
require_dist.object({
|
|
23481
23481
|
t: require_dist.literal("scrubCommit"),
|
|
23482
|
-
epoch: require_dist.number()
|
|
23482
|
+
epoch: require_dist.number(),
|
|
23483
|
+
dragId: require_dist.string().optional()
|
|
23484
|
+
}),
|
|
23485
|
+
require_dist.object({
|
|
23486
|
+
t: require_dist.literal("scrubStart"),
|
|
23487
|
+
dragId: require_dist.string().optional()
|
|
23483
23488
|
}),
|
|
23484
|
-
require_dist.object({ t: require_dist.literal("scrubStart") }),
|
|
23485
23489
|
require_dist.object({
|
|
23486
23490
|
t: require_dist.literal("scrubTo"),
|
|
23487
23491
|
epoch: require_dist.number(),
|
|
@@ -23633,104 +23637,6 @@ var MonotonicClock = class {
|
|
|
23633
23637
|
this.rate = rate;
|
|
23634
23638
|
}
|
|
23635
23639
|
};
|
|
23636
|
-
var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
|
|
23637
|
-
var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
|
|
23638
|
-
/** Demuxed bytes an entry holds — what the byte bound actually counts. */
|
|
23639
|
-
function fragmentBytes(frag) {
|
|
23640
|
-
let total = 0;
|
|
23641
|
-
for (const au of frag.aus) total += au.data.length;
|
|
23642
|
-
return total;
|
|
23643
|
-
}
|
|
23644
|
-
var ScrubFragmentCache = class {
|
|
23645
|
-
maxBytes;
|
|
23646
|
-
otherMaxBytes;
|
|
23647
|
-
map = /* @__PURE__ */ new Map();
|
|
23648
|
-
/**
|
|
23649
|
-
* Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
|
|
23650
|
-
* `high` entry must never be able to evict the `low` segments the drag is
|
|
23651
|
-
* running on, and the reverse would be just as wrong.
|
|
23652
|
-
*/
|
|
23653
|
-
bytesByProfile = /* @__PURE__ */ new Map();
|
|
23654
|
-
constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
|
|
23655
|
-
this.maxBytes = maxBytes;
|
|
23656
|
-
this.otherMaxBytes = otherMaxBytes;
|
|
23657
|
-
}
|
|
23658
|
-
/** The byte budget for a profile — the scrub profile gets the real one. */
|
|
23659
|
-
budgetFor(profile) {
|
|
23660
|
-
return profile === "low" ? this.maxBytes : this.otherMaxBytes;
|
|
23661
|
-
}
|
|
23662
|
-
key(profile, startMs) {
|
|
23663
|
-
return `${profile}|${startMs}`;
|
|
23664
|
-
}
|
|
23665
|
-
/** Exact-start lookup with LRU touch (re-insertion marks most recent). */
|
|
23666
|
-
get(profile, startMs) {
|
|
23667
|
-
const k = this.key(profile, startMs);
|
|
23668
|
-
const frag = this.map.get(k);
|
|
23669
|
-
if (!frag) return null;
|
|
23670
|
-
this.map.delete(k);
|
|
23671
|
-
this.map.set(k, frag);
|
|
23672
|
-
return frag;
|
|
23673
|
-
}
|
|
23674
|
-
/**
|
|
23675
|
-
* The cached fragment COVERING an instant, if any — the lookup that lets a
|
|
23676
|
-
* warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
|
|
23677
|
-
* saturated hub). Coverage is proof of footage: recorded fragments are
|
|
23678
|
-
* immutable, so a hit needs no re-validation. LRU-touches the hit.
|
|
23679
|
-
*/
|
|
23680
|
-
covering(profile, epochMs) {
|
|
23681
|
-
for (const [k, frag] of this.map) {
|
|
23682
|
-
if (!k.startsWith(`${profile}|`)) continue;
|
|
23683
|
-
if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
|
|
23684
|
-
this.map.delete(k);
|
|
23685
|
-
this.map.set(k, frag);
|
|
23686
|
-
return frag;
|
|
23687
|
-
}
|
|
23688
|
-
}
|
|
23689
|
-
return null;
|
|
23690
|
-
}
|
|
23691
|
-
/** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
|
|
23692
|
-
put(profile, frag) {
|
|
23693
|
-
const k = this.key(profile, frag.startMs);
|
|
23694
|
-
const held = this.map.get(k);
|
|
23695
|
-
let bytes = this.bytesByProfile.get(profile) ?? 0;
|
|
23696
|
-
if (held) bytes -= fragmentBytes(held);
|
|
23697
|
-
this.map.delete(k);
|
|
23698
|
-
this.map.set(k, frag);
|
|
23699
|
-
bytes += fragmentBytes(frag);
|
|
23700
|
-
this.bytesByProfile.set(profile, bytes);
|
|
23701
|
-
const budget = this.budgetFor(profile);
|
|
23702
|
-
const prefix = `${profile}|`;
|
|
23703
|
-
while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
|
|
23704
|
-
const oldest = this.oldestKeyWithPrefix(prefix);
|
|
23705
|
-
if (oldest === void 0) break;
|
|
23706
|
-
const evicted = this.map.get(oldest);
|
|
23707
|
-
this.map.delete(oldest);
|
|
23708
|
-
if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
|
|
23709
|
-
}
|
|
23710
|
-
}
|
|
23711
|
-
oldestKeyWithPrefix(prefix) {
|
|
23712
|
-
for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
|
|
23713
|
-
}
|
|
23714
|
-
entriesFor(profile) {
|
|
23715
|
-
const prefix = `${profile}|`;
|
|
23716
|
-
let n = 0;
|
|
23717
|
-
for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
|
|
23718
|
-
return n;
|
|
23719
|
-
}
|
|
23720
|
-
/** Demuxed bytes currently held across every profile. */
|
|
23721
|
-
get byteSize() {
|
|
23722
|
-
let total = 0;
|
|
23723
|
-
for (const bytes of this.bytesByProfile.values()) total += bytes;
|
|
23724
|
-
return total;
|
|
23725
|
-
}
|
|
23726
|
-
/** Demuxed bytes held for one profile — the number its MB bound governs. */
|
|
23727
|
-
byteSizeFor(profile) {
|
|
23728
|
-
return this.bytesByProfile.get(profile) ?? 0;
|
|
23729
|
-
}
|
|
23730
|
-
get size() {
|
|
23731
|
-
return this.map.size;
|
|
23732
|
-
}
|
|
23733
|
-
};
|
|
23734
23640
|
/**
|
|
23735
23641
|
* A bounded numeric series. Exact count/sum/min/max forever; the median comes
|
|
23736
23642
|
* from the first {@link SCRUB_PHASE_SAMPLE_CAP} samples.
|
|
@@ -23796,7 +23702,13 @@ var ScrubDragStats = class {
|
|
|
23796
23702
|
prefetchBytes = 0;
|
|
23797
23703
|
prefetchHits = 0;
|
|
23798
23704
|
prefetchSuppressed = 0;
|
|
23705
|
+
warmReads = 0;
|
|
23706
|
+
warmBytes = 0;
|
|
23707
|
+
warmHits = 0;
|
|
23799
23708
|
ackTimeouts = 0;
|
|
23709
|
+
deadlineMisses = 0;
|
|
23710
|
+
retargets = 0;
|
|
23711
|
+
resolveJoins = 0;
|
|
23800
23712
|
failures = 0;
|
|
23801
23713
|
firstPushMs = null;
|
|
23802
23714
|
creditEndedAt = null;
|
|
@@ -23806,6 +23718,9 @@ var ScrubDragStats = class {
|
|
|
23806
23718
|
demuxMs = new PhaseSamples();
|
|
23807
23719
|
tickMs = new PhaseSamples();
|
|
23808
23720
|
ackMs = new PhaseSamples();
|
|
23721
|
+
leadMs = new PhaseSamples();
|
|
23722
|
+
oversizeSegments = 0;
|
|
23723
|
+
degradedProfile = null;
|
|
23809
23724
|
constructor(startedAtMs) {
|
|
23810
23725
|
this.startedAtMs = startedAtMs;
|
|
23811
23726
|
}
|
|
@@ -23856,10 +23771,43 @@ var ScrubDragStats = class {
|
|
|
23856
23771
|
recordPrefetchHit() {
|
|
23857
23772
|
this.prefetchHits += 1;
|
|
23858
23773
|
}
|
|
23774
|
+
/** One window-warm read taken while the drag was live, and what it cost. */
|
|
23775
|
+
recordWarmRead(bytes) {
|
|
23776
|
+
this.warmReads += 1;
|
|
23777
|
+
this.warmBytes += bytes;
|
|
23778
|
+
}
|
|
23779
|
+
/** A tick served by a fragment the window warm had already landed. */
|
|
23780
|
+
recordWarmHit() {
|
|
23781
|
+
this.warmHits += 1;
|
|
23782
|
+
}
|
|
23859
23783
|
/** Pushes written off after the ack timeout — dropped work, never silent. */
|
|
23860
23784
|
recordAckTimeout(pushes) {
|
|
23861
23785
|
this.ackTimeouts += pushes;
|
|
23862
23786
|
}
|
|
23787
|
+
/** A frame abandoned on its deadline — dropped work, counted every time. */
|
|
23788
|
+
recordDeadlineMiss() {
|
|
23789
|
+
this.deadlineMisses += 1;
|
|
23790
|
+
}
|
|
23791
|
+
/** A landing re-aimed at the newer finger position it also covered. */
|
|
23792
|
+
recordRetarget() {
|
|
23793
|
+
this.retargets += 1;
|
|
23794
|
+
}
|
|
23795
|
+
/** A whole-segment read that had to degrade to a GOP range on size. */
|
|
23796
|
+
recordOversizeSegment() {
|
|
23797
|
+
this.oversizeSegments += 1;
|
|
23798
|
+
}
|
|
23799
|
+
/** This drag is not running on the scrub profile — every gate is off. */
|
|
23800
|
+
recordProfileDegraded(profile) {
|
|
23801
|
+
this.degradedProfile = profile;
|
|
23802
|
+
}
|
|
23803
|
+
/** One read aimed ahead of the finger, and by how much. */
|
|
23804
|
+
recordLead(leadMs) {
|
|
23805
|
+
this.leadMs.add(Math.abs(leadMs));
|
|
23806
|
+
}
|
|
23807
|
+
/** A tick served by the read another tick already had in flight. */
|
|
23808
|
+
recordResolveJoin() {
|
|
23809
|
+
this.resolveJoins += 1;
|
|
23810
|
+
}
|
|
23863
23811
|
/** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
|
|
23864
23812
|
recordFailure() {
|
|
23865
23813
|
this.failures += 1;
|
|
@@ -23875,6 +23823,7 @@ var ScrubDragStats = class {
|
|
|
23875
23823
|
const dmx = this.demuxMs.summary();
|
|
23876
23824
|
const tick = this.tickMs.summary();
|
|
23877
23825
|
const ack = this.ackMs.summary();
|
|
23826
|
+
const lead = this.leadMs.summary();
|
|
23878
23827
|
return {
|
|
23879
23828
|
ticks: this.ticks,
|
|
23880
23829
|
dragMs: Math.round(dragMs),
|
|
@@ -23913,7 +23862,17 @@ var ScrubDragStats = class {
|
|
|
23913
23862
|
prefetchBytes: this.prefetchBytes,
|
|
23914
23863
|
prefetchHits: this.prefetchHits,
|
|
23915
23864
|
prefetchSuppressed: this.prefetchSuppressed,
|
|
23865
|
+
warmReads: this.warmReads,
|
|
23866
|
+
warmBytes: this.warmBytes,
|
|
23867
|
+
warmHits: this.warmHits,
|
|
23916
23868
|
ackTimeouts: this.ackTimeouts,
|
|
23869
|
+
deadlineMisses: this.deadlineMisses,
|
|
23870
|
+
retargets: this.retargets,
|
|
23871
|
+
resolveJoins: this.resolveJoins,
|
|
23872
|
+
leadMsP50: lead.p50Ms,
|
|
23873
|
+
leadMsMax: lead.maxMs,
|
|
23874
|
+
oversizeSegments: this.oversizeSegments,
|
|
23875
|
+
degradedProfile: this.degradedProfile,
|
|
23917
23876
|
failures: this.failures,
|
|
23918
23877
|
creditEndedAt: this.creditEndedAt,
|
|
23919
23878
|
acks: ack.n,
|
|
@@ -23925,8 +23884,158 @@ var ScrubDragStats = class {
|
|
|
23925
23884
|
};
|
|
23926
23885
|
}
|
|
23927
23886
|
};
|
|
23887
|
+
var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 48 * 1024 * 1024;
|
|
23888
|
+
var SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES = 2 * 1024 * 1024;
|
|
23889
|
+
/** Demuxed bytes an entry holds — what the byte bound actually counts. */
|
|
23890
|
+
function fragmentBytes(frag) {
|
|
23891
|
+
let total = 0;
|
|
23892
|
+
for (const au of frag.aus) total += au.data.length;
|
|
23893
|
+
return total;
|
|
23894
|
+
}
|
|
23895
|
+
var ScrubFragmentCache = class {
|
|
23896
|
+
maxBytes;
|
|
23897
|
+
otherMaxBytes;
|
|
23898
|
+
map = /* @__PURE__ */ new Map();
|
|
23899
|
+
/**
|
|
23900
|
+
* Bytes held PER PROFILE. Budgets are per profile and so is eviction: a
|
|
23901
|
+
* `high` entry must never be able to evict the `low` segments the drag is
|
|
23902
|
+
* running on, and the reverse would be just as wrong.
|
|
23903
|
+
*/
|
|
23904
|
+
bytesByProfile = /* @__PURE__ */ new Map();
|
|
23905
|
+
constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES, otherMaxBytes = SCRUB_FRAGMENT_CACHE_OTHER_MAX_BYTES) {
|
|
23906
|
+
this.maxBytes = maxBytes;
|
|
23907
|
+
this.otherMaxBytes = otherMaxBytes;
|
|
23908
|
+
}
|
|
23909
|
+
/** The byte budget for a profile — the scrub profile gets the real one. */
|
|
23910
|
+
budgetFor(profile) {
|
|
23911
|
+
return profile === "low" ? this.maxBytes : this.otherMaxBytes;
|
|
23912
|
+
}
|
|
23913
|
+
key(profile, startMs) {
|
|
23914
|
+
return `${profile}|${startMs}`;
|
|
23915
|
+
}
|
|
23916
|
+
/** Exact-start lookup with LRU touch (re-insertion marks most recent). */
|
|
23917
|
+
get(profile, startMs) {
|
|
23918
|
+
const k = this.key(profile, startMs);
|
|
23919
|
+
const frag = this.map.get(k);
|
|
23920
|
+
if (!frag) return null;
|
|
23921
|
+
this.map.delete(k);
|
|
23922
|
+
this.map.set(k, frag);
|
|
23923
|
+
return frag;
|
|
23924
|
+
}
|
|
23925
|
+
/**
|
|
23926
|
+
* The cached fragment COVERING an instant, if any — the lookup that lets a
|
|
23927
|
+
* warm drag tick skip `locate` entirely (an RPC measured at 1–8 s on a
|
|
23928
|
+
* saturated hub). Coverage is proof of footage: recorded fragments are
|
|
23929
|
+
* immutable, so a hit needs no re-validation. LRU-touches the hit.
|
|
23930
|
+
*/
|
|
23931
|
+
covering(profile, epochMs) {
|
|
23932
|
+
for (const [k, frag] of this.map) {
|
|
23933
|
+
if (!k.startsWith(`${profile}|`)) continue;
|
|
23934
|
+
if (epochMs >= frag.startMs && epochMs < frag.startMs + frag.durMs) {
|
|
23935
|
+
this.map.delete(k);
|
|
23936
|
+
this.map.set(k, frag);
|
|
23937
|
+
return frag;
|
|
23938
|
+
}
|
|
23939
|
+
}
|
|
23940
|
+
return null;
|
|
23941
|
+
}
|
|
23942
|
+
/** Insert (or refresh) a fragment, evicting that PROFILE's oldest past its bound. */
|
|
23943
|
+
put(profile, frag) {
|
|
23944
|
+
const k = this.key(profile, frag.startMs);
|
|
23945
|
+
const held = this.map.get(k);
|
|
23946
|
+
let bytes = this.bytesByProfile.get(profile) ?? 0;
|
|
23947
|
+
if (held) bytes -= fragmentBytes(held);
|
|
23948
|
+
this.map.delete(k);
|
|
23949
|
+
this.map.set(k, frag);
|
|
23950
|
+
bytes += fragmentBytes(frag);
|
|
23951
|
+
this.bytesByProfile.set(profile, bytes);
|
|
23952
|
+
const budget = this.budgetFor(profile);
|
|
23953
|
+
const prefix = `${profile}|`;
|
|
23954
|
+
while ((this.bytesByProfile.get(profile) ?? 0) > budget && this.entriesFor(profile) > 4) {
|
|
23955
|
+
const oldest = this.oldestKeyWithPrefix(prefix);
|
|
23956
|
+
if (oldest === void 0) break;
|
|
23957
|
+
const evicted = this.map.get(oldest);
|
|
23958
|
+
this.map.delete(oldest);
|
|
23959
|
+
if (evicted) this.bytesByProfile.set(profile, (this.bytesByProfile.get(profile) ?? 0) - fragmentBytes(evicted));
|
|
23960
|
+
}
|
|
23961
|
+
}
|
|
23962
|
+
oldestKeyWithPrefix(prefix) {
|
|
23963
|
+
for (const k of this.map.keys()) if (k.startsWith(prefix)) return k;
|
|
23964
|
+
}
|
|
23965
|
+
entriesFor(profile) {
|
|
23966
|
+
const prefix = `${profile}|`;
|
|
23967
|
+
let n = 0;
|
|
23968
|
+
for (const k of this.map.keys()) if (k.startsWith(prefix)) n += 1;
|
|
23969
|
+
return n;
|
|
23970
|
+
}
|
|
23971
|
+
/** Demuxed bytes currently held across every profile. */
|
|
23972
|
+
get byteSize() {
|
|
23973
|
+
let total = 0;
|
|
23974
|
+
for (const bytes of this.bytesByProfile.values()) total += bytes;
|
|
23975
|
+
return total;
|
|
23976
|
+
}
|
|
23977
|
+
/** Demuxed bytes held for one profile — the number its MB bound governs. */
|
|
23978
|
+
byteSizeFor(profile) {
|
|
23979
|
+
return this.bytesByProfile.get(profile) ?? 0;
|
|
23980
|
+
}
|
|
23981
|
+
get size() {
|
|
23982
|
+
return this.map.size;
|
|
23983
|
+
}
|
|
23984
|
+
};
|
|
23985
|
+
/**
|
|
23986
|
+
* `velocity` is ms of timeline per ms of wall clock, signed. `readMs` is what a
|
|
23987
|
+
* read costs on this disk right now. Either being null means "not enough is
|
|
23988
|
+
* known to aim" — which is NOT saturation: an unknown velocity must not switch
|
|
23989
|
+
* speculation off, or the first ticks of every drag would lose it.
|
|
23990
|
+
*/
|
|
23991
|
+
function scrubLeadDecision(velocity, readMs) {
|
|
23992
|
+
if (velocity === null || readMs === null) return {
|
|
23993
|
+
leadMs: 0,
|
|
23994
|
+
saturated: false
|
|
23995
|
+
};
|
|
23996
|
+
const raw = Math.round(velocity * readMs);
|
|
23997
|
+
if (Math.abs(raw) > 3e4) return {
|
|
23998
|
+
leadMs: 0,
|
|
23999
|
+
saturated: true
|
|
24000
|
+
};
|
|
24001
|
+
return {
|
|
24002
|
+
leadMs: raw,
|
|
24003
|
+
saturated: false
|
|
24004
|
+
};
|
|
24005
|
+
}
|
|
23928
24006
|
//#endregion
|
|
23929
24007
|
//#region src/stream-broker/recorded/recorded-feeder.ts
|
|
24008
|
+
/**
|
|
24009
|
+
* READ-AHEAD LEAD — the fix for "the picture chases the finger".
|
|
24010
|
+
*
|
|
24011
|
+
* A scrub frame is always the frame a read STARTED for, one read time ago.
|
|
24012
|
+
* On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
|
|
24013
|
+
* a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
|
|
24014
|
+
* the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
|
|
24015
|
+
* behind the finger. Nothing about that is I/O — it is aiming at where the
|
|
24016
|
+
* finger WAS.
|
|
24017
|
+
*
|
|
24018
|
+
* So a steady drag reads where the finger WILL BE: `mediaVelocity ×
|
|
24019
|
+
* medianRecentReadMs`. It costs nothing (the same one read, at a different
|
|
24020
|
+
* offset), and it is bounded by the two things that make it a prediction
|
|
24021
|
+
* rather than a guess:
|
|
24022
|
+
*
|
|
24023
|
+
* - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
|
|
24024
|
+
* samples, so a jittery or reversing drag never leads. Pointer samples and
|
|
24025
|
+
* not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
|
|
24026
|
+
* ~4 pushes, so a gate counted in pushes has no runway inside the gesture
|
|
24027
|
+
* it is supposed to help — measured in the drag bench, it engaged once, at
|
|
24028
|
+
* the very end;
|
|
24029
|
+
* - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
|
|
24030
|
+
* is derived from this disk right now and not from a constant. ONE is
|
|
24031
|
+
* enough: against a read cost that is unknown, a one-sample estimate is not
|
|
24032
|
+
* a good prediction — it is simply a far better one than assuming zero.
|
|
24033
|
+
*
|
|
24034
|
+
* Overshoot is self-correcting: the retarget below serves the newest target
|
|
24035
|
+
* the landed fragment covers, so a lead that is slightly long lands on the
|
|
24036
|
+
* finger exactly, and one that is wrong costs the same single read it would
|
|
24037
|
+
* have cost anyway.
|
|
24038
|
+
*/
|
|
23930
24039
|
var SEGMENT_TIMEOUT_MS = 8e3;
|
|
23931
24040
|
/**
|
|
23932
24041
|
* Deadline for the recorded-AUDIO demux on the `reusedWhole` seek path, and
|
|
@@ -24027,30 +24136,6 @@ var SEEK_ANIMATION_MIN_DISTANCE_MS = 8e3;
|
|
|
24027
24136
|
*/
|
|
24028
24137
|
var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
|
|
24029
24138
|
/**
|
|
24030
|
-
* Consecutive same-direction `scrubTo` deltas required before a direction-aware
|
|
24031
|
-
* prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
|
|
24032
|
-
* sustained direction, so a jittery/reversing drag never wastes the slot.
|
|
24033
|
-
*/
|
|
24034
|
-
var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
|
|
24035
|
-
/**
|
|
24036
|
-
* How many speculative GOP reads may be in flight at once, i.e. how many drag
|
|
24037
|
-
* steps ahead the feeder reads.
|
|
24038
|
-
*
|
|
24039
|
-
* The scrub tick IS the read: measured on salone 590 over `low`,
|
|
24040
|
-
* `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
|
|
24041
|
-
* the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
|
|
24042
|
-
* hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
|
|
24043
|
-
* tens of seconds apart revisits nothing. Reading N steps ahead is the only
|
|
24044
|
-
* thing that converts wall-clock the drag is ALREADY spending into pushes.
|
|
24045
|
-
*
|
|
24046
|
-
* 2 and not more: these reads land on the same spindles the recorder is writing
|
|
24047
|
-
* every camera to, and past a small number concurrent random reads stop
|
|
24048
|
-
* overlapping and start thrashing. Raise it only against a measured
|
|
24049
|
-
* `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
|
|
24050
|
-
* both halves of the evidence.
|
|
24051
|
-
*/
|
|
24052
|
-
var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
|
|
24053
|
-
/**
|
|
24054
24139
|
* The profile whose scrub read unit is the WHOLE SEGMENT.
|
|
24055
24140
|
*
|
|
24056
24141
|
* Every drag is pinned to `low` by the client, and a `low` segment is ~230 KB
|
|
@@ -24068,14 +24153,48 @@ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
|
|
|
24068
24153
|
*/
|
|
24069
24154
|
var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
|
|
24070
24155
|
/**
|
|
24071
|
-
* Safety ceiling on
|
|
24072
|
-
*
|
|
24073
|
-
*
|
|
24074
|
-
*
|
|
24075
|
-
*
|
|
24156
|
+
* Safety ceiling on ONE scrub read, in bytes.
|
|
24157
|
+
*
|
|
24158
|
+
* `low` is ~230 KB per 10 s, so this is ~6× headroom for a camera whose
|
|
24159
|
+
* substream is unusually fat — but a segment past it (a mis-pinned `high` at
|
|
24160
|
+
* ~5 MB) falls back to the GOP range, because reading 5 MB to push one
|
|
24161
|
+
* keyframe is the trade this change exists to avoid, in the other direction.
|
|
24162
|
+
*
|
|
24163
|
+
* ⚠️ THIS NUMBER IS TIED TO THE SEGMENT DURATION AND THE TIE IS NOT ENFORCED.
|
|
24164
|
+
* It was tuned against a 10-second `low` segment. At the 60-second segments
|
|
24165
|
+
* the 2026-08-25 teardown proposes, a NATIVE `low` is ~1.4 MB (just inside,
|
|
24166
|
+
* and the whole-segment read gets six times better — one seek leaves a minute
|
|
24167
|
+
* of access units behind) while a DERIVED `low` is ~4.8 MB (outside, and every
|
|
24168
|
+
* scrub read silently degrades to a 3-seek GOP range that leaves nothing
|
|
24169
|
+
* behind). Same constant, opposite outcomes, no signal either way — which is
|
|
24170
|
+
* why the degradation is now COUNTED and LOGGED
|
|
24171
|
+
* (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
|
|
24172
|
+
* instead of being inferred from a falling hit rate.
|
|
24173
|
+
*
|
|
24174
|
+
* The real fix, for when 60 s segments land, is a read unit that is a BYTE
|
|
24175
|
+
* BUDGET over the fragment's own keyframe table — "as many consecutive GOPs
|
|
24176
|
+
* as fit in N bytes" — computed from `tfra` offsets alone, no payload read
|
|
24177
|
+
* (`fragmentsWithinBudget`, `recorder/mfra-index.ts`). `readGopBytes` now uses
|
|
24178
|
+
* exactly this budget on the fallback path below: it no longer reads exactly
|
|
24179
|
+
* one GOP, it reads as many CONSECUTIVE fragments as fit this same ceiling, in
|
|
24180
|
+
* one range read (`gop-range-read.ts`'s `maxBytes`). The recorder-side handler
|
|
24181
|
+
* (`recording-provider.ts`) declares its OWN copy of this number
|
|
24182
|
+
* (`SCRUB_GOP_BUDGET_MAX_BYTES` in `gop-range-read.ts`) rather than importing
|
|
24183
|
+
* this module — the client feeder is a different addon-internal concern, and
|
|
24184
|
+
* pulling its whole dependency graph in for one number is the wrong
|
|
24185
|
+
* direction. Exported here so a future caller in THIS process can reuse it;
|
|
24186
|
+
* if D163's number moves, move both.
|
|
24076
24187
|
*/
|
|
24077
24188
|
var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
|
|
24078
24189
|
/**
|
|
24190
|
+
* How still the finger must be before the window warm may take the disk back.
|
|
24191
|
+
*
|
|
24192
|
+
* Long enough that it does not thrash on the gap between two ticks of a slow
|
|
24193
|
+
* drag (tickMsP50 was 120-129 ms on the measured drags), short enough that a
|
|
24194
|
+
* pause the operator would call "stopped" is warm again before they move.
|
|
24195
|
+
*/
|
|
24196
|
+
var SCRUB_WARM_QUIET_MS = 300;
|
|
24197
|
+
/**
|
|
24079
24198
|
* How far the entry warm-up reaches, backward and forward from the playhead the
|
|
24080
24199
|
* drag starts on.
|
|
24081
24200
|
*
|
|
@@ -24110,17 +24229,6 @@ var SCRUB_WARM_MAX_SEGMENTS = Math.max(1, Math.floor(SCRUB_FRAGMENT_CACHE_MAX_BY
|
|
|
24110
24229
|
*/
|
|
24111
24230
|
var IDLE_WARM_READ_GAP_MS = 250;
|
|
24112
24231
|
/**
|
|
24113
|
-
* Median recent read time at or above which the feeder STOPS speculating.
|
|
24114
|
-
*
|
|
24115
|
-
* Speculation is a bet that spare I/O exists. When reads are this slow there is
|
|
24116
|
-
* no spare I/O — the array is saturated — and a prefetch is not free work, it
|
|
24117
|
-
* is another random seek queued ahead of the read the operator is waiting on.
|
|
24118
|
-
* The field regime that forced this: 54 prefetch reads for 9 hits with the disk
|
|
24119
|
-
* at 98% busy. Suppression is counted (`prefetchSuppressed`) so the decision is
|
|
24120
|
-
* visible, and it lifts by itself as soon as reads come back.
|
|
24121
|
-
*/
|
|
24122
|
-
var SCRUB_SLOW_READ_MS = 600;
|
|
24123
|
-
/**
|
|
24124
24232
|
* Max forward TARGET delta (ms of timeline, between consecutive drag targets)
|
|
24125
24233
|
* that SWEEPS predicted frames instead of jumping. A steady forward drag is
|
|
24126
24234
|
* playback with a moving target: once a keyframe decoded, the predicted frames
|
|
@@ -24133,6 +24241,84 @@ var SCRUB_SLOW_READ_MS = 600;
|
|
|
24133
24241
|
*/
|
|
24134
24242
|
var SCRUB_SWEEP_WINDOW_MS = 4e3;
|
|
24135
24243
|
/**
|
|
24244
|
+
* THE DEADLINE ON ONE SCRUB FRAME — and the reason it is not
|
|
24245
|
+
* {@link SEGMENT_TIMEOUT_MS}.
|
|
24246
|
+
*
|
|
24247
|
+
* A scrub tick used to be bounded only by the 8 s per-RPC timeout, against a
|
|
24248
|
+
* cold read measured at p50 531 ms, p95 1.4–2 s and a 9.2 s max (census
|
|
24249
|
+
* 2026-08-25). So the feeder WAITED. The frame it eventually pushed was for a
|
|
24250
|
+
* finger position from seconds earlier, and the gesture spent its whole
|
|
24251
|
+
* duration on a handful of stale frames — the operator's "quasi 0 frame, e
|
|
24252
|
+
* l'immagine insegue il dito".
|
|
24253
|
+
*
|
|
24254
|
+
* The reference design (Scrypted NVR, teardown 2026-08-25) spends at most
|
|
24255
|
+
* ~1000 ms on the WHOLE scrub animation and gives every single frame the
|
|
24256
|
+
* REMAINING window as its deadline: a frame that cannot be produced in time is
|
|
24257
|
+
* dropped, never awaited. This is the same contract on our constraints.
|
|
24258
|
+
*
|
|
24259
|
+
* 700 ms and not 1000: the deadline must be shorter than the gesture's own
|
|
24260
|
+
* animation window or it can never fire twice inside one, and it must be above
|
|
24261
|
+
* the cold-read p50 (531 ms) or the median frame would be dropped — a deadline
|
|
24262
|
+
* that drops the median makes the picture WORSE, not fresher. 700 ms sits
|
|
24263
|
+
* between: it serves the p50–p60 of cold reads and cuts the p90 tail that used
|
|
24264
|
+
* to eat the gesture.
|
|
24265
|
+
*
|
|
24266
|
+
* Dropping is never silent: `deadlineMisses` on the drag aggregate plus one
|
|
24267
|
+
* `scrub-feeder:deadline-miss` line per drag.
|
|
24268
|
+
*/
|
|
24269
|
+
var SCRUB_TICK_DEADLINE_MS = 700;
|
|
24270
|
+
/**
|
|
24271
|
+
* The FIRST frame of a drag gets a longer deadline, because nothing is painted
|
|
24272
|
+
* yet.
|
|
24273
|
+
*
|
|
24274
|
+
* Every later frame has a predecessor on screen, so dropping it costs
|
|
24275
|
+
* freshness. The first one has none: dropping it costs the whole picture, and
|
|
24276
|
+
* the operator reads a blank recorded view as "playback parte dopo una vita".
|
|
24277
|
+
* The reference design has the same asymmetry for the same reason — its
|
|
24278
|
+
* animation frames are deadline-dropped while the LANDING seek is unbounded.
|
|
24279
|
+
*
|
|
24280
|
+
* 2500 ms covers the cold-read p95 (1.4–2 s) and still cuts the 9.2 s tail.
|
|
24281
|
+
*/
|
|
24282
|
+
var SCRUB_FIRST_TICK_DEADLINE_MS = 2500;
|
|
24283
|
+
/**
|
|
24284
|
+
* How far a new target may be from an in-flight read's target and still RIDE
|
|
24285
|
+
* it instead of starting a second one.
|
|
24286
|
+
*
|
|
24287
|
+
* A `low` segment is ~10 s of timeline and the read unit is the whole segment
|
|
24288
|
+
* ({@link SCRUB_WHOLE_SEGMENT_PROFILE}), so a target within one segment of the
|
|
24289
|
+
* one being read is very likely already inside the bytes on their way. Riding
|
|
24290
|
+
* costs nothing and saves a random seek on the spindle that is the bottleneck;
|
|
24291
|
+
* being wrong costs one wasted deadline, which is now cheap by construction.
|
|
24292
|
+
*/
|
|
24293
|
+
var SCRUB_RIDE_WINDOW_MS = 1e4;
|
|
24294
|
+
/**
|
|
24295
|
+
* Hard bound on concurrent fragment resolutions for one drag.
|
|
24296
|
+
*
|
|
24297
|
+
* A deadline that drops frames is only safe if dropping does not multiply the
|
|
24298
|
+
* work: without this bound a drag over slow footage would abandon a read every
|
|
24299
|
+
* {@link SCRUB_TICK_DEADLINE_MS} and start another, turning one saturated
|
|
24300
|
+
* spindle into N. Two: the one that is landing, and the one the finger has
|
|
24301
|
+
* moved on to. At the cap, a tick rides the newest in-flight read instead.
|
|
24302
|
+
*/
|
|
24303
|
+
var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
|
|
24304
|
+
var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
|
|
24305
|
+
/**
|
|
24306
|
+
* Smoothing for the media-velocity estimate (ms of timeline per ms of wall
|
|
24307
|
+
* clock), folded on every pointer sample. Half-and-half: fast enough to track
|
|
24308
|
+
* a fling that starts mid-gesture, slow enough that one jittery sample cannot
|
|
24309
|
+
* aim a read an hour away.
|
|
24310
|
+
*/
|
|
24311
|
+
var SCRUB_VELOCITY_EMA = .5;
|
|
24312
|
+
/** Same-direction POINTER samples required before a read may aim ahead. */
|
|
24313
|
+
var SCRUB_LEAD_MIN_DIR_RUN = 2;
|
|
24314
|
+
/**
|
|
24315
|
+
* Pointer-sample gaps outside this range do not update the velocity estimate:
|
|
24316
|
+
* below it the delta is dominated by timer jitter, above it the finger has
|
|
24317
|
+
* effectively paused and the "velocity" is an artefact of the pause.
|
|
24318
|
+
*/
|
|
24319
|
+
var SCRUB_VELOCITY_MIN_GAP_MS = 10;
|
|
24320
|
+
var SCRUB_VELOCITY_MAX_GAP_MS = 500;
|
|
24321
|
+
/**
|
|
24136
24322
|
* Receiver-credit cap for scrub pushes: at most this many PUSHES may be
|
|
24137
24323
|
* OUTSTANDING (on the track, not yet acknowledged by the viewer via
|
|
24138
24324
|
* `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
|
|
@@ -24347,25 +24533,6 @@ var RecordedFeeder = class {
|
|
|
24347
24533
|
scrubLastPushLogAtMs = 0;
|
|
24348
24534
|
scrubLastSlowLocateLogAtMs = 0;
|
|
24349
24535
|
/**
|
|
24350
|
-
* Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
|
|
24351
|
-
* − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
|
|
24352
|
-
* a reversal or an in-fragment no-move resets it.
|
|
24353
|
-
*/
|
|
24354
|
-
scrubDirRun = 0;
|
|
24355
|
-
/**
|
|
24356
|
-
* Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
|
|
24357
|
-
* these are the SPECULATIVE concurrency slots, and they only fill
|
|
24358
|
-
* `scrubCache` — they never push, never touch the pending/timer/latest-wins
|
|
24359
|
-
* state, and never block the live coalesced tick.
|
|
24360
|
-
*
|
|
24361
|
-
* More than one because the read IS the tick (263 of 270 ms measured): with a
|
|
24362
|
-
* single slot the drag can never go faster than one cold read, however well
|
|
24363
|
-
* the speculation guesses.
|
|
24364
|
-
*/
|
|
24365
|
-
scrubPrefetchInFlight = 0;
|
|
24366
|
-
/** Probe instants currently being read, so two horizons never duplicate one. */
|
|
24367
|
-
scrubPrefetchProbes = /* @__PURE__ */ new Set();
|
|
24368
|
-
/**
|
|
24369
24536
|
* Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
|
|
24370
24537
|
* hits one of them is what makes speculation provably worth its I/O
|
|
24371
24538
|
* (`prefetchHits`) — without this the feeder could double the load on the disk
|
|
@@ -24389,10 +24556,44 @@ var RecordedFeeder = class {
|
|
|
24389
24556
|
scrubWarmGen = 0;
|
|
24390
24557
|
/** Tick demand-read in flight: the fill waits so the finger wins the spindle. */
|
|
24391
24558
|
scrubTickReadInFlight = false;
|
|
24559
|
+
/**
|
|
24560
|
+
* The newest fragment resolution in flight for this drag — what a later tick
|
|
24561
|
+
* rides instead of issuing its own read. Cleared by the resolution itself.
|
|
24562
|
+
*/
|
|
24563
|
+
scrubResolve = null;
|
|
24564
|
+
/** Concurrent resolutions, bounded by {@link SCRUB_RESOLVE_MAX_INFLIGHT}. */
|
|
24565
|
+
scrubResolvesInFlight = 0;
|
|
24566
|
+
/** One `deadline-miss` line per drag; the count lives on the aggregate. */
|
|
24567
|
+
scrubDeadlineLogged = false;
|
|
24568
|
+
/** One `oversize-segment` line per drag; the count lives on the aggregate. */
|
|
24569
|
+
scrubOversizeLogged = false;
|
|
24570
|
+
/** One `profile-degraded` line per drag — the `low` pin is lost. */
|
|
24571
|
+
scrubProfileLogged = false;
|
|
24572
|
+
/** Has this drag put a frame on the wire? Gates the first-frame deadline. */
|
|
24573
|
+
scrubPushedThisDrag = false;
|
|
24574
|
+
/** Smoothed media-ms per wall-ms of the drag, signed. Null until measurable. */
|
|
24575
|
+
scrubVelocity = null;
|
|
24576
|
+
/** Previous pointer sample, for the velocity estimate above. */
|
|
24577
|
+
scrubLastSampleMs = null;
|
|
24578
|
+
scrubLastSampleAtMs = 0;
|
|
24579
|
+
/** Signed run of consecutive same-direction POINTER samples. */
|
|
24580
|
+
scrubVelRun = 0;
|
|
24392
24581
|
/** One fill walker per drag — a fling retargets it, it does not spawn another. */
|
|
24393
24582
|
scrubFillLoopActive = false;
|
|
24394
24583
|
scrubWarmBackMs = SCRUB_WARM_BACK_MS;
|
|
24395
24584
|
scrubWarmFwdMs = SCRUB_WARM_FWD_MS;
|
|
24585
|
+
/**
|
|
24586
|
+
* The client-supplied id of the CURRENT (or most recently active) scrub
|
|
24587
|
+
* gesture, set by `beginScrub(dragId)`. Printed on every `scrub-feeder:*`
|
|
24588
|
+
* line — ticks, warm fill, park/deadline/ack pathologies, and the drag-end
|
|
24589
|
+
* aggregate — so a drag can be isolated in the logs without depending on
|
|
24590
|
+
* wall-clock proximity. `undefined` outside a drag (and for a feeder whose
|
|
24591
|
+
* caller never sends one — additive, optional, see D1 in the plan).
|
|
24592
|
+
*/
|
|
24593
|
+
scrubDragId;
|
|
24594
|
+
/** This feeder's own last-announced state — the `from` half of every
|
|
24595
|
+
* {@link FeederStateTransition}. `null` before the first transition. */
|
|
24596
|
+
lastAnnouncedState = null;
|
|
24396
24597
|
constructor(deps) {
|
|
24397
24598
|
this.deps = deps;
|
|
24398
24599
|
this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
|
|
@@ -24410,6 +24611,50 @@ var RecordedFeeder = class {
|
|
|
24410
24611
|
if (typeof pcmu === "function") return (data, rtpTs) => pcmu.call(sink, data, rtpTs);
|
|
24411
24612
|
}
|
|
24412
24613
|
/**
|
|
24614
|
+
* Emit one state transition through `onState` — see
|
|
24615
|
+
* {@link FeederStateTransition} for why `from`/`reason`/`seekToken` exist.
|
|
24616
|
+
* The ONLY place `this.lastAnnouncedState` is written, so every announced
|
|
24617
|
+
* state (wherever it is announced from) is captured as the next
|
|
24618
|
+
* transition's `from`.
|
|
24619
|
+
*/
|
|
24620
|
+
emitState(to, reason, seekToken, dragId) {
|
|
24621
|
+
const from = this.lastAnnouncedState;
|
|
24622
|
+
this.lastAnnouncedState = to;
|
|
24623
|
+
this.deps.onState({
|
|
24624
|
+
from,
|
|
24625
|
+
to,
|
|
24626
|
+
reason,
|
|
24627
|
+
seekToken,
|
|
24628
|
+
dragId
|
|
24629
|
+
});
|
|
24630
|
+
}
|
|
24631
|
+
/**
|
|
24632
|
+
* ONE line per `seek()` call, unconditionally — the C3 rendiconto. Before
|
|
24633
|
+
* this, a commit's outcome was told by WHICHEVER of several lines happened
|
|
24634
|
+
* to fire: `recorded:seek-ready` only on success, `recorded:seek-abandoned`
|
|
24635
|
+
* only when a later token/dispose superseded this one, `recorded:seek-no-footage`
|
|
24636
|
+
* / `recorded:snap-too-far` only on a gap — and a raw thrown error (or an
|
|
24637
|
+
* error on an ALREADY-abandoned commit) logged NOTHING at all. That silent
|
|
24638
|
+
* shape is exactly the 2026-08-26 stall: two commits reached
|
|
24639
|
+
* `playback:intent` and never `playback:ready`, with nothing in the feeder's
|
|
24640
|
+
* own logs saying why. Every return path in `seek()` now calls this before
|
|
24641
|
+
* it exits, so a commit that never lands still leaves one line naming what
|
|
24642
|
+
* happened to it.
|
|
24643
|
+
*/
|
|
24644
|
+
logSeekOutcome(token, seekStartedAt, epochMs, dragId, outcome, state) {
|
|
24645
|
+
this.deps.onLog?.("recorded:seek-outcome", {
|
|
24646
|
+
dragId,
|
|
24647
|
+
epochMs,
|
|
24648
|
+
outcome,
|
|
24649
|
+
state,
|
|
24650
|
+
disposed: this.disposed,
|
|
24651
|
+
superseded: token !== this.seekToken,
|
|
24652
|
+
tokenAtStart: token,
|
|
24653
|
+
tokenNow: this.seekToken,
|
|
24654
|
+
totalMs: Math.round(performance.now() - seekStartedAt)
|
|
24655
|
+
});
|
|
24656
|
+
}
|
|
24657
|
+
/**
|
|
24413
24658
|
* Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
|
|
24414
24659
|
* the FIRST located segment is fed; the forward `advance` loop is started
|
|
24415
24660
|
* fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
|
|
@@ -24422,6 +24667,7 @@ var RecordedFeeder = class {
|
|
|
24422
24667
|
let loadMs = 0;
|
|
24423
24668
|
let firstFrameMs = 0;
|
|
24424
24669
|
const token = ++this.seekToken;
|
|
24670
|
+
const dragId = opts?.dragId;
|
|
24425
24671
|
const wasScrubbing = this.scrubbing;
|
|
24426
24672
|
if (this.scrubbing) {
|
|
24427
24673
|
this.emitDragEnd();
|
|
@@ -24433,7 +24679,6 @@ var RecordedFeeder = class {
|
|
|
24433
24679
|
this.clearScrubAckTimeout();
|
|
24434
24680
|
this.scrubPendingHint = null;
|
|
24435
24681
|
this.scrubPrevTarget = null;
|
|
24436
|
-
this.scrubDirRun = 0;
|
|
24437
24682
|
if (this.scrubTimer) {
|
|
24438
24683
|
clearTimeout(this.scrubTimer);
|
|
24439
24684
|
this.scrubTimer = null;
|
|
@@ -24443,7 +24688,7 @@ var RecordedFeeder = class {
|
|
|
24443
24688
|
this.inFlightSeekEpochMs = epochMs;
|
|
24444
24689
|
let snapInfo = null;
|
|
24445
24690
|
try {
|
|
24446
|
-
this.
|
|
24691
|
+
this.emitState("loading", "seek-start", token, dragId);
|
|
24447
24692
|
if (this.clockHasAdvanced) {
|
|
24448
24693
|
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
24449
24694
|
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
@@ -24455,21 +24700,29 @@ var RecordedFeeder = class {
|
|
|
24455
24700
|
epochMs
|
|
24456
24701
|
}));
|
|
24457
24702
|
locateMs += performance.now() - locateStartedAt;
|
|
24458
|
-
if (this.disposed || token !== this.seekToken)
|
|
24703
|
+
if (this.disposed || token !== this.seekToken) {
|
|
24704
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
|
|
24705
|
+
return;
|
|
24706
|
+
}
|
|
24459
24707
|
let landMs = epochMs;
|
|
24460
24708
|
if (loc.kind === "gap") {
|
|
24461
24709
|
const decision = resolveSnapTarget(loc, epochMs);
|
|
24462
24710
|
if (decision.kind !== "snap") {
|
|
24463
24711
|
if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
|
|
24712
|
+
dragId,
|
|
24464
24713
|
requestedMs: epochMs,
|
|
24465
24714
|
nearestMs: decision.snapMs,
|
|
24466
24715
|
distanceMs: decision.distanceMs
|
|
24467
24716
|
});
|
|
24468
|
-
else this.deps.onLog?.("recorded:seek-no-footage", {
|
|
24717
|
+
else this.deps.onLog?.("recorded:seek-no-footage", {
|
|
24718
|
+
dragId,
|
|
24719
|
+
requestedMs: epochMs
|
|
24720
|
+
});
|
|
24469
24721
|
this.currentSegment = null;
|
|
24470
24722
|
this.lastFedVideoIdx = -1;
|
|
24471
24723
|
this.stopPositionReports();
|
|
24472
|
-
this.
|
|
24724
|
+
this.emitState("gap", decision.kind === "too-far" ? "seek-snap-too-far" : "seek-no-footage", token, dragId);
|
|
24725
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
|
|
24473
24726
|
return;
|
|
24474
24727
|
}
|
|
24475
24728
|
const snapLocateStartedAt = performance.now();
|
|
@@ -24479,19 +24732,25 @@ var RecordedFeeder = class {
|
|
|
24479
24732
|
epochMs: decision.snapMs
|
|
24480
24733
|
}));
|
|
24481
24734
|
locateMs += performance.now() - snapLocateStartedAt;
|
|
24482
|
-
if (this.disposed || token !== this.seekToken)
|
|
24735
|
+
if (this.disposed || token !== this.seekToken) {
|
|
24736
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
|
|
24737
|
+
return;
|
|
24738
|
+
}
|
|
24483
24739
|
if (snapped.kind !== "segment") {
|
|
24484
24740
|
this.deps.onLog?.("recorded:seek-no-footage", {
|
|
24741
|
+
dragId,
|
|
24485
24742
|
requestedMs: epochMs,
|
|
24486
24743
|
snappedMs: decision.snapMs
|
|
24487
24744
|
});
|
|
24488
24745
|
this.currentSegment = null;
|
|
24489
24746
|
this.lastFedVideoIdx = -1;
|
|
24490
24747
|
this.stopPositionReports();
|
|
24491
|
-
this.
|
|
24748
|
+
this.emitState("gap", "seek-snap-target-no-footage", token, dragId);
|
|
24749
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
|
|
24492
24750
|
return;
|
|
24493
24751
|
}
|
|
24494
24752
|
this.deps.onLog?.("recorded:seek-snapped", {
|
|
24753
|
+
dragId,
|
|
24495
24754
|
requestedMs: epochMs,
|
|
24496
24755
|
snappedMs: decision.snapMs
|
|
24497
24756
|
});
|
|
@@ -24505,9 +24764,12 @@ var RecordedFeeder = class {
|
|
|
24505
24764
|
const travelFromMs = opts?.travelFromMs ?? (this.clockHasAdvanced ? this.cursorMs : null);
|
|
24506
24765
|
if (!wasScrubbing && travelFromMs !== null) {
|
|
24507
24766
|
const travelStartedAt = performance.now();
|
|
24508
|
-
await this.animateSeekTravel(travelFromMs, landMs, token);
|
|
24767
|
+
await this.animateSeekTravel(travelFromMs, landMs, token, dragId);
|
|
24509
24768
|
travelMs = performance.now() - travelStartedAt;
|
|
24510
|
-
if (this.disposed || token !== this.seekToken)
|
|
24769
|
+
if (this.disposed || token !== this.seekToken) {
|
|
24770
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
|
|
24771
|
+
return;
|
|
24772
|
+
}
|
|
24511
24773
|
}
|
|
24512
24774
|
this.cursorMs = loc.startMs;
|
|
24513
24775
|
const loadStartedAt = performance.now();
|
|
@@ -24524,7 +24786,10 @@ var RecordedFeeder = class {
|
|
|
24524
24786
|
prefixSource = "cache";
|
|
24525
24787
|
} else {
|
|
24526
24788
|
const fragmentLoad = await this.loadScrubFragment(loc, landMs);
|
|
24527
|
-
if (fragmentLoad === null)
|
|
24789
|
+
if (fragmentLoad === null) {
|
|
24790
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
|
|
24791
|
+
return;
|
|
24792
|
+
}
|
|
24528
24793
|
fragment = fragmentLoad.seg;
|
|
24529
24794
|
this.scrubCache.put(this.deps.profile, fragment);
|
|
24530
24795
|
prefixSource = fragmentLoad.wholeSegmentRead ? "whole" : "gop";
|
|
@@ -24533,7 +24798,10 @@ var RecordedFeeder = class {
|
|
|
24533
24798
|
bytes: fragmentLoad.rawBytes
|
|
24534
24799
|
};
|
|
24535
24800
|
}
|
|
24536
|
-
if (this.disposed || token !== this.seekToken)
|
|
24801
|
+
if (this.disposed || token !== this.seekToken) {
|
|
24802
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
|
|
24803
|
+
return;
|
|
24804
|
+
}
|
|
24537
24805
|
const fragmentLoc = {
|
|
24538
24806
|
kind: "segment",
|
|
24539
24807
|
startMs: fragment.startMs,
|
|
@@ -24544,7 +24812,7 @@ var RecordedFeeder = class {
|
|
|
24544
24812
|
prefixFed = true;
|
|
24545
24813
|
gopFirst = true;
|
|
24546
24814
|
firstFrameMs = performance.now() - seekStartedAt;
|
|
24547
|
-
this.
|
|
24815
|
+
this.emitState(this.rate === 0 ? "paused" : "playing", "seek-gop-prefix-landed", token, dragId);
|
|
24548
24816
|
this.deps.onPosition(this.cursorMs);
|
|
24549
24817
|
this.startPositionReports();
|
|
24550
24818
|
stateAnnounced = true;
|
|
@@ -24559,6 +24827,7 @@ var RecordedFeeder = class {
|
|
|
24559
24827
|
audio = [...await this.withTimeout(this.deps.demuxAudio(reusedWhole.bytes), AUDIO_DEMUX_TIMEOUT_MS)];
|
|
24560
24828
|
} catch (err) {
|
|
24561
24829
|
this.deps.onLog?.("recorded:seek-audio-dropped", {
|
|
24830
|
+
dragId,
|
|
24562
24831
|
waitedMs: Date.now() - audioStartedAt,
|
|
24563
24832
|
budgetMs: AUDIO_DEMUX_TIMEOUT_MS,
|
|
24564
24833
|
bytes: reusedWhole.bytes.byteLength,
|
|
@@ -24586,7 +24855,21 @@ var RecordedFeeder = class {
|
|
|
24586
24855
|
loadAusMs = performance.now() - loadAusStartedAt;
|
|
24587
24856
|
}
|
|
24588
24857
|
loadMs = performance.now() - loadStartedAt;
|
|
24589
|
-
if (this.disposed || token !== this.seekToken || loaded === null)
|
|
24858
|
+
if (this.disposed || token !== this.seekToken || loaded === null) {
|
|
24859
|
+
this.deps.onLog?.("recorded:seek-abandoned", {
|
|
24860
|
+
dragId,
|
|
24861
|
+
requestedMs: epochMs,
|
|
24862
|
+
disposed: this.disposed,
|
|
24863
|
+
superseded: token !== this.seekToken,
|
|
24864
|
+
tokenAtStart: token,
|
|
24865
|
+
tokenNow: this.seekToken,
|
|
24866
|
+
loadedNull: loaded === null,
|
|
24867
|
+
announcedLoading: true,
|
|
24868
|
+
loadMs: Math.round(loadMs)
|
|
24869
|
+
});
|
|
24870
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
|
|
24871
|
+
return;
|
|
24872
|
+
}
|
|
24590
24873
|
if (!prefixFed) this.feedPrefix(loc, loaded.video, landMs);
|
|
24591
24874
|
if (gopFirst) {
|
|
24592
24875
|
this.currentSegment = {
|
|
@@ -24608,11 +24891,12 @@ var RecordedFeeder = class {
|
|
|
24608
24891
|
}
|
|
24609
24892
|
if (this.rate === 0) {
|
|
24610
24893
|
if (!stateAnnounced) {
|
|
24611
|
-
this.
|
|
24894
|
+
this.emitState("paused", "seek-landed-paused", token, dragId);
|
|
24612
24895
|
this.deps.onPosition(this.cursorMs);
|
|
24613
24896
|
this.startPositionReports();
|
|
24614
24897
|
}
|
|
24615
24898
|
this.logSeekReady({
|
|
24899
|
+
dragId,
|
|
24616
24900
|
epochMs,
|
|
24617
24901
|
startedAt: seekStartedAt,
|
|
24618
24902
|
locateMs,
|
|
@@ -24628,14 +24912,16 @@ var RecordedFeeder = class {
|
|
|
24628
24912
|
loadAusMs: Math.round(loadAusMs)
|
|
24629
24913
|
});
|
|
24630
24914
|
this.startIdleWarm(this.cursorMs);
|
|
24915
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "paused");
|
|
24631
24916
|
return;
|
|
24632
24917
|
}
|
|
24633
24918
|
if (!stateAnnounced) {
|
|
24634
|
-
this.
|
|
24919
|
+
this.emitState("playing", "seek-landed-playing", token, dragId);
|
|
24635
24920
|
this.deps.onPosition(this.cursorMs);
|
|
24636
24921
|
this.startPositionReports();
|
|
24637
24922
|
}
|
|
24638
24923
|
this.logSeekReady({
|
|
24924
|
+
dragId,
|
|
24639
24925
|
epochMs,
|
|
24640
24926
|
startedAt: seekStartedAt,
|
|
24641
24927
|
locateMs,
|
|
@@ -24665,22 +24951,26 @@ var RecordedFeeder = class {
|
|
|
24665
24951
|
resumeAfterPtsMs
|
|
24666
24952
|
});
|
|
24667
24953
|
this.startIdleWarm(this.cursorMs);
|
|
24954
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "served", "playing");
|
|
24668
24955
|
} catch (err) {
|
|
24669
24956
|
if (!this.disposed && token === this.seekToken) {
|
|
24670
24957
|
this.stopPositionReports();
|
|
24671
24958
|
if (snapInfo !== null) {
|
|
24672
24959
|
this.deps.onLog?.("recorded:seek-snap-load-failed", {
|
|
24960
|
+
dragId,
|
|
24673
24961
|
requestedMs: snapInfo.requestedMs,
|
|
24674
24962
|
snappedMs: snapInfo.snappedMs,
|
|
24675
24963
|
error: require_dist.errMsg(err)
|
|
24676
24964
|
});
|
|
24677
24965
|
this.currentSegment = null;
|
|
24678
24966
|
this.lastFedVideoIdx = -1;
|
|
24679
|
-
this.
|
|
24967
|
+
this.emitState("gap", "seek-snap-load-failed", token, dragId);
|
|
24968
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "gap", "gap");
|
|
24680
24969
|
return;
|
|
24681
24970
|
}
|
|
24682
|
-
this.
|
|
24683
|
-
|
|
24971
|
+
this.emitState("error", "seek-error", token, dragId);
|
|
24972
|
+
this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "error", "error");
|
|
24973
|
+
} else this.logSeekOutcome(token, seekStartedAt, epochMs, dragId, "abandoned", null);
|
|
24684
24974
|
} finally {
|
|
24685
24975
|
if (token === this.seekToken) {
|
|
24686
24976
|
this.inFlightSeekEpochMs = null;
|
|
@@ -24690,6 +24980,7 @@ var RecordedFeeder = class {
|
|
|
24690
24980
|
}
|
|
24691
24981
|
logSeekReady(fields) {
|
|
24692
24982
|
this.deps.onLog?.("recorded:seek-ready", {
|
|
24983
|
+
dragId: fields.dragId,
|
|
24693
24984
|
epochMs: fields.epochMs,
|
|
24694
24985
|
state: fields.state,
|
|
24695
24986
|
totalMs: Math.round(performance.now() - fields.startedAt),
|
|
@@ -24724,7 +25015,7 @@ var RecordedFeeder = class {
|
|
|
24724
25015
|
if (next === 0) {
|
|
24725
25016
|
this.advanceToken++;
|
|
24726
25017
|
this.stopPositionReports();
|
|
24727
|
-
this.
|
|
25018
|
+
this.emitState("paused", "rate-zero", this.seekToken, this.scrubDragId);
|
|
24728
25019
|
return;
|
|
24729
25020
|
}
|
|
24730
25021
|
this.pendingStepDirs.length = 0;
|
|
@@ -24746,9 +25037,16 @@ var RecordedFeeder = class {
|
|
|
24746
25037
|
* (which flips `scrubbing` false and clears the session cache) or `dispose()`;
|
|
24747
25038
|
* a genuinely NEW drag is therefore always preceded by that exit, so its
|
|
24748
25039
|
* `beginScrub` runs the full reset below. (D-scrub-ux)
|
|
25040
|
+
*
|
|
25041
|
+
* `dragId` is the client's id for this gesture (optional — the viewer's main
|
|
25042
|
+
* session generates it; a caller that omits it gets identical behaviour,
|
|
25043
|
+
* just no id on the logs). Stored for the whole drag, including its commit:
|
|
25044
|
+
* every `scrub-feeder:*` line printed until the next `beginScrub` carries it
|
|
25045
|
+
* (C1).
|
|
24749
25046
|
*/
|
|
24750
|
-
beginScrub() {
|
|
25047
|
+
beginScrub(dragId) {
|
|
24751
25048
|
if (this.disposed || this.scrubbing) return;
|
|
25049
|
+
this.scrubDragId = dragId;
|
|
24752
25050
|
this.seekToken++;
|
|
24753
25051
|
this.advanceToken++;
|
|
24754
25052
|
this.scrubbing = true;
|
|
@@ -24757,17 +25055,25 @@ var RecordedFeeder = class {
|
|
|
24757
25055
|
this.scrubSegment = null;
|
|
24758
25056
|
this.scrubCredit = SCRUB_CREDIT_CAP;
|
|
24759
25057
|
this.scrubPrevTarget = null;
|
|
24760
|
-
this.scrubDirRun = 0;
|
|
24761
25058
|
this.scrubPrefetched = /* @__PURE__ */ new Set();
|
|
24762
25059
|
this.scrubCursor = null;
|
|
24763
25060
|
this.scrubNoFootageLogged = false;
|
|
24764
25061
|
this.scrubParkLogged = false;
|
|
25062
|
+
this.scrubDeadlineLogged = false;
|
|
25063
|
+
this.scrubOversizeLogged = false;
|
|
25064
|
+
this.scrubProfileLogged = false;
|
|
25065
|
+
this.scrubPushedThisDrag = false;
|
|
25066
|
+
this.scrubResolve = null;
|
|
25067
|
+
this.scrubVelocity = null;
|
|
25068
|
+
this.scrubLastSampleMs = null;
|
|
25069
|
+
this.scrubVelRun = 0;
|
|
24765
25070
|
this.scrubOutstanding = [];
|
|
24766
25071
|
this.clearScrubAckTimeout();
|
|
24767
25072
|
this.scrubAckTimeoutLogged = false;
|
|
24768
25073
|
this.scrubPendingHint = null;
|
|
24769
25074
|
this.scrubStartedAtMs = performance.now();
|
|
24770
25075
|
this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
|
|
25076
|
+
this.reportProfileDegradation();
|
|
24771
25077
|
this.scrubDemuxMark = this.deps.demuxTally?.counts() ?? null;
|
|
24772
25078
|
this.scrubLastPushLogAtMs = 0;
|
|
24773
25079
|
this.scrubLastSlowLocateLogAtMs = 0;
|
|
@@ -24831,6 +25137,7 @@ var RecordedFeeder = class {
|
|
|
24831
25137
|
this.scrubWarmBackMs = opts?.backMs ?? SCRUB_WARM_BACK_MS;
|
|
24832
25138
|
this.scrubWarmFwdMs = opts?.fwdMs ?? SCRUB_WARM_FWD_MS;
|
|
24833
25139
|
this.deps.onLog?.("scrub-feeder:warm-start", {
|
|
25140
|
+
dragId: this.scrubDragId,
|
|
24834
25141
|
centreMs,
|
|
24835
25142
|
fillGen: this.scrubWarmGen
|
|
24836
25143
|
});
|
|
@@ -24856,6 +25163,7 @@ var RecordedFeeder = class {
|
|
|
24856
25163
|
lastWorked = gen;
|
|
24857
25164
|
if (gen === this.scrubWarmGen) {
|
|
24858
25165
|
this.deps.onLog?.("scrub-feeder:warm-done", {
|
|
25166
|
+
dragId: this.scrubDragId,
|
|
24859
25167
|
fillGen: gen,
|
|
24860
25168
|
idle: this.idleWarm && !this.scrubbing,
|
|
24861
25169
|
fetched,
|
|
@@ -24892,6 +25200,19 @@ var RecordedFeeder = class {
|
|
|
24892
25200
|
if (Math.abs(targetMs - centre) <= SCRUB_RECENTER_MS) return;
|
|
24893
25201
|
this.recenterScrubWarm(targetMs);
|
|
24894
25202
|
}
|
|
25203
|
+
/**
|
|
25204
|
+
* Is the operator's finger actually moving right now?
|
|
25205
|
+
*
|
|
25206
|
+
* `scrubbing` alone is not the question — a drag stays open while the finger
|
|
25207
|
+
* rests, and those pauses are exactly when the warm should work. The signal
|
|
25208
|
+
* is the pointer sample clock, which `noteScrubSample` stamps on every
|
|
25209
|
+
* `scrubTo` before any velocity gating, so it is true even for a drag too
|
|
25210
|
+
* slow or too jittery for the velocity estimator.
|
|
25211
|
+
*/
|
|
25212
|
+
fingerIsMoving() {
|
|
25213
|
+
if (!this.scrubbing) return false;
|
|
25214
|
+
return performance.now() - this.scrubLastSampleAtMs < SCRUB_WARM_QUIET_MS;
|
|
25215
|
+
}
|
|
24895
25216
|
async warmScrubWindow(centreMs, dragGen, fillGen, backMs, fwdMs) {
|
|
24896
25217
|
const spent = await this.warmScrubDirection(centreMs, -1, backMs, 0, dragGen, fillGen);
|
|
24897
25218
|
return this.warmScrubDirection(centreMs, 1, fwdMs, spent, dragGen, fillGen);
|
|
@@ -24931,6 +25252,10 @@ var RecordedFeeder = class {
|
|
|
24931
25252
|
durMs: warm.durMs,
|
|
24932
25253
|
fetched: false
|
|
24933
25254
|
};
|
|
25255
|
+
while (this.fingerIsMoving()) {
|
|
25256
|
+
if (!this.warmLoopAlive(dragGen) || fillGen !== this.scrubWarmGen) return null;
|
|
25257
|
+
await new Promise((r) => setTimeout(r, SCRUB_WARM_QUIET_MS));
|
|
25258
|
+
}
|
|
24934
25259
|
if (this.idleWarm && !this.scrubbing) {
|
|
24935
25260
|
await new Promise((r) => setTimeout(r, this.deps.idleWarmGapMs ?? IDLE_WARM_READ_GAP_MS));
|
|
24936
25261
|
while (this.inFlightSeekEpochMs !== null) {
|
|
@@ -24950,7 +25275,7 @@ var RecordedFeeder = class {
|
|
|
24950
25275
|
if (loc.kind !== "segment") return null;
|
|
24951
25276
|
if (!await this.waitForTickReadIdle(fillGen)) return null;
|
|
24952
25277
|
const load = await this.loadScrubFragment(loc, probeMs);
|
|
24953
|
-
if (load !== null && this.scrubbing) this.scrubStats?.
|
|
25278
|
+
if (load !== null && this.scrubbing) this.scrubStats?.recordWarmRead(load.readBytes);
|
|
24954
25279
|
if (!this.warmLoopAlive(dragGen) || load === null) return null;
|
|
24955
25280
|
if (fillGen !== this.scrubWarmGen) return null;
|
|
24956
25281
|
this.scrubCache.put(this.deps.profile, load.seg);
|
|
@@ -24982,6 +25307,7 @@ var RecordedFeeder = class {
|
|
|
24982
25307
|
*/
|
|
24983
25308
|
scrubTo(epochMs, hint) {
|
|
24984
25309
|
if (this.disposed || !this.scrubbing) return;
|
|
25310
|
+
this.noteScrubSample(epochMs);
|
|
24985
25311
|
this.scrubPendingEpochMs = epochMs;
|
|
24986
25312
|
this.scrubPendingHint = hint ?? null;
|
|
24987
25313
|
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
@@ -25069,6 +25395,7 @@ var RecordedFeeder = class {
|
|
|
25069
25395
|
if (!this.scrubAckTimeoutLogged) {
|
|
25070
25396
|
this.scrubAckTimeoutLogged = true;
|
|
25071
25397
|
this.deps.onLog?.("scrub-feeder:ack-timeout", {
|
|
25398
|
+
dragId: this.scrubDragId,
|
|
25072
25399
|
pushes: expired,
|
|
25073
25400
|
frames,
|
|
25074
25401
|
timeoutMs: SCRUB_ACK_TIMEOUT_MS,
|
|
@@ -25100,6 +25427,7 @@ var RecordedFeeder = class {
|
|
|
25100
25427
|
if (!this.scrubParkLogged) {
|
|
25101
25428
|
this.scrubParkLogged = true;
|
|
25102
25429
|
this.deps.onLog?.("scrub-feeder:parked", {
|
|
25430
|
+
dragId: this.scrubDragId,
|
|
25103
25431
|
pendingEpochMs: this.scrubPendingEpochMs,
|
|
25104
25432
|
outstanding: this.scrubOutstanding.length
|
|
25105
25433
|
});
|
|
@@ -25107,7 +25435,7 @@ var RecordedFeeder = class {
|
|
|
25107
25435
|
return;
|
|
25108
25436
|
}
|
|
25109
25437
|
const target = this.scrubPendingEpochMs;
|
|
25110
|
-
|
|
25438
|
+
let hint = this.scrubPendingHint;
|
|
25111
25439
|
this.scrubPendingEpochMs = null;
|
|
25112
25440
|
this.scrubPendingHint = null;
|
|
25113
25441
|
if (target === null) {
|
|
@@ -25132,74 +25460,62 @@ var RecordedFeeder = class {
|
|
|
25132
25460
|
seg = this.scrubCache.covering(this.deps.profile, target);
|
|
25133
25461
|
if (seg) {
|
|
25134
25462
|
coverage = "cache";
|
|
25135
|
-
if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.
|
|
25463
|
+
if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordWarmHit();
|
|
25136
25464
|
}
|
|
25137
25465
|
}
|
|
25138
25466
|
if (!seg) {
|
|
25139
|
-
|
|
25140
|
-
|
|
25141
|
-
|
|
25142
|
-
|
|
25143
|
-
|
|
25144
|
-
|
|
25145
|
-
|
|
25146
|
-
|
|
25147
|
-
|
|
25148
|
-
|
|
25149
|
-
|
|
25150
|
-
|
|
25151
|
-
|
|
25152
|
-
|
|
25153
|
-
|
|
25154
|
-
|
|
25155
|
-
|
|
25156
|
-
|
|
25157
|
-
|
|
25158
|
-
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
-
|
|
25162
|
-
|
|
25163
|
-
|
|
25164
|
-
|
|
25165
|
-
|
|
25166
|
-
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
epochMs: decision.snapMs
|
|
25171
|
-
}));
|
|
25172
|
-
locateCalls += 1;
|
|
25173
|
-
locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
|
|
25174
|
-
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
25175
|
-
if (snapped.kind !== "segment") {
|
|
25176
|
-
if (!this.scrubNoFootageLogged) {
|
|
25177
|
-
this.scrubNoFootageLogged = true;
|
|
25178
|
-
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
25179
|
-
}
|
|
25180
|
-
return;
|
|
25181
|
-
}
|
|
25182
|
-
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
25183
|
-
requestedMs: target,
|
|
25184
|
-
snappedMs: decision.snapMs
|
|
25467
|
+
const cfg = this.deps.scrubDeadline;
|
|
25468
|
+
const rideWindowMs = cfg?.rideWindowMs ?? SCRUB_RIDE_WINDOW_MS;
|
|
25469
|
+
const inFlight = this.scrubResolve;
|
|
25470
|
+
let entry;
|
|
25471
|
+
if (inFlight !== null && (Math.abs(target - inFlight.target) <= rideWindowMs || this.scrubResolvesInFlight >= SCRUB_RESOLVE_MAX_INFLIGHT)) {
|
|
25472
|
+
entry = inFlight;
|
|
25473
|
+
this.scrubStats?.recordResolveJoin();
|
|
25474
|
+
} else {
|
|
25475
|
+
const leadMs = this.scrubLeadMs();
|
|
25476
|
+
if (leadMs !== 0) this.scrubStats?.recordLead(leadMs);
|
|
25477
|
+
const readTarget = target + leadMs;
|
|
25478
|
+
entry = {
|
|
25479
|
+
target: readTarget,
|
|
25480
|
+
promise: this.startScrubResolve(readTarget, gen)
|
|
25481
|
+
};
|
|
25482
|
+
this.scrubResolve = entry;
|
|
25483
|
+
}
|
|
25484
|
+
const deadlineMs = this.scrubPushedThisDrag ? cfg?.tickMs ?? SCRUB_TICK_DEADLINE_MS : cfg?.firstTickMs ?? SCRUB_FIRST_TICK_DEADLINE_MS;
|
|
25485
|
+
const res = await this.raceDeadline(entry.promise, performance.now() + deadlineMs);
|
|
25486
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
25487
|
+
if (res === "deadline") {
|
|
25488
|
+
this.scrubStats?.recordDeadlineMiss();
|
|
25489
|
+
if (this.scrubPendingEpochMs === null) this.scrubPendingEpochMs = target;
|
|
25490
|
+
if (!this.scrubDeadlineLogged) {
|
|
25491
|
+
this.scrubDeadlineLogged = true;
|
|
25492
|
+
this.deps.onLog?.("scrub-feeder:deadline-miss", {
|
|
25493
|
+
dragId: this.scrubDragId,
|
|
25494
|
+
epochMs: target,
|
|
25495
|
+
deadlineMs,
|
|
25496
|
+
first: !this.scrubPushedThisDrag,
|
|
25497
|
+
inFlight: this.scrubResolvesInFlight
|
|
25185
25498
|
});
|
|
25186
|
-
loc = snapped;
|
|
25187
|
-
effTarget = decision.snapMs;
|
|
25188
|
-
}
|
|
25189
|
-
seg = this.scrubCache.covering(this.deps.profile, effTarget);
|
|
25190
|
-
if (!seg) {
|
|
25191
|
-
const load = await this.loadScrubFragment(loc, effTarget);
|
|
25192
|
-
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
|
|
25193
|
-
seg = load.seg;
|
|
25194
|
-
readMs = load.readMs;
|
|
25195
|
-
readBytes = load.readBytes;
|
|
25196
|
-
demuxMs = load.demuxMs;
|
|
25197
|
-
wholeSegmentRead = load.wholeSegmentRead;
|
|
25198
|
-
this.scrubCache.put(this.deps.profile, seg);
|
|
25199
25499
|
}
|
|
25200
|
-
|
|
25201
|
-
this.scrubTickReadInFlight = false;
|
|
25500
|
+
return;
|
|
25202
25501
|
}
|
|
25502
|
+
if (res === null) return;
|
|
25503
|
+
seg = res.seg;
|
|
25504
|
+
const newest = this.scrubPendingEpochMs;
|
|
25505
|
+
if (res.effTarget !== res.requested) effTarget = res.effTarget;
|
|
25506
|
+
else if (newest !== null && fragmentCovers(seg, newest)) {
|
|
25507
|
+
effTarget = newest;
|
|
25508
|
+
hint = this.scrubPendingHint;
|
|
25509
|
+
this.scrubPendingEpochMs = null;
|
|
25510
|
+
this.scrubPendingHint = null;
|
|
25511
|
+
} else effTarget = newest ?? target;
|
|
25512
|
+
if (effTarget !== res.effTarget) this.scrubStats?.recordRetarget();
|
|
25513
|
+
locateCalls = res.locateCalls;
|
|
25514
|
+
locateMs = res.locateMs;
|
|
25515
|
+
readMs = res.readMs;
|
|
25516
|
+
readBytes = res.readBytes;
|
|
25517
|
+
demuxMs = res.demuxMs;
|
|
25518
|
+
wholeSegmentRead = res.wholeSegmentRead;
|
|
25203
25519
|
}
|
|
25204
25520
|
this.scrubSegment = seg;
|
|
25205
25521
|
const prevTarget = this.scrubPrevTarget;
|
|
@@ -25266,6 +25582,7 @@ var RecordedFeeder = class {
|
|
|
25266
25582
|
pushedAtMs
|
|
25267
25583
|
}];
|
|
25268
25584
|
this.scrubCredit -= 1;
|
|
25585
|
+
this.scrubPushedThisDrag = true;
|
|
25269
25586
|
this.armScrubAckTimeout();
|
|
25270
25587
|
this.clockHasAdvanced = true;
|
|
25271
25588
|
let runBytes = 0;
|
|
@@ -25297,6 +25614,7 @@ var RecordedFeeder = class {
|
|
|
25297
25614
|
if (this.scrubLastPushLogAtMs === 0 || pushedAtMs - this.scrubLastPushLogAtMs >= 1e3) {
|
|
25298
25615
|
this.scrubLastPushLogAtMs = pushedAtMs;
|
|
25299
25616
|
this.deps.onLog?.("scrub-feeder:pushed", {
|
|
25617
|
+
dragId: this.scrubDragId,
|
|
25300
25618
|
epochMs: effTarget,
|
|
25301
25619
|
mode,
|
|
25302
25620
|
dir,
|
|
@@ -25314,10 +25632,11 @@ var RecordedFeeder = class {
|
|
|
25314
25632
|
});
|
|
25315
25633
|
}
|
|
25316
25634
|
this.deps.onPosition(this.clampIntoSegment(seg, effTarget), pushSeq);
|
|
25317
|
-
this.
|
|
25635
|
+
this.scrubPrevTarget = effTarget;
|
|
25318
25636
|
} catch (err) {
|
|
25319
25637
|
this.scrubStats?.recordFailure();
|
|
25320
25638
|
this.deps.onLog?.("scrub-feeder:tick-failed", {
|
|
25639
|
+
dragId: this.scrubDragId,
|
|
25321
25640
|
epochMs: target,
|
|
25322
25641
|
error: err instanceof Error ? err.message : String(err)
|
|
25323
25642
|
});
|
|
@@ -25327,6 +25646,115 @@ var RecordedFeeder = class {
|
|
|
25327
25646
|
}
|
|
25328
25647
|
}
|
|
25329
25648
|
/**
|
|
25649
|
+
* ONE fragment resolution for the drag: locate (with gap snapping), read,
|
|
25650
|
+
* demux, cache. Deliberately NOT bound to the tick that started it.
|
|
25651
|
+
*
|
|
25652
|
+
* The tick races this with the frame's deadline and may walk away; this
|
|
25653
|
+
* promise then finishes on its own and puts the fragment in the session
|
|
25654
|
+
* cache, because the read is already on the wire and abandoning its RESULT
|
|
25655
|
+
* as well would turn a dropped frame into wasted disk. That is what makes
|
|
25656
|
+
* the deadline cheap: the tick after a miss is usually a cache hit.
|
|
25657
|
+
*
|
|
25658
|
+
* Null ⇒ nothing to serve (dead zone, superseded generation, or a load the
|
|
25659
|
+
* seek token invalidated). Throws propagate to the tick's `tick-failed`.
|
|
25660
|
+
*/
|
|
25661
|
+
async startScrubResolve(target, gen) {
|
|
25662
|
+
this.scrubResolvesInFlight += 1;
|
|
25663
|
+
this.scrubTickReadInFlight = true;
|
|
25664
|
+
let locateCalls = 0;
|
|
25665
|
+
let locateMs = null;
|
|
25666
|
+
try {
|
|
25667
|
+
this.maybeRecenterScrubWarm(target);
|
|
25668
|
+
const locateStartMs = performance.now();
|
|
25669
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
25670
|
+
deviceId: this.deps.deviceId,
|
|
25671
|
+
profile: this.deps.profile,
|
|
25672
|
+
epochMs: target
|
|
25673
|
+
}));
|
|
25674
|
+
locateCalls += 1;
|
|
25675
|
+
locateMs = performance.now() - locateStartMs;
|
|
25676
|
+
this.maybeLogSlowLocate(target, locateMs);
|
|
25677
|
+
if (this.disposed || gen !== this.scrubToken) return null;
|
|
25678
|
+
let effTarget = target;
|
|
25679
|
+
if (loc.kind === "gap") {
|
|
25680
|
+
const decision = resolveSnapTarget(loc, target);
|
|
25681
|
+
if (decision.kind !== "snap") {
|
|
25682
|
+
if (!this.scrubNoFootageLogged) {
|
|
25683
|
+
this.scrubNoFootageLogged = true;
|
|
25684
|
+
if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
|
|
25685
|
+
dragId: this.scrubDragId,
|
|
25686
|
+
epochMs: target,
|
|
25687
|
+
nearestMs: decision.snapMs,
|
|
25688
|
+
distanceMs: decision.distanceMs
|
|
25689
|
+
});
|
|
25690
|
+
else this.deps.onLog?.("scrub-feeder:no-footage", {
|
|
25691
|
+
dragId: this.scrubDragId,
|
|
25692
|
+
epochMs: target
|
|
25693
|
+
});
|
|
25694
|
+
}
|
|
25695
|
+
return null;
|
|
25696
|
+
}
|
|
25697
|
+
const snapStartMs = performance.now();
|
|
25698
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
25699
|
+
deviceId: this.deps.deviceId,
|
|
25700
|
+
profile: this.deps.profile,
|
|
25701
|
+
epochMs: decision.snapMs
|
|
25702
|
+
}));
|
|
25703
|
+
locateCalls += 1;
|
|
25704
|
+
locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
|
|
25705
|
+
if (this.disposed || gen !== this.scrubToken) return null;
|
|
25706
|
+
if (snapped.kind !== "segment") {
|
|
25707
|
+
if (!this.scrubNoFootageLogged) {
|
|
25708
|
+
this.scrubNoFootageLogged = true;
|
|
25709
|
+
this.deps.onLog?.("scrub-feeder:no-footage", {
|
|
25710
|
+
dragId: this.scrubDragId,
|
|
25711
|
+
epochMs: target
|
|
25712
|
+
});
|
|
25713
|
+
}
|
|
25714
|
+
return null;
|
|
25715
|
+
}
|
|
25716
|
+
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
25717
|
+
dragId: this.scrubDragId,
|
|
25718
|
+
requestedMs: target,
|
|
25719
|
+
snappedMs: decision.snapMs
|
|
25720
|
+
});
|
|
25721
|
+
loc = snapped;
|
|
25722
|
+
effTarget = decision.snapMs;
|
|
25723
|
+
}
|
|
25724
|
+
const warm = this.scrubCache.covering(this.deps.profile, effTarget);
|
|
25725
|
+
if (warm) return {
|
|
25726
|
+
seg: warm,
|
|
25727
|
+
requested: target,
|
|
25728
|
+
effTarget,
|
|
25729
|
+
locateCalls,
|
|
25730
|
+
locateMs,
|
|
25731
|
+
readMs: null,
|
|
25732
|
+
readBytes: null,
|
|
25733
|
+
demuxMs: null,
|
|
25734
|
+
wholeSegmentRead: false
|
|
25735
|
+
};
|
|
25736
|
+
const load = await this.loadScrubFragment(loc, effTarget);
|
|
25737
|
+
if (this.disposed || load === null) return null;
|
|
25738
|
+
this.scrubCache.put(this.deps.profile, load.seg);
|
|
25739
|
+
if (gen !== this.scrubToken) return null;
|
|
25740
|
+
return {
|
|
25741
|
+
seg: load.seg,
|
|
25742
|
+
requested: target,
|
|
25743
|
+
effTarget,
|
|
25744
|
+
locateCalls,
|
|
25745
|
+
locateMs,
|
|
25746
|
+
readMs: load.readMs,
|
|
25747
|
+
readBytes: load.readBytes,
|
|
25748
|
+
demuxMs: load.demuxMs,
|
|
25749
|
+
wholeSegmentRead: load.wholeSegmentRead
|
|
25750
|
+
};
|
|
25751
|
+
} finally {
|
|
25752
|
+
this.scrubResolvesInFlight -= 1;
|
|
25753
|
+
if (this.scrubResolvesInFlight === 0) this.scrubTickReadInFlight = false;
|
|
25754
|
+
if (this.scrubResolve?.target === target) this.scrubResolve = null;
|
|
25755
|
+
}
|
|
25756
|
+
}
|
|
25757
|
+
/**
|
|
25330
25758
|
* Index of the nearest AU at-or-before the drag target within `seg`
|
|
25331
25759
|
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
25332
25760
|
* guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
|
|
@@ -25419,7 +25847,21 @@ var RecordedFeeder = class {
|
|
|
25419
25847
|
*/
|
|
25420
25848
|
readsWholeSegment(loc) {
|
|
25421
25849
|
if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
|
|
25422
|
-
|
|
25850
|
+
if (loc.bytes === void 0 || loc.bytes <= 15e5) return true;
|
|
25851
|
+
this.scrubStats?.recordOversizeSegment();
|
|
25852
|
+
if (!this.scrubOversizeLogged) {
|
|
25853
|
+
this.scrubOversizeLogged = true;
|
|
25854
|
+
this.deps.onLog?.("scrub-feeder:oversize-segment", {
|
|
25855
|
+
dragId: this.scrubDragId,
|
|
25856
|
+
profile: this.deps.profile,
|
|
25857
|
+
startMs: loc.startMs,
|
|
25858
|
+
durMs: loc.durMs,
|
|
25859
|
+
bytes: loc.bytes,
|
|
25860
|
+
maxBytes: SCRUB_WHOLE_SEGMENT_MAX_BYTES,
|
|
25861
|
+
bytesPerSecond: loc.durMs > 0 ? Math.round(loc.bytes / (loc.durMs / 1e3)) : null
|
|
25862
|
+
});
|
|
25863
|
+
}
|
|
25864
|
+
return false;
|
|
25423
25865
|
}
|
|
25424
25866
|
/**
|
|
25425
25867
|
* Fold one read into the recent-read window that governs speculation. The
|
|
@@ -25430,15 +25872,74 @@ var RecordedFeeder = class {
|
|
|
25430
25872
|
this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
|
|
25431
25873
|
}
|
|
25432
25874
|
/**
|
|
25433
|
-
*
|
|
25434
|
-
*
|
|
25435
|
-
*
|
|
25436
|
-
*
|
|
25875
|
+
* A drag that is NOT on the scrub profile runs with every scrub optimisation
|
|
25876
|
+
* off: the whole-segment read unit, the 48 MB fragment-cache budget and the
|
|
25877
|
+
* entry warm are all gated on the exact string `low`
|
|
25878
|
+
* ({@link SCRUB_WHOLE_SEGMENT_PROFILE}). A momentary hole in `low` therefore
|
|
25879
|
+
* turns a drag into 3-seek GOP reads over ~5 MB `high` segments — measured at
|
|
25880
|
+
* 2 642 ms cold — and the client cannot repair it.
|
|
25881
|
+
*
|
|
25882
|
+
* It used to be indistinguishable from a slow disk. Now it says so, once per
|
|
25883
|
+
* drag, and the census carries the flag beside the numbers it explains.
|
|
25884
|
+
*/
|
|
25885
|
+
reportProfileDegradation() {
|
|
25886
|
+
if (this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) return;
|
|
25887
|
+
this.scrubStats?.recordProfileDegraded(this.deps.profile);
|
|
25888
|
+
if (this.scrubProfileLogged) return;
|
|
25889
|
+
this.scrubProfileLogged = true;
|
|
25890
|
+
this.deps.onLog?.("scrub-feeder:profile-degraded", {
|
|
25891
|
+
dragId: this.scrubDragId,
|
|
25892
|
+
profile: this.deps.profile,
|
|
25893
|
+
scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
|
|
25894
|
+
});
|
|
25895
|
+
}
|
|
25896
|
+
/**
|
|
25897
|
+
* Median of the recent reads — what a read costs on this disk RIGHT NOW.
|
|
25898
|
+
* Null until there are enough samples to call it anything: a single cold
|
|
25899
|
+
* first read is not a median, and acting on it would aim a whole gesture.
|
|
25437
25900
|
*/
|
|
25438
|
-
|
|
25439
|
-
if (this.scrubReadMsWindow.length <
|
|
25901
|
+
scrubReadMedianMs() {
|
|
25902
|
+
if (this.scrubReadMsWindow.length < SCRUB_LEAD_MIN_READ_SAMPLES) return null;
|
|
25440
25903
|
const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
|
|
25441
|
-
return
|
|
25904
|
+
return sorted[Math.floor(sorted.length / 2)] ?? null;
|
|
25905
|
+
}
|
|
25906
|
+
/**
|
|
25907
|
+
* Fold one pointer sample into the drag's media velocity (ms of timeline per
|
|
25908
|
+
* ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
|
|
25909
|
+
*/
|
|
25910
|
+
noteScrubSample(epochMs) {
|
|
25911
|
+
const now = performance.now();
|
|
25912
|
+
const prev = this.scrubLastSampleMs;
|
|
25913
|
+
const prevAt = this.scrubLastSampleAtMs;
|
|
25914
|
+
this.scrubLastSampleMs = epochMs;
|
|
25915
|
+
this.scrubLastSampleAtMs = now;
|
|
25916
|
+
if (prev === null) return;
|
|
25917
|
+
const gapMs = now - prevAt;
|
|
25918
|
+
if (gapMs < SCRUB_VELOCITY_MIN_GAP_MS || gapMs > SCRUB_VELOCITY_MAX_GAP_MS) return;
|
|
25919
|
+
const deltaMs = epochMs - prev;
|
|
25920
|
+
const dir = Math.sign(deltaMs);
|
|
25921
|
+
if (dir === 0) {
|
|
25922
|
+
this.scrubVelRun = 0;
|
|
25923
|
+
return;
|
|
25924
|
+
}
|
|
25925
|
+
this.scrubVelRun = Math.sign(this.scrubVelRun) === dir ? this.scrubVelRun + dir : dir;
|
|
25926
|
+
const v = deltaMs / gapMs;
|
|
25927
|
+
this.scrubVelocity = this.scrubVelocity === null ? v : this.scrubVelocity * (1 - SCRUB_VELOCITY_EMA) + v * SCRUB_VELOCITY_EMA;
|
|
25928
|
+
}
|
|
25929
|
+
/**
|
|
25930
|
+
* How far AHEAD of the finger this read should aim — see the
|
|
25931
|
+
* {@link SCRUB_LEAD_MIN_READ_SAMPLES} docblock for why this exists and what
|
|
25932
|
+
* bounds it. Zero whenever the prediction is not earned: no measured reads,
|
|
25933
|
+
* no sustained direction, or a velocity whose sign disagrees with the run.
|
|
25934
|
+
*/
|
|
25935
|
+
scrubLeadMs() {
|
|
25936
|
+
const velocity = this.scrubVelocity;
|
|
25937
|
+
if (velocity === null) return 0;
|
|
25938
|
+
if (Math.abs(this.scrubVelRun) < SCRUB_LEAD_MIN_DIR_RUN) return 0;
|
|
25939
|
+
if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
|
|
25940
|
+
const readMs = this.scrubReadMedianMs();
|
|
25941
|
+
if (readMs === null) return 0;
|
|
25942
|
+
return scrubLeadDecision(velocity, readMs).leadMs;
|
|
25442
25943
|
}
|
|
25443
25944
|
/**
|
|
25444
25945
|
* A single slow `locate` gets its own line, throttled per drag. Reserved for
|
|
@@ -25454,6 +25955,7 @@ var RecordedFeeder = class {
|
|
|
25454
25955
|
if (this.scrubLastSlowLocateLogAtMs !== 0 && now - this.scrubLastSlowLocateLogAtMs < SCRUB_PATHOLOGY_LOG_INTERVAL_MS) return;
|
|
25455
25956
|
this.scrubLastSlowLocateLogAtMs = now;
|
|
25456
25957
|
this.deps.onLog?.("scrub-feeder:slow-locate", {
|
|
25958
|
+
dragId: this.scrubDragId,
|
|
25457
25959
|
epochMs,
|
|
25458
25960
|
locateMs: Math.round(locateMs),
|
|
25459
25961
|
thresholdMs: threshold
|
|
@@ -25470,7 +25972,7 @@ var RecordedFeeder = class {
|
|
|
25470
25972
|
* interval comes out warm for the next drag. Aborts (without landing — the
|
|
25471
25973
|
* caller owns that) the moment a newer seek bumps the token.
|
|
25472
25974
|
*/
|
|
25473
|
-
async animateSeekTravel(fromMs, toMs, token) {
|
|
25975
|
+
async animateSeekTravel(fromMs, toMs, token, dragId) {
|
|
25474
25976
|
const cfg = this.deps.seekAnimation;
|
|
25475
25977
|
const budgetMs = cfg?.budgetMs ?? SEEK_ANIMATION_BUDGET_MS;
|
|
25476
25978
|
const tickMs = cfg?.tickMs ?? SEEK_ANIMATION_TICK_MS;
|
|
@@ -25502,6 +26004,7 @@ var RecordedFeeder = class {
|
|
|
25502
26004
|
if (this.disposed || token !== this.seekToken) return;
|
|
25503
26005
|
}
|
|
25504
26006
|
if (stills > 0) this.deps.onLog?.("recorded:seek-travel", {
|
|
26007
|
+
dragId,
|
|
25505
26008
|
fromMs,
|
|
25506
26009
|
toMs,
|
|
25507
26010
|
stills,
|
|
@@ -25596,79 +26099,41 @@ var RecordedFeeder = class {
|
|
|
25596
26099
|
return stills;
|
|
25597
26100
|
}
|
|
25598
26101
|
/**
|
|
25599
|
-
*
|
|
25600
|
-
* shows a sustained direction, kick off a single background prefetch of the
|
|
25601
|
-
* NEXT fragment in that direction. Prefetch fills the cache only — it never
|
|
25602
|
-
* pushes, so crossing into the neighbour later is an instant cache hit.
|
|
25603
|
-
*/
|
|
25604
|
-
maybeScrubPrefetch(seg, target, gen) {
|
|
25605
|
-
const prev = this.scrubPrevTarget;
|
|
25606
|
-
this.scrubPrevTarget = target;
|
|
25607
|
-
if (prev === null) {
|
|
25608
|
-
this.scrubDirRun = 0;
|
|
25609
|
-
return;
|
|
25610
|
-
}
|
|
25611
|
-
const stepMs = target - prev;
|
|
25612
|
-
const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
|
|
25613
|
-
if (dir === 0) {
|
|
25614
|
-
this.scrubDirRun = 0;
|
|
25615
|
-
return;
|
|
25616
|
-
}
|
|
25617
|
-
this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
|
|
25618
|
-
if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
|
|
25619
|
-
if (this.scrubReadsAreSlow()) {
|
|
25620
|
-
this.scrubStats?.recordPrefetchSuppressed();
|
|
25621
|
-
return;
|
|
25622
|
-
}
|
|
25623
|
-
if (this.scrubFillLoopActive) return;
|
|
25624
|
-
const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
|
|
25625
|
-
const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
|
|
25626
|
-
for (let horizon = 1; horizon <= horizons; horizon++) {
|
|
25627
|
-
if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
|
|
25628
|
-
const probeMs = target + dir * aheadMs * horizon;
|
|
25629
|
-
if (probeMs < 0) continue;
|
|
25630
|
-
if (this.scrubPrefetchProbes.has(probeMs)) continue;
|
|
25631
|
-
if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
|
|
25632
|
-
if (this.scrubWarmCentreMs !== null && Math.abs(probeMs - this.scrubWarmCentreMs) > SCRUB_WARM_AROUND_MS) continue;
|
|
25633
|
-
this.scrubPrefetch(probeMs, gen);
|
|
25634
|
-
}
|
|
25635
|
-
}
|
|
25636
|
-
/**
|
|
25637
|
-
* Speculative read: locate + read + demux the fragment covering `probeMs` into
|
|
25638
|
-
* `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
|
|
25639
|
-
* teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
|
|
25640
|
-
* swallowed (the fragment just demuxes on demand). Never pushes and never
|
|
25641
|
-
* mutates latest-wins state.
|
|
26102
|
+
* The directional prefetch is GONE (2026-08-26), not disabled.
|
|
25642
26103
|
*
|
|
25643
|
-
*
|
|
25644
|
-
*
|
|
25645
|
-
*
|
|
25646
|
-
*
|
|
25647
|
-
|
|
25648
|
-
|
|
25649
|
-
|
|
25650
|
-
|
|
25651
|
-
|
|
25652
|
-
|
|
25653
|
-
|
|
25654
|
-
|
|
25655
|
-
|
|
25656
|
-
|
|
25657
|
-
|
|
25658
|
-
|
|
25659
|
-
|
|
25660
|
-
|
|
25661
|
-
|
|
25662
|
-
|
|
25663
|
-
|
|
25664
|
-
|
|
25665
|
-
|
|
25666
|
-
|
|
25667
|
-
|
|
25668
|
-
|
|
25669
|
-
|
|
25670
|
-
|
|
25671
|
-
|
|
26104
|
+
* It guessed where the next tick would land from the drag's recent
|
|
26105
|
+
* direction. Measured on the operator's own drags the day it was removed:
|
|
26106
|
+
* **25 speculative reads bought 3 hits** — 88% waste, and roughly 60% of all
|
|
26107
|
+
* disk work during a gesture, queued as random seeks ahead of the read the
|
|
26108
|
+
* finger was actually blocked on, on a 5400 rpm SMR disk.
|
|
26109
|
+
*
|
|
26110
|
+
* The regression tests it passed drove a drag at CONSTANT step size, which is
|
|
26111
|
+
* the one regime where direction predicts position. A real backward scrub is
|
|
26112
|
+
* exploratory — 26 of 29 jumps backward, step size set by where the operator
|
|
26113
|
+
* decided to look next — so the premise never held in the field.
|
|
26114
|
+
*
|
|
26115
|
+
* Scrypted has no predictive prefetch either: their read-ahead is a stream
|
|
26116
|
+
* `highWaterMark`, and a short forward move is a fast-forward on an open fd
|
|
26117
|
+
* rather than a seek. They deleted the problem instead of solving it.
|
|
26118
|
+
*
|
|
26119
|
+
* What REMAINS and must not be confused with this: the WINDOW warm
|
|
26120
|
+
* (`runScrubFillLoop`) — a sequential walk of the window around the playhead.
|
|
26121
|
+
*
|
|
26122
|
+
* It was described here, wrongly, as running "when no drag is live, so it
|
|
26123
|
+
* competes with nothing". It does not: its survival condition is
|
|
26124
|
+
* `this.scrubbing || this.idleWarm`, and within hours of this deletion it had
|
|
26125
|
+
* inherited the deleted predictor's role — 1.9 MB and 2.4 MB of reads on two
|
|
26126
|
+
* measured drags, 38-45% of the gesture's I/O, zero cache hits. Deleting one
|
|
26127
|
+
* speculator without looking at the other left the contention exactly where
|
|
26128
|
+
* it was. It now waits out a moving finger (`fingerIsMoving`), which is what
|
|
26129
|
+
* the sentence above claimed all along.
|
|
26130
|
+
*
|
|
26131
|
+
* Cost of the removal, bounded and accepted: a careful short-step drag no
|
|
26132
|
+
* longer warms the fragment across the edge, so the first tick past a
|
|
26133
|
+
* boundary may pay one cold read. Its own regression test shows that case is
|
|
26134
|
+
* served by the MEMO (`coverMemo ≥ 5`) with the neighbour warm worth ≤2
|
|
26135
|
+
* reads.
|
|
26136
|
+
*/
|
|
25672
26137
|
/**
|
|
25673
26138
|
* Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
|
|
25674
26139
|
* paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
|
|
@@ -25852,7 +26317,7 @@ var RecordedFeeder = class {
|
|
|
25852
26317
|
this.advanceLoop(gen, initial).catch(() => {
|
|
25853
26318
|
if (!this.disposed && gen === this.advanceToken) {
|
|
25854
26319
|
this.stopPositionReports();
|
|
25855
|
-
this.
|
|
26320
|
+
this.emitState("error", "advance-loop-failed", this.seekToken, this.scrubDragId);
|
|
25856
26321
|
}
|
|
25857
26322
|
});
|
|
25858
26323
|
}
|
|
@@ -25869,7 +26334,7 @@ var RecordedFeeder = class {
|
|
|
25869
26334
|
if (prepared === null) return;
|
|
25870
26335
|
if (prepared === "ended") {
|
|
25871
26336
|
this.stopPositionReports();
|
|
25872
|
-
this.
|
|
26337
|
+
this.emitState("ended", "advance-ended", this.seekToken, this.scrubDragId);
|
|
25873
26338
|
return;
|
|
25874
26339
|
}
|
|
25875
26340
|
if (isResumedSegment) {} else {
|
|
@@ -26146,7 +26611,10 @@ var RecordedFeeder = class {
|
|
|
26146
26611
|
const mark = this.scrubDemuxMark;
|
|
26147
26612
|
const demux = mark && this.deps.demuxTally ? this.deps.demuxTally.deltaSince(mark) : void 0;
|
|
26148
26613
|
this.scrubDemuxMark = null;
|
|
26149
|
-
this.deps.onLog?.("scrub-feeder:drag-end",
|
|
26614
|
+
this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
26615
|
+
dragId: this.scrubDragId,
|
|
26616
|
+
...stats.toLogFields(performance.now() - this.scrubStartedAtMs, demux)
|
|
26617
|
+
});
|
|
26150
26618
|
}
|
|
26151
26619
|
/** Stop everything: invalidate in-flight work and tear down the position timer. */
|
|
26152
26620
|
dispose() {
|
|
@@ -26159,10 +26627,17 @@ var RecordedFeeder = class {
|
|
|
26159
26627
|
this.scrubToken++;
|
|
26160
26628
|
this.scrubSegment = null;
|
|
26161
26629
|
this.scrubPrevTarget = null;
|
|
26162
|
-
this.scrubDirRun = 0;
|
|
26163
26630
|
this.scrubCursor = null;
|
|
26164
26631
|
this.scrubNoFootageLogged = false;
|
|
26165
26632
|
this.scrubParkLogged = false;
|
|
26633
|
+
this.scrubDeadlineLogged = false;
|
|
26634
|
+
this.scrubOversizeLogged = false;
|
|
26635
|
+
this.scrubProfileLogged = false;
|
|
26636
|
+
this.scrubPushedThisDrag = false;
|
|
26637
|
+
this.scrubResolve = null;
|
|
26638
|
+
this.scrubVelocity = null;
|
|
26639
|
+
this.scrubLastSampleMs = null;
|
|
26640
|
+
this.scrubVelRun = 0;
|
|
26166
26641
|
this.scrubOutstanding = [];
|
|
26167
26642
|
this.clearScrubAckTimeout();
|
|
26168
26643
|
this.scrubPendingHint = null;
|
|
@@ -26243,10 +26718,11 @@ var TimelineSession = class {
|
|
|
26243
26718
|
await this.playAt(msg.epoch, this.profile, { travelFromMs: this.lastPositionMs });
|
|
26244
26719
|
break;
|
|
26245
26720
|
case "scrubCommit":
|
|
26246
|
-
await this.playAt(msg.epoch, this.profile);
|
|
26721
|
+
if (msg.dragId !== void 0) await this.playAt(msg.epoch, this.profile, { dragId: msg.dragId });
|
|
26722
|
+
else await this.playAt(msg.epoch, this.profile);
|
|
26247
26723
|
break;
|
|
26248
26724
|
case "scrubStart":
|
|
26249
|
-
(this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
|
|
26725
|
+
(this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub(msg.dragId);
|
|
26250
26726
|
break;
|
|
26251
26727
|
case "scrubTo": {
|
|
26252
26728
|
const hint = msg.m !== void 0 && msg.v !== void 0 ? {
|
|
@@ -26500,7 +26976,7 @@ function createOffThreadJsParse(deps) {
|
|
|
26500
26976
|
if (!w) return deps.parseInProcess(bytes);
|
|
26501
26977
|
const id = nextId;
|
|
26502
26978
|
nextId += 1;
|
|
26503
|
-
const copy = bytes
|
|
26979
|
+
const copy = new Uint8Array(bytes);
|
|
26504
26980
|
const request = {
|
|
26505
26981
|
id,
|
|
26506
26982
|
bytes: copy
|
|
@@ -27937,14 +28413,21 @@ var BrokerWebrtcServer = class {
|
|
|
27937
28413
|
pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
|
|
27938
28414
|
pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
|
|
27939
28415
|
},
|
|
27940
|
-
onState: (
|
|
27941
|
-
sessionLogger.info("recorded: feeder state", {
|
|
27942
|
-
|
|
27943
|
-
|
|
27944
|
-
|
|
27945
|
-
|
|
27946
|
-
|
|
27947
|
-
|
|
28416
|
+
onState: (t) => {
|
|
28417
|
+
sessionLogger.info("recorded: feeder state", {
|
|
28418
|
+
tags: { deviceId },
|
|
28419
|
+
meta: {
|
|
28420
|
+
sessionId,
|
|
28421
|
+
deviceId,
|
|
28422
|
+
profile,
|
|
28423
|
+
from: t.from,
|
|
28424
|
+
to: t.to,
|
|
28425
|
+
reason: t.reason,
|
|
28426
|
+
seekToken: t.seekToken,
|
|
28427
|
+
dragId: t.dragId
|
|
28428
|
+
}
|
|
28429
|
+
});
|
|
28430
|
+
timeline._emitState(t.to);
|
|
27948
28431
|
},
|
|
27949
28432
|
onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
|
|
27950
28433
|
onLog: (msg, data) => sessionLogger.info(msg, {
|