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