@camstack/addon-pipeline 1.2.127 → 1.2.129
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-CKB-2oM9.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-HLAOw6Pe.mjs → dist-CMio-1qq.mjs} +47 -31
- package/dist/{dist-BZUXb5S5.js → dist-D9x2_csL.js} +47 -31
- package/dist/{event-loop-stall-monitor-BH-cL_hg.mjs → event-loop-stall-monitor-B9dxYxyf.mjs} +1 -1
- package/dist/{event-loop-stall-monitor-BdArVUlx.js → event-loop-stall-monitor-UW8nsiMb.js} +1 -1
- package/dist/{lazy-sharp-DTDXrhMG.js → lazy-sharp-DrLwLKpg.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-C6es8dMA.mjs} +1 -1
- package/dist/{process-memory-Dh1SyzdJ.js → process-memory-CSJ7-Wlp.js} +1 -1
- package/dist/recorder/index.js +121 -33
- package/dist/recorder/index.mjs +120 -32
- package/dist/{segment-demux-js-D-TLcHW5.mjs → segment-demux-js-BRCjXbI0.mjs} +1 -1
- package/dist/{segment-demux-js-DRutggd6.js → segment-demux-js-DiLDoV9_.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 +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BDR5z0Z9.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-1O99xPTr.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CxppPmXr.mjs +26 -0
- 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-8scvCWua.mjs} +2 -2
- package/dist/stream-broker/index.js +530 -154
- package/dist/stream-broker/index.mjs +530 -154
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-h7Smb_0i.mjs → worker-protocol-BHNWB9Ve.mjs} +1 -1
- package/dist/{worker-protocol-BxkCoVjw.js → worker-protocol-BmBYCioC.js} +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cs8bmrWP.mjs +0 -26
|
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-D9x2_csL.js");
|
|
6
6
|
const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
|
|
7
7
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
8
8
|
const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
|
|
9
|
-
const require_segment_demux_js = require("../segment-demux-js-
|
|
9
|
+
const require_segment_demux_js = require("../segment-demux-js-DiLDoV9_.js");
|
|
10
10
|
let node_crypto = require("node:crypto");
|
|
11
11
|
node_crypto = require_dist.__toESM(node_crypto, 1);
|
|
12
12
|
let node_child_process = require("node:child_process");
|
|
@@ -390,6 +390,83 @@ var STREAM_BROKER_RETIRED_ROOT_KEYS = [
|
|
|
390
390
|
}
|
|
391
391
|
];
|
|
392
392
|
//#endregion
|
|
393
|
+
//#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
|
|
394
|
+
/** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
|
|
395
|
+
function logBannerArgs(level) {
|
|
396
|
+
return [
|
|
397
|
+
"-hide_banner",
|
|
398
|
+
"-loglevel",
|
|
399
|
+
level
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/shared/ffmpeg-decode-args.ts
|
|
404
|
+
/**
|
|
405
|
+
* Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
|
|
406
|
+
*
|
|
407
|
+
* `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
|
|
408
|
+
* child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
|
|
409
|
+
* h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
|
|
410
|
+
* `qsv` first on Intel, so we re-rank its result through this table before
|
|
411
|
+
* building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
|
|
412
|
+
* trail for NVIDIA. A method not listed here keeps its incoming relative order,
|
|
413
|
+
* placed AFTER every ranked one.
|
|
414
|
+
*/
|
|
415
|
+
var DECODE_HWACCEL_RANK = [
|
|
416
|
+
"videotoolbox",
|
|
417
|
+
"vaapi",
|
|
418
|
+
"qsv",
|
|
419
|
+
"cuda",
|
|
420
|
+
"nvdec",
|
|
421
|
+
"d3d11va",
|
|
422
|
+
"dxva2",
|
|
423
|
+
"amf",
|
|
424
|
+
"vdpau",
|
|
425
|
+
"drm"
|
|
426
|
+
];
|
|
427
|
+
/** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
|
|
428
|
+
function decodeHwAccelRankIndex(method) {
|
|
429
|
+
const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
|
|
430
|
+
return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
|
|
434
|
+
* `'auto'` decode session, best-first. The session walks this chain on an
|
|
435
|
+
* early-exit-with-no-frames and only falls to software once it is exhausted.
|
|
436
|
+
*
|
|
437
|
+
* Strategy:
|
|
438
|
+
* - Seed candidates from the kernel-preferred order UNION the canonical rank
|
|
439
|
+
* UNION the build's supported list, so a method the kernel omitted but the
|
|
440
|
+
* build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
|
|
441
|
+
* still attempted.
|
|
442
|
+
* - When the build's supported-method list is known (non-empty), keep only
|
|
443
|
+
* methods it actually offers; on a probe miss (empty) keep just the kernel
|
|
444
|
+
* preferences (avoid blindly spawning every backend).
|
|
445
|
+
* - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
|
|
446
|
+
* `qsv`. A supported method not in the rank table keeps its position AFTER
|
|
447
|
+
* every ranked one (stable sort). Returns lowercased method names; empty ⇒
|
|
448
|
+
* pure software.
|
|
449
|
+
*/
|
|
450
|
+
function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
451
|
+
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
452
|
+
const hasSupportList = supported.size > 0;
|
|
453
|
+
const pool = hasSupportList ? [
|
|
454
|
+
...preferred,
|
|
455
|
+
...DECODE_HWACCEL_RANK,
|
|
456
|
+
...supportedMethods
|
|
457
|
+
] : [...preferred];
|
|
458
|
+
const seen = /* @__PURE__ */ new Set();
|
|
459
|
+
const candidates = [];
|
|
460
|
+
for (const raw of pool) {
|
|
461
|
+
const method = raw.toLowerCase();
|
|
462
|
+
if (seen.has(method)) continue;
|
|
463
|
+
seen.add(method);
|
|
464
|
+
if (hasSupportList && !supported.has(method)) continue;
|
|
465
|
+
candidates.push(method);
|
|
466
|
+
}
|
|
467
|
+
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
393
470
|
//#region src/stream-broker/battery-wake-on-play.ts
|
|
394
471
|
/**
|
|
395
472
|
* Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
|
|
@@ -592,83 +669,6 @@ async function wakeBatteryDeviceForPlay(deviceId, deps) {
|
|
|
592
669
|
throw new BatteryStreamUnavailableError(deviceId, outcome, wake.awoke ? "it acknowledged the wake but no stream had appeared yet — try again in a few seconds" : "it did not answer the wake in time — try again");
|
|
593
670
|
}
|
|
594
671
|
//#endregion
|
|
595
|
-
//#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
|
|
596
|
-
/** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
|
|
597
|
-
function logBannerArgs(level) {
|
|
598
|
-
return [
|
|
599
|
-
"-hide_banner",
|
|
600
|
-
"-loglevel",
|
|
601
|
-
level
|
|
602
|
-
];
|
|
603
|
-
}
|
|
604
|
-
//#endregion
|
|
605
|
-
//#region src/shared/ffmpeg-decode-args.ts
|
|
606
|
-
/**
|
|
607
|
-
* Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
|
|
608
|
-
*
|
|
609
|
-
* `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
|
|
610
|
-
* child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
|
|
611
|
-
* h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
|
|
612
|
-
* `qsv` first on Intel, so we re-rank its result through this table before
|
|
613
|
-
* building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
|
|
614
|
-
* trail for NVIDIA. A method not listed here keeps its incoming relative order,
|
|
615
|
-
* placed AFTER every ranked one.
|
|
616
|
-
*/
|
|
617
|
-
var DECODE_HWACCEL_RANK = [
|
|
618
|
-
"videotoolbox",
|
|
619
|
-
"vaapi",
|
|
620
|
-
"qsv",
|
|
621
|
-
"cuda",
|
|
622
|
-
"nvdec",
|
|
623
|
-
"d3d11va",
|
|
624
|
-
"dxva2",
|
|
625
|
-
"amf",
|
|
626
|
-
"vdpau",
|
|
627
|
-
"drm"
|
|
628
|
-
];
|
|
629
|
-
/** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
|
|
630
|
-
function decodeHwAccelRankIndex(method) {
|
|
631
|
-
const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
|
|
632
|
-
return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
|
|
633
|
-
}
|
|
634
|
-
/**
|
|
635
|
-
* Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
|
|
636
|
-
* `'auto'` decode session, best-first. The session walks this chain on an
|
|
637
|
-
* early-exit-with-no-frames and only falls to software once it is exhausted.
|
|
638
|
-
*
|
|
639
|
-
* Strategy:
|
|
640
|
-
* - Seed candidates from the kernel-preferred order UNION the canonical rank
|
|
641
|
-
* UNION the build's supported list, so a method the kernel omitted but the
|
|
642
|
-
* build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
|
|
643
|
-
* still attempted.
|
|
644
|
-
* - When the build's supported-method list is known (non-empty), keep only
|
|
645
|
-
* methods it actually offers; on a probe miss (empty) keep just the kernel
|
|
646
|
-
* preferences (avoid blindly spawning every backend).
|
|
647
|
-
* - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
|
|
648
|
-
* `qsv`. A supported method not in the rank table keeps its position AFTER
|
|
649
|
-
* every ranked one (stable sort). Returns lowercased method names; empty ⇒
|
|
650
|
-
* pure software.
|
|
651
|
-
*/
|
|
652
|
-
function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
653
|
-
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
654
|
-
const hasSupportList = supported.size > 0;
|
|
655
|
-
const pool = hasSupportList ? [
|
|
656
|
-
...preferred,
|
|
657
|
-
...DECODE_HWACCEL_RANK,
|
|
658
|
-
...supportedMethods
|
|
659
|
-
] : [...preferred];
|
|
660
|
-
const seen = /* @__PURE__ */ new Set();
|
|
661
|
-
const candidates = [];
|
|
662
|
-
for (const raw of pool) {
|
|
663
|
-
const method = raw.toLowerCase();
|
|
664
|
-
if (seen.has(method)) continue;
|
|
665
|
-
seen.add(method);
|
|
666
|
-
if (hasSupportList && !supported.has(method)) continue;
|
|
667
|
-
candidates.push(method);
|
|
668
|
-
}
|
|
669
|
-
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
670
|
-
}
|
|
671
|
-
//#endregion
|
|
672
672
|
//#region src/stream-broker/derived/cycle-check.ts
|
|
673
673
|
/**
|
|
674
674
|
* Would assigning `derivedCamStreamId` to `targetProfile` create a cycle?
|
|
@@ -16279,7 +16279,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16279
16279
|
}
|
|
16280
16280
|
let devices;
|
|
16281
16281
|
try {
|
|
16282
|
-
devices = await api.deviceManager.listAll.query({});
|
|
16282
|
+
devices = await api.deviceManager.listAll.query({ projection: "slim" });
|
|
16283
16283
|
} catch (err) {
|
|
16284
16284
|
this.logger.warn("profile-map prune skipped — deviceManager.listAll failed", { meta: {
|
|
16285
16285
|
persistedCount,
|
|
@@ -16604,7 +16604,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16604
16604
|
if (!this.api) return;
|
|
16605
16605
|
let devices;
|
|
16606
16606
|
try {
|
|
16607
|
-
devices = await this.api.deviceManager.listAll.query({});
|
|
16607
|
+
devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
|
|
16608
16608
|
} catch (err) {
|
|
16609
16609
|
this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", { meta: { error: require_dist.errMsg(err) } });
|
|
16610
16610
|
return;
|
|
@@ -16744,7 +16744,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16744
16744
|
if (!this.api) return false;
|
|
16745
16745
|
let devices;
|
|
16746
16746
|
try {
|
|
16747
|
-
devices = await this.api.deviceManager.listAll.query({});
|
|
16747
|
+
devices = await this.api.deviceManager.listAll.query({ projection: "slim" });
|
|
16748
16748
|
} catch {
|
|
16749
16749
|
return false;
|
|
16750
16750
|
}
|
|
@@ -23797,6 +23797,9 @@ var ScrubDragStats = class {
|
|
|
23797
23797
|
prefetchHits = 0;
|
|
23798
23798
|
prefetchSuppressed = 0;
|
|
23799
23799
|
ackTimeouts = 0;
|
|
23800
|
+
deadlineMisses = 0;
|
|
23801
|
+
retargets = 0;
|
|
23802
|
+
resolveJoins = 0;
|
|
23800
23803
|
failures = 0;
|
|
23801
23804
|
firstPushMs = null;
|
|
23802
23805
|
creditEndedAt = null;
|
|
@@ -23806,6 +23809,9 @@ var ScrubDragStats = class {
|
|
|
23806
23809
|
demuxMs = new PhaseSamples();
|
|
23807
23810
|
tickMs = new PhaseSamples();
|
|
23808
23811
|
ackMs = new PhaseSamples();
|
|
23812
|
+
leadMs = new PhaseSamples();
|
|
23813
|
+
oversizeSegments = 0;
|
|
23814
|
+
degradedProfile = null;
|
|
23809
23815
|
constructor(startedAtMs) {
|
|
23810
23816
|
this.startedAtMs = startedAtMs;
|
|
23811
23817
|
}
|
|
@@ -23860,6 +23866,30 @@ var ScrubDragStats = class {
|
|
|
23860
23866
|
recordAckTimeout(pushes) {
|
|
23861
23867
|
this.ackTimeouts += pushes;
|
|
23862
23868
|
}
|
|
23869
|
+
/** A frame abandoned on its deadline — dropped work, counted every time. */
|
|
23870
|
+
recordDeadlineMiss() {
|
|
23871
|
+
this.deadlineMisses += 1;
|
|
23872
|
+
}
|
|
23873
|
+
/** A landing re-aimed at the newer finger position it also covered. */
|
|
23874
|
+
recordRetarget() {
|
|
23875
|
+
this.retargets += 1;
|
|
23876
|
+
}
|
|
23877
|
+
/** A whole-segment read that had to degrade to a GOP range on size. */
|
|
23878
|
+
recordOversizeSegment() {
|
|
23879
|
+
this.oversizeSegments += 1;
|
|
23880
|
+
}
|
|
23881
|
+
/** This drag is not running on the scrub profile — every gate is off. */
|
|
23882
|
+
recordProfileDegraded(profile) {
|
|
23883
|
+
this.degradedProfile = profile;
|
|
23884
|
+
}
|
|
23885
|
+
/** One read aimed ahead of the finger, and by how much. */
|
|
23886
|
+
recordLead(leadMs) {
|
|
23887
|
+
this.leadMs.add(Math.abs(leadMs));
|
|
23888
|
+
}
|
|
23889
|
+
/** A tick served by the read another tick already had in flight. */
|
|
23890
|
+
recordResolveJoin() {
|
|
23891
|
+
this.resolveJoins += 1;
|
|
23892
|
+
}
|
|
23863
23893
|
/** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
|
|
23864
23894
|
recordFailure() {
|
|
23865
23895
|
this.failures += 1;
|
|
@@ -23875,6 +23905,7 @@ var ScrubDragStats = class {
|
|
|
23875
23905
|
const dmx = this.demuxMs.summary();
|
|
23876
23906
|
const tick = this.tickMs.summary();
|
|
23877
23907
|
const ack = this.ackMs.summary();
|
|
23908
|
+
const lead = this.leadMs.summary();
|
|
23878
23909
|
return {
|
|
23879
23910
|
ticks: this.ticks,
|
|
23880
23911
|
dragMs: Math.round(dragMs),
|
|
@@ -23914,6 +23945,13 @@ var ScrubDragStats = class {
|
|
|
23914
23945
|
prefetchHits: this.prefetchHits,
|
|
23915
23946
|
prefetchSuppressed: this.prefetchSuppressed,
|
|
23916
23947
|
ackTimeouts: this.ackTimeouts,
|
|
23948
|
+
deadlineMisses: this.deadlineMisses,
|
|
23949
|
+
retargets: this.retargets,
|
|
23950
|
+
resolveJoins: this.resolveJoins,
|
|
23951
|
+
leadMsP50: lead.p50Ms,
|
|
23952
|
+
leadMsMax: lead.maxMs,
|
|
23953
|
+
oversizeSegments: this.oversizeSegments,
|
|
23954
|
+
degradedProfile: this.degradedProfile,
|
|
23917
23955
|
failures: this.failures,
|
|
23918
23956
|
creditEndedAt: this.creditEndedAt,
|
|
23919
23957
|
acks: ack.n,
|
|
@@ -24068,11 +24106,29 @@ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
|
|
|
24068
24106
|
*/
|
|
24069
24107
|
var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
|
|
24070
24108
|
/**
|
|
24071
|
-
* Safety ceiling on
|
|
24072
|
-
*
|
|
24073
|
-
*
|
|
24074
|
-
*
|
|
24075
|
-
*
|
|
24109
|
+
* Safety ceiling on ONE scrub read, in bytes.
|
|
24110
|
+
*
|
|
24111
|
+
* `low` is ~230 KB per 10 s, so this is ~6× headroom for a camera whose
|
|
24112
|
+
* substream is unusually fat — but a segment past it (a mis-pinned `high` at
|
|
24113
|
+
* ~5 MB) falls back to the GOP range, because reading 5 MB to push one
|
|
24114
|
+
* keyframe is the trade this change exists to avoid, in the other direction.
|
|
24115
|
+
*
|
|
24116
|
+
* ⚠️ THIS NUMBER IS TIED TO THE SEGMENT DURATION AND THE TIE IS NOT ENFORCED.
|
|
24117
|
+
* It was tuned against a 10-second `low` segment. At the 60-second segments
|
|
24118
|
+
* the 2026-08-25 teardown proposes, a NATIVE `low` is ~1.4 MB (just inside,
|
|
24119
|
+
* and the whole-segment read gets six times better — one seek leaves a minute
|
|
24120
|
+
* of access units behind) while a DERIVED `low` is ~4.8 MB (outside, and every
|
|
24121
|
+
* scrub read silently degrades to a 3-seek GOP range that leaves nothing
|
|
24122
|
+
* behind). Same constant, opposite outcomes, no signal either way — which is
|
|
24123
|
+
* why the degradation is now COUNTED and LOGGED
|
|
24124
|
+
* (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
|
|
24125
|
+
* instead of being inferred from a falling hit rate.
|
|
24126
|
+
*
|
|
24127
|
+
* The real fix, when 60 s segments land, is a read unit that is a BYTE BUDGET
|
|
24128
|
+
* over the fragment's own keyframe table — "as many consecutive GOPs as fit in
|
|
24129
|
+
* N bytes" — which needs a provider method we do not have
|
|
24130
|
+
* (`readGopBytes` reads exactly one GOP). Until then this is an absolute cap
|
|
24131
|
+
* on a single read and the log is the guard.
|
|
24076
24132
|
*/
|
|
24077
24133
|
var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
|
|
24078
24134
|
/**
|
|
@@ -24133,6 +24189,115 @@ var SCRUB_SLOW_READ_MS = 600;
|
|
|
24133
24189
|
*/
|
|
24134
24190
|
var SCRUB_SWEEP_WINDOW_MS = 4e3;
|
|
24135
24191
|
/**
|
|
24192
|
+
* THE DEADLINE ON ONE SCRUB FRAME — and the reason it is not
|
|
24193
|
+
* {@link SEGMENT_TIMEOUT_MS}.
|
|
24194
|
+
*
|
|
24195
|
+
* A scrub tick used to be bounded only by the 8 s per-RPC timeout, against a
|
|
24196
|
+
* cold read measured at p50 531 ms, p95 1.4–2 s and a 9.2 s max (census
|
|
24197
|
+
* 2026-08-25). So the feeder WAITED. The frame it eventually pushed was for a
|
|
24198
|
+
* finger position from seconds earlier, and the gesture spent its whole
|
|
24199
|
+
* duration on a handful of stale frames — the operator's "quasi 0 frame, e
|
|
24200
|
+
* l'immagine insegue il dito".
|
|
24201
|
+
*
|
|
24202
|
+
* The reference design (Scrypted NVR, teardown 2026-08-25) spends at most
|
|
24203
|
+
* ~1000 ms on the WHOLE scrub animation and gives every single frame the
|
|
24204
|
+
* REMAINING window as its deadline: a frame that cannot be produced in time is
|
|
24205
|
+
* dropped, never awaited. This is the same contract on our constraints.
|
|
24206
|
+
*
|
|
24207
|
+
* 700 ms and not 1000: the deadline must be shorter than the gesture's own
|
|
24208
|
+
* animation window or it can never fire twice inside one, and it must be above
|
|
24209
|
+
* the cold-read p50 (531 ms) or the median frame would be dropped — a deadline
|
|
24210
|
+
* that drops the median makes the picture WORSE, not fresher. 700 ms sits
|
|
24211
|
+
* between: it serves the p50–p60 of cold reads and cuts the p90 tail that used
|
|
24212
|
+
* to eat the gesture.
|
|
24213
|
+
*
|
|
24214
|
+
* Dropping is never silent: `deadlineMisses` on the drag aggregate plus one
|
|
24215
|
+
* `scrub-feeder:deadline-miss` line per drag.
|
|
24216
|
+
*/
|
|
24217
|
+
var SCRUB_TICK_DEADLINE_MS = 700;
|
|
24218
|
+
/**
|
|
24219
|
+
* The FIRST frame of a drag gets a longer deadline, because nothing is painted
|
|
24220
|
+
* yet.
|
|
24221
|
+
*
|
|
24222
|
+
* Every later frame has a predecessor on screen, so dropping it costs
|
|
24223
|
+
* freshness. The first one has none: dropping it costs the whole picture, and
|
|
24224
|
+
* the operator reads a blank recorded view as "playback parte dopo una vita".
|
|
24225
|
+
* The reference design has the same asymmetry for the same reason — its
|
|
24226
|
+
* animation frames are deadline-dropped while the LANDING seek is unbounded.
|
|
24227
|
+
*
|
|
24228
|
+
* 2500 ms covers the cold-read p95 (1.4–2 s) and still cuts the 9.2 s tail.
|
|
24229
|
+
*/
|
|
24230
|
+
var SCRUB_FIRST_TICK_DEADLINE_MS = 2500;
|
|
24231
|
+
/**
|
|
24232
|
+
* How far a new target may be from an in-flight read's target and still RIDE
|
|
24233
|
+
* it instead of starting a second one.
|
|
24234
|
+
*
|
|
24235
|
+
* A `low` segment is ~10 s of timeline and the read unit is the whole segment
|
|
24236
|
+
* ({@link SCRUB_WHOLE_SEGMENT_PROFILE}), so a target within one segment of the
|
|
24237
|
+
* one being read is very likely already inside the bytes on their way. Riding
|
|
24238
|
+
* costs nothing and saves a random seek on the spindle that is the bottleneck;
|
|
24239
|
+
* being wrong costs one wasted deadline, which is now cheap by construction.
|
|
24240
|
+
*/
|
|
24241
|
+
var SCRUB_RIDE_WINDOW_MS = 1e4;
|
|
24242
|
+
/**
|
|
24243
|
+
* Hard bound on concurrent fragment resolutions for one drag.
|
|
24244
|
+
*
|
|
24245
|
+
* A deadline that drops frames is only safe if dropping does not multiply the
|
|
24246
|
+
* work: without this bound a drag over slow footage would abandon a read every
|
|
24247
|
+
* {@link SCRUB_TICK_DEADLINE_MS} and start another, turning one saturated
|
|
24248
|
+
* spindle into N. Two: the one that is landing, and the one the finger has
|
|
24249
|
+
* moved on to. At the cap, a tick rides the newest in-flight read instead.
|
|
24250
|
+
*/
|
|
24251
|
+
var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
|
|
24252
|
+
/**
|
|
24253
|
+
* READ-AHEAD LEAD — the fix for "the picture chases the finger".
|
|
24254
|
+
*
|
|
24255
|
+
* A scrub frame is always the frame a read STARTED for, one read time ago.
|
|
24256
|
+
* On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
|
|
24257
|
+
* a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
|
|
24258
|
+
* the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
|
|
24259
|
+
* behind the finger. Nothing about that is I/O — it is aiming at where the
|
|
24260
|
+
* finger WAS.
|
|
24261
|
+
*
|
|
24262
|
+
* So a steady drag reads where the finger WILL BE: `mediaVelocity ×
|
|
24263
|
+
* medianRecentReadMs`. It costs nothing (the same one read, at a different
|
|
24264
|
+
* offset), and it is bounded by the two things that make it a prediction
|
|
24265
|
+
* rather than a guess:
|
|
24266
|
+
*
|
|
24267
|
+
* - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
|
|
24268
|
+
* samples, so a jittery or reversing drag never leads. Pointer samples and
|
|
24269
|
+
* not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
|
|
24270
|
+
* ~4 pushes, so a gate counted in pushes has no runway inside the gesture
|
|
24271
|
+
* it is supposed to help — measured in the drag bench, it engaged once, at
|
|
24272
|
+
* the very end;
|
|
24273
|
+
* - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
|
|
24274
|
+
* is derived from this disk right now and not from a constant. ONE is
|
|
24275
|
+
* enough: against a read cost that is unknown, a one-sample estimate is not
|
|
24276
|
+
* a good prediction — it is simply a far better one than assuming zero.
|
|
24277
|
+
*
|
|
24278
|
+
* Overshoot is self-correcting: the retarget below serves the newest target
|
|
24279
|
+
* the landed fragment covers, so a lead that is slightly long lands on the
|
|
24280
|
+
* finger exactly, and one that is wrong costs the same single read it would
|
|
24281
|
+
* have cost anyway.
|
|
24282
|
+
*/
|
|
24283
|
+
var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
|
|
24284
|
+
/**
|
|
24285
|
+
* Smoothing for the media-velocity estimate (ms of timeline per ms of wall
|
|
24286
|
+
* clock), folded on every pointer sample. Half-and-half: fast enough to track
|
|
24287
|
+
* a fling that starts mid-gesture, slow enough that one jittery sample cannot
|
|
24288
|
+
* aim a read an hour away.
|
|
24289
|
+
*/
|
|
24290
|
+
var SCRUB_VELOCITY_EMA = .5;
|
|
24291
|
+
/** Same-direction POINTER samples required before a read may aim ahead. */
|
|
24292
|
+
var SCRUB_LEAD_MIN_DIR_RUN = 2;
|
|
24293
|
+
/**
|
|
24294
|
+
* Pointer-sample gaps outside this range do not update the velocity estimate:
|
|
24295
|
+
* below it the delta is dominated by timer jitter, above it the finger has
|
|
24296
|
+
* effectively paused and the "velocity" is an artefact of the pause.
|
|
24297
|
+
*/
|
|
24298
|
+
var SCRUB_VELOCITY_MIN_GAP_MS = 10;
|
|
24299
|
+
var SCRUB_VELOCITY_MAX_GAP_MS = 500;
|
|
24300
|
+
/**
|
|
24136
24301
|
* Receiver-credit cap for scrub pushes: at most this many PUSHES may be
|
|
24137
24302
|
* OUTSTANDING (on the track, not yet acknowledged by the viewer via
|
|
24138
24303
|
* `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
|
|
@@ -24389,6 +24554,28 @@ var RecordedFeeder = class {
|
|
|
24389
24554
|
scrubWarmGen = 0;
|
|
24390
24555
|
/** Tick demand-read in flight: the fill waits so the finger wins the spindle. */
|
|
24391
24556
|
scrubTickReadInFlight = false;
|
|
24557
|
+
/**
|
|
24558
|
+
* The newest fragment resolution in flight for this drag — what a later tick
|
|
24559
|
+
* rides instead of issuing its own read. Cleared by the resolution itself.
|
|
24560
|
+
*/
|
|
24561
|
+
scrubResolve = null;
|
|
24562
|
+
/** Concurrent resolutions, bounded by {@link SCRUB_RESOLVE_MAX_INFLIGHT}. */
|
|
24563
|
+
scrubResolvesInFlight = 0;
|
|
24564
|
+
/** One `deadline-miss` line per drag; the count lives on the aggregate. */
|
|
24565
|
+
scrubDeadlineLogged = false;
|
|
24566
|
+
/** One `oversize-segment` line per drag; the count lives on the aggregate. */
|
|
24567
|
+
scrubOversizeLogged = false;
|
|
24568
|
+
/** One `profile-degraded` line per drag — the `low` pin is lost. */
|
|
24569
|
+
scrubProfileLogged = false;
|
|
24570
|
+
/** Has this drag put a frame on the wire? Gates the first-frame deadline. */
|
|
24571
|
+
scrubPushedThisDrag = false;
|
|
24572
|
+
/** Smoothed media-ms per wall-ms of the drag, signed. Null until measurable. */
|
|
24573
|
+
scrubVelocity = null;
|
|
24574
|
+
/** Previous pointer sample, for the velocity estimate above. */
|
|
24575
|
+
scrubLastSampleMs = null;
|
|
24576
|
+
scrubLastSampleAtMs = 0;
|
|
24577
|
+
/** Signed run of consecutive same-direction POINTER samples. */
|
|
24578
|
+
scrubVelRun = 0;
|
|
24392
24579
|
/** One fill walker per drag — a fling retargets it, it does not spawn another. */
|
|
24393
24580
|
scrubFillLoopActive = false;
|
|
24394
24581
|
scrubWarmBackMs = SCRUB_WARM_BACK_MS;
|
|
@@ -24762,12 +24949,21 @@ var RecordedFeeder = class {
|
|
|
24762
24949
|
this.scrubCursor = null;
|
|
24763
24950
|
this.scrubNoFootageLogged = false;
|
|
24764
24951
|
this.scrubParkLogged = false;
|
|
24952
|
+
this.scrubDeadlineLogged = false;
|
|
24953
|
+
this.scrubOversizeLogged = false;
|
|
24954
|
+
this.scrubProfileLogged = false;
|
|
24955
|
+
this.scrubPushedThisDrag = false;
|
|
24956
|
+
this.scrubResolve = null;
|
|
24957
|
+
this.scrubVelocity = null;
|
|
24958
|
+
this.scrubLastSampleMs = null;
|
|
24959
|
+
this.scrubVelRun = 0;
|
|
24765
24960
|
this.scrubOutstanding = [];
|
|
24766
24961
|
this.clearScrubAckTimeout();
|
|
24767
24962
|
this.scrubAckTimeoutLogged = false;
|
|
24768
24963
|
this.scrubPendingHint = null;
|
|
24769
24964
|
this.scrubStartedAtMs = performance.now();
|
|
24770
24965
|
this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
|
|
24966
|
+
this.reportProfileDegradation();
|
|
24771
24967
|
this.scrubDemuxMark = this.deps.demuxTally?.counts() ?? null;
|
|
24772
24968
|
this.scrubLastPushLogAtMs = 0;
|
|
24773
24969
|
this.scrubLastSlowLocateLogAtMs = 0;
|
|
@@ -24982,6 +25178,7 @@ var RecordedFeeder = class {
|
|
|
24982
25178
|
*/
|
|
24983
25179
|
scrubTo(epochMs, hint) {
|
|
24984
25180
|
if (this.disposed || !this.scrubbing) return;
|
|
25181
|
+
this.noteScrubSample(epochMs);
|
|
24985
25182
|
this.scrubPendingEpochMs = epochMs;
|
|
24986
25183
|
this.scrubPendingHint = hint ?? null;
|
|
24987
25184
|
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
@@ -25107,7 +25304,7 @@ var RecordedFeeder = class {
|
|
|
25107
25304
|
return;
|
|
25108
25305
|
}
|
|
25109
25306
|
const target = this.scrubPendingEpochMs;
|
|
25110
|
-
|
|
25307
|
+
let hint = this.scrubPendingHint;
|
|
25111
25308
|
this.scrubPendingEpochMs = null;
|
|
25112
25309
|
this.scrubPendingHint = null;
|
|
25113
25310
|
if (target === null) {
|
|
@@ -25136,70 +25333,57 @@ var RecordedFeeder = class {
|
|
|
25136
25333
|
}
|
|
25137
25334
|
}
|
|
25138
25335
|
if (!seg) {
|
|
25139
|
-
|
|
25140
|
-
|
|
25141
|
-
|
|
25142
|
-
|
|
25143
|
-
|
|
25144
|
-
|
|
25145
|
-
|
|
25146
|
-
|
|
25147
|
-
|
|
25148
|
-
|
|
25149
|
-
|
|
25150
|
-
|
|
25151
|
-
|
|
25152
|
-
|
|
25153
|
-
|
|
25154
|
-
|
|
25155
|
-
|
|
25156
|
-
|
|
25157
|
-
|
|
25158
|
-
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
-
|
|
25162
|
-
|
|
25163
|
-
|
|
25164
|
-
|
|
25165
|
-
|
|
25166
|
-
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
profile: this.deps.profile,
|
|
25170
|
-
epochMs: decision.snapMs
|
|
25171
|
-
}));
|
|
25172
|
-
locateCalls += 1;
|
|
25173
|
-
locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
|
|
25174
|
-
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
25175
|
-
if (snapped.kind !== "segment") {
|
|
25176
|
-
if (!this.scrubNoFootageLogged) {
|
|
25177
|
-
this.scrubNoFootageLogged = true;
|
|
25178
|
-
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
25179
|
-
}
|
|
25180
|
-
return;
|
|
25181
|
-
}
|
|
25182
|
-
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
25183
|
-
requestedMs: target,
|
|
25184
|
-
snappedMs: decision.snapMs
|
|
25336
|
+
const cfg = this.deps.scrubDeadline;
|
|
25337
|
+
const rideWindowMs = cfg?.rideWindowMs ?? SCRUB_RIDE_WINDOW_MS;
|
|
25338
|
+
const inFlight = this.scrubResolve;
|
|
25339
|
+
let entry;
|
|
25340
|
+
if (inFlight !== null && (Math.abs(target - inFlight.target) <= rideWindowMs || this.scrubResolvesInFlight >= SCRUB_RESOLVE_MAX_INFLIGHT)) {
|
|
25341
|
+
entry = inFlight;
|
|
25342
|
+
this.scrubStats?.recordResolveJoin();
|
|
25343
|
+
} else {
|
|
25344
|
+
const leadMs = this.scrubLeadMs();
|
|
25345
|
+
if (leadMs !== 0) this.scrubStats?.recordLead(leadMs);
|
|
25346
|
+
const readTarget = target + leadMs;
|
|
25347
|
+
entry = {
|
|
25348
|
+
target: readTarget,
|
|
25349
|
+
promise: this.startScrubResolve(readTarget, gen)
|
|
25350
|
+
};
|
|
25351
|
+
this.scrubResolve = entry;
|
|
25352
|
+
}
|
|
25353
|
+
const deadlineMs = this.scrubPushedThisDrag ? cfg?.tickMs ?? SCRUB_TICK_DEADLINE_MS : cfg?.firstTickMs ?? SCRUB_FIRST_TICK_DEADLINE_MS;
|
|
25354
|
+
const res = await this.raceDeadline(entry.promise, performance.now() + deadlineMs);
|
|
25355
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
25356
|
+
if (res === "deadline") {
|
|
25357
|
+
this.scrubStats?.recordDeadlineMiss();
|
|
25358
|
+
if (this.scrubPendingEpochMs === null) this.scrubPendingEpochMs = target;
|
|
25359
|
+
if (!this.scrubDeadlineLogged) {
|
|
25360
|
+
this.scrubDeadlineLogged = true;
|
|
25361
|
+
this.deps.onLog?.("scrub-feeder:deadline-miss", {
|
|
25362
|
+
epochMs: target,
|
|
25363
|
+
deadlineMs,
|
|
25364
|
+
first: !this.scrubPushedThisDrag,
|
|
25365
|
+
inFlight: this.scrubResolvesInFlight
|
|
25185
25366
|
});
|
|
25186
|
-
loc = snapped;
|
|
25187
|
-
effTarget = decision.snapMs;
|
|
25188
25367
|
}
|
|
25189
|
-
|
|
25190
|
-
if (!seg) {
|
|
25191
|
-
const load = await this.loadScrubFragment(loc, effTarget);
|
|
25192
|
-
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
|
|
25193
|
-
seg = load.seg;
|
|
25194
|
-
readMs = load.readMs;
|
|
25195
|
-
readBytes = load.readBytes;
|
|
25196
|
-
demuxMs = load.demuxMs;
|
|
25197
|
-
wholeSegmentRead = load.wholeSegmentRead;
|
|
25198
|
-
this.scrubCache.put(this.deps.profile, seg);
|
|
25199
|
-
}
|
|
25200
|
-
} finally {
|
|
25201
|
-
this.scrubTickReadInFlight = false;
|
|
25368
|
+
return;
|
|
25202
25369
|
}
|
|
25370
|
+
if (res === null) return;
|
|
25371
|
+
seg = res.seg;
|
|
25372
|
+
const newest = this.scrubPendingEpochMs;
|
|
25373
|
+
if (res.effTarget !== res.requested) effTarget = res.effTarget;
|
|
25374
|
+
else if (newest !== null && fragmentCovers(seg, newest)) {
|
|
25375
|
+
effTarget = newest;
|
|
25376
|
+
hint = this.scrubPendingHint;
|
|
25377
|
+
this.scrubPendingEpochMs = null;
|
|
25378
|
+
this.scrubPendingHint = null;
|
|
25379
|
+
} else effTarget = newest ?? target;
|
|
25380
|
+
if (effTarget !== res.effTarget) this.scrubStats?.recordRetarget();
|
|
25381
|
+
locateCalls = res.locateCalls;
|
|
25382
|
+
locateMs = res.locateMs;
|
|
25383
|
+
readMs = res.readMs;
|
|
25384
|
+
readBytes = res.readBytes;
|
|
25385
|
+
demuxMs = res.demuxMs;
|
|
25386
|
+
wholeSegmentRead = res.wholeSegmentRead;
|
|
25203
25387
|
}
|
|
25204
25388
|
this.scrubSegment = seg;
|
|
25205
25389
|
const prevTarget = this.scrubPrevTarget;
|
|
@@ -25266,6 +25450,7 @@ var RecordedFeeder = class {
|
|
|
25266
25450
|
pushedAtMs
|
|
25267
25451
|
}];
|
|
25268
25452
|
this.scrubCredit -= 1;
|
|
25453
|
+
this.scrubPushedThisDrag = true;
|
|
25269
25454
|
this.armScrubAckTimeout();
|
|
25270
25455
|
this.clockHasAdvanced = true;
|
|
25271
25456
|
let runBytes = 0;
|
|
@@ -25327,6 +25512,107 @@ var RecordedFeeder = class {
|
|
|
25327
25512
|
}
|
|
25328
25513
|
}
|
|
25329
25514
|
/**
|
|
25515
|
+
* ONE fragment resolution for the drag: locate (with gap snapping), read,
|
|
25516
|
+
* demux, cache. Deliberately NOT bound to the tick that started it.
|
|
25517
|
+
*
|
|
25518
|
+
* The tick races this with the frame's deadline and may walk away; this
|
|
25519
|
+
* promise then finishes on its own and puts the fragment in the session
|
|
25520
|
+
* cache, because the read is already on the wire and abandoning its RESULT
|
|
25521
|
+
* as well would turn a dropped frame into wasted disk. That is what makes
|
|
25522
|
+
* the deadline cheap: the tick after a miss is usually a cache hit.
|
|
25523
|
+
*
|
|
25524
|
+
* Null ⇒ nothing to serve (dead zone, superseded generation, or a load the
|
|
25525
|
+
* seek token invalidated). Throws propagate to the tick's `tick-failed`.
|
|
25526
|
+
*/
|
|
25527
|
+
async startScrubResolve(target, gen) {
|
|
25528
|
+
this.scrubResolvesInFlight += 1;
|
|
25529
|
+
this.scrubTickReadInFlight = true;
|
|
25530
|
+
let locateCalls = 0;
|
|
25531
|
+
let locateMs = null;
|
|
25532
|
+
try {
|
|
25533
|
+
this.maybeRecenterScrubWarm(target);
|
|
25534
|
+
const locateStartMs = performance.now();
|
|
25535
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
25536
|
+
deviceId: this.deps.deviceId,
|
|
25537
|
+
profile: this.deps.profile,
|
|
25538
|
+
epochMs: target
|
|
25539
|
+
}));
|
|
25540
|
+
locateCalls += 1;
|
|
25541
|
+
locateMs = performance.now() - locateStartMs;
|
|
25542
|
+
this.maybeLogSlowLocate(target, locateMs);
|
|
25543
|
+
if (this.disposed || gen !== this.scrubToken) return null;
|
|
25544
|
+
let effTarget = target;
|
|
25545
|
+
if (loc.kind === "gap") {
|
|
25546
|
+
const decision = resolveSnapTarget(loc, target);
|
|
25547
|
+
if (decision.kind !== "snap") {
|
|
25548
|
+
if (!this.scrubNoFootageLogged) {
|
|
25549
|
+
this.scrubNoFootageLogged = true;
|
|
25550
|
+
if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
|
|
25551
|
+
epochMs: target,
|
|
25552
|
+
nearestMs: decision.snapMs,
|
|
25553
|
+
distanceMs: decision.distanceMs
|
|
25554
|
+
});
|
|
25555
|
+
else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
25556
|
+
}
|
|
25557
|
+
return null;
|
|
25558
|
+
}
|
|
25559
|
+
const snapStartMs = performance.now();
|
|
25560
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
25561
|
+
deviceId: this.deps.deviceId,
|
|
25562
|
+
profile: this.deps.profile,
|
|
25563
|
+
epochMs: decision.snapMs
|
|
25564
|
+
}));
|
|
25565
|
+
locateCalls += 1;
|
|
25566
|
+
locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
|
|
25567
|
+
if (this.disposed || gen !== this.scrubToken) return null;
|
|
25568
|
+
if (snapped.kind !== "segment") {
|
|
25569
|
+
if (!this.scrubNoFootageLogged) {
|
|
25570
|
+
this.scrubNoFootageLogged = true;
|
|
25571
|
+
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
25572
|
+
}
|
|
25573
|
+
return null;
|
|
25574
|
+
}
|
|
25575
|
+
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
25576
|
+
requestedMs: target,
|
|
25577
|
+
snappedMs: decision.snapMs
|
|
25578
|
+
});
|
|
25579
|
+
loc = snapped;
|
|
25580
|
+
effTarget = decision.snapMs;
|
|
25581
|
+
}
|
|
25582
|
+
const warm = this.scrubCache.covering(this.deps.profile, effTarget);
|
|
25583
|
+
if (warm) return {
|
|
25584
|
+
seg: warm,
|
|
25585
|
+
requested: target,
|
|
25586
|
+
effTarget,
|
|
25587
|
+
locateCalls,
|
|
25588
|
+
locateMs,
|
|
25589
|
+
readMs: null,
|
|
25590
|
+
readBytes: null,
|
|
25591
|
+
demuxMs: null,
|
|
25592
|
+
wholeSegmentRead: false
|
|
25593
|
+
};
|
|
25594
|
+
const load = await this.loadScrubFragment(loc, effTarget);
|
|
25595
|
+
if (this.disposed || load === null) return null;
|
|
25596
|
+
this.scrubCache.put(this.deps.profile, load.seg);
|
|
25597
|
+
if (gen !== this.scrubToken) return null;
|
|
25598
|
+
return {
|
|
25599
|
+
seg: load.seg,
|
|
25600
|
+
requested: target,
|
|
25601
|
+
effTarget,
|
|
25602
|
+
locateCalls,
|
|
25603
|
+
locateMs,
|
|
25604
|
+
readMs: load.readMs,
|
|
25605
|
+
readBytes: load.readBytes,
|
|
25606
|
+
demuxMs: load.demuxMs,
|
|
25607
|
+
wholeSegmentRead: load.wholeSegmentRead
|
|
25608
|
+
};
|
|
25609
|
+
} finally {
|
|
25610
|
+
this.scrubResolvesInFlight -= 1;
|
|
25611
|
+
if (this.scrubResolvesInFlight === 0) this.scrubTickReadInFlight = false;
|
|
25612
|
+
if (this.scrubResolve?.target === target) this.scrubResolve = null;
|
|
25613
|
+
}
|
|
25614
|
+
}
|
|
25615
|
+
/**
|
|
25330
25616
|
* Index of the nearest AU at-or-before the drag target within `seg`
|
|
25331
25617
|
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
25332
25618
|
* guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
|
|
@@ -25419,7 +25705,20 @@ var RecordedFeeder = class {
|
|
|
25419
25705
|
*/
|
|
25420
25706
|
readsWholeSegment(loc) {
|
|
25421
25707
|
if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
|
|
25422
|
-
|
|
25708
|
+
if (loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES) return true;
|
|
25709
|
+
this.scrubStats?.recordOversizeSegment();
|
|
25710
|
+
if (!this.scrubOversizeLogged) {
|
|
25711
|
+
this.scrubOversizeLogged = true;
|
|
25712
|
+
this.deps.onLog?.("scrub-feeder:oversize-segment", {
|
|
25713
|
+
profile: this.deps.profile,
|
|
25714
|
+
startMs: loc.startMs,
|
|
25715
|
+
durMs: loc.durMs,
|
|
25716
|
+
bytes: loc.bytes,
|
|
25717
|
+
maxBytes: SCRUB_WHOLE_SEGMENT_MAX_BYTES,
|
|
25718
|
+
bytesPerSecond: loc.durMs > 0 ? Math.round(loc.bytes / (loc.durMs / 1e3)) : null
|
|
25719
|
+
});
|
|
25720
|
+
}
|
|
25721
|
+
return false;
|
|
25423
25722
|
}
|
|
25424
25723
|
/**
|
|
25425
25724
|
* Fold one read into the recent-read window that governs speculation. The
|
|
@@ -25430,15 +25729,84 @@ var RecordedFeeder = class {
|
|
|
25430
25729
|
this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
|
|
25431
25730
|
}
|
|
25432
25731
|
/**
|
|
25433
|
-
*
|
|
25732
|
+
* A drag that is NOT on the scrub profile runs with every scrub optimisation
|
|
25733
|
+
* off: the whole-segment read unit, the 48 MB fragment-cache budget and the
|
|
25734
|
+
* entry warm are all gated on the exact string `low`
|
|
25735
|
+
* ({@link SCRUB_WHOLE_SEGMENT_PROFILE}). A momentary hole in `low` therefore
|
|
25736
|
+
* turns a drag into 3-seek GOP reads over ~5 MB `high` segments — measured at
|
|
25737
|
+
* 2 642 ms cold — and the client cannot repair it.
|
|
25738
|
+
*
|
|
25739
|
+
* It used to be indistinguishable from a slow disk. Now it says so, once per
|
|
25740
|
+
* drag, and the census carries the flag beside the numbers it explains.
|
|
25741
|
+
*/
|
|
25742
|
+
reportProfileDegradation() {
|
|
25743
|
+
if (this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) return;
|
|
25744
|
+
this.scrubStats?.recordProfileDegraded(this.deps.profile);
|
|
25745
|
+
if (this.scrubProfileLogged) return;
|
|
25746
|
+
this.scrubProfileLogged = true;
|
|
25747
|
+
this.deps.onLog?.("scrub-feeder:profile-degraded", {
|
|
25748
|
+
profile: this.deps.profile,
|
|
25749
|
+
scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
|
|
25750
|
+
});
|
|
25751
|
+
}
|
|
25752
|
+
/**
|
|
25753
|
+
* Median of the recent reads — what a read costs on this disk RIGHT NOW.
|
|
25754
|
+
* Null until there are enough samples to call it anything: a single cold
|
|
25755
|
+
* first read is not a median, and acting on it would aim a whole gesture.
|
|
25756
|
+
*/
|
|
25757
|
+
scrubReadMedianMs() {
|
|
25758
|
+
if (this.scrubReadMsWindow.length < SCRUB_LEAD_MIN_READ_SAMPLES) return null;
|
|
25759
|
+
const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
|
|
25760
|
+
return sorted[Math.floor(sorted.length / 2)] ?? null;
|
|
25761
|
+
}
|
|
25762
|
+
/**
|
|
25763
|
+
* Is the disk the problem right now? The recent-read median against
|
|
25434
25764
|
* {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
|
|
25435
25765
|
* noise and the decision must not swing on it; and never before a few
|
|
25436
25766
|
* samples, or a cold first read would suppress the whole drag.
|
|
25437
25767
|
*/
|
|
25438
25768
|
scrubReadsAreSlow() {
|
|
25439
|
-
|
|
25440
|
-
|
|
25441
|
-
return
|
|
25769
|
+
const median = this.scrubReadMedianMs();
|
|
25770
|
+
if (median === null) return false;
|
|
25771
|
+
return median >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
|
|
25772
|
+
}
|
|
25773
|
+
/**
|
|
25774
|
+
* Fold one pointer sample into the drag's media velocity (ms of timeline per
|
|
25775
|
+
* ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
|
|
25776
|
+
*/
|
|
25777
|
+
noteScrubSample(epochMs) {
|
|
25778
|
+
const now = performance.now();
|
|
25779
|
+
const prev = this.scrubLastSampleMs;
|
|
25780
|
+
const prevAt = this.scrubLastSampleAtMs;
|
|
25781
|
+
this.scrubLastSampleMs = epochMs;
|
|
25782
|
+
this.scrubLastSampleAtMs = now;
|
|
25783
|
+
if (prev === null) return;
|
|
25784
|
+
const gapMs = now - prevAt;
|
|
25785
|
+
if (gapMs < SCRUB_VELOCITY_MIN_GAP_MS || gapMs > SCRUB_VELOCITY_MAX_GAP_MS) return;
|
|
25786
|
+
const deltaMs = epochMs - prev;
|
|
25787
|
+
const dir = Math.sign(deltaMs);
|
|
25788
|
+
if (dir === 0) {
|
|
25789
|
+
this.scrubVelRun = 0;
|
|
25790
|
+
return;
|
|
25791
|
+
}
|
|
25792
|
+
this.scrubVelRun = Math.sign(this.scrubVelRun) === dir ? this.scrubVelRun + dir : dir;
|
|
25793
|
+
const v = deltaMs / gapMs;
|
|
25794
|
+
this.scrubVelocity = this.scrubVelocity === null ? v : this.scrubVelocity * (1 - SCRUB_VELOCITY_EMA) + v * SCRUB_VELOCITY_EMA;
|
|
25795
|
+
}
|
|
25796
|
+
/**
|
|
25797
|
+
* How far AHEAD of the finger this read should aim — see the
|
|
25798
|
+
* {@link SCRUB_LEAD_MIN_READ_SAMPLES} docblock for why this exists and what
|
|
25799
|
+
* bounds it. Zero whenever the prediction is not earned: no measured reads,
|
|
25800
|
+
* no sustained direction, or a velocity whose sign disagrees with the run.
|
|
25801
|
+
*/
|
|
25802
|
+
scrubLeadMs() {
|
|
25803
|
+
const velocity = this.scrubVelocity;
|
|
25804
|
+
if (velocity === null) return 0;
|
|
25805
|
+
if (Math.abs(this.scrubVelRun) < SCRUB_LEAD_MIN_DIR_RUN) return 0;
|
|
25806
|
+
if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
|
|
25807
|
+
const readMs = this.scrubReadMedianMs();
|
|
25808
|
+
if (readMs === null) return 0;
|
|
25809
|
+
return Math.round(velocity * readMs);
|
|
25442
25810
|
}
|
|
25443
25811
|
/**
|
|
25444
25812
|
* A single slow `locate` gets its own line, throttled per drag. Reserved for
|
|
@@ -26163,6 +26531,14 @@ var RecordedFeeder = class {
|
|
|
26163
26531
|
this.scrubCursor = null;
|
|
26164
26532
|
this.scrubNoFootageLogged = false;
|
|
26165
26533
|
this.scrubParkLogged = false;
|
|
26534
|
+
this.scrubDeadlineLogged = false;
|
|
26535
|
+
this.scrubOversizeLogged = false;
|
|
26536
|
+
this.scrubProfileLogged = false;
|
|
26537
|
+
this.scrubPushedThisDrag = false;
|
|
26538
|
+
this.scrubResolve = null;
|
|
26539
|
+
this.scrubVelocity = null;
|
|
26540
|
+
this.scrubLastSampleMs = null;
|
|
26541
|
+
this.scrubVelRun = 0;
|
|
26166
26542
|
this.scrubOutstanding = [];
|
|
26167
26543
|
this.clearScrubAckTimeout();
|
|
26168
26544
|
this.scrubPendingHint = null;
|