@camstack/addon-pipeline 1.2.128 → 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 +2 -2
- package/dist/recorder/index.mjs +1 -1
- 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
|
@@ -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-CMio-1qq.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-BRCjXbI0.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
|
}
|
|
@@ -23792,6 +23792,9 @@ var ScrubDragStats = class {
|
|
|
23792
23792
|
prefetchHits = 0;
|
|
23793
23793
|
prefetchSuppressed = 0;
|
|
23794
23794
|
ackTimeouts = 0;
|
|
23795
|
+
deadlineMisses = 0;
|
|
23796
|
+
retargets = 0;
|
|
23797
|
+
resolveJoins = 0;
|
|
23795
23798
|
failures = 0;
|
|
23796
23799
|
firstPushMs = null;
|
|
23797
23800
|
creditEndedAt = null;
|
|
@@ -23801,6 +23804,9 @@ var ScrubDragStats = class {
|
|
|
23801
23804
|
demuxMs = new PhaseSamples();
|
|
23802
23805
|
tickMs = new PhaseSamples();
|
|
23803
23806
|
ackMs = new PhaseSamples();
|
|
23807
|
+
leadMs = new PhaseSamples();
|
|
23808
|
+
oversizeSegments = 0;
|
|
23809
|
+
degradedProfile = null;
|
|
23804
23810
|
constructor(startedAtMs) {
|
|
23805
23811
|
this.startedAtMs = startedAtMs;
|
|
23806
23812
|
}
|
|
@@ -23855,6 +23861,30 @@ var ScrubDragStats = class {
|
|
|
23855
23861
|
recordAckTimeout(pushes) {
|
|
23856
23862
|
this.ackTimeouts += pushes;
|
|
23857
23863
|
}
|
|
23864
|
+
/** A frame abandoned on its deadline — dropped work, counted every time. */
|
|
23865
|
+
recordDeadlineMiss() {
|
|
23866
|
+
this.deadlineMisses += 1;
|
|
23867
|
+
}
|
|
23868
|
+
/** A landing re-aimed at the newer finger position it also covered. */
|
|
23869
|
+
recordRetarget() {
|
|
23870
|
+
this.retargets += 1;
|
|
23871
|
+
}
|
|
23872
|
+
/** A whole-segment read that had to degrade to a GOP range on size. */
|
|
23873
|
+
recordOversizeSegment() {
|
|
23874
|
+
this.oversizeSegments += 1;
|
|
23875
|
+
}
|
|
23876
|
+
/** This drag is not running on the scrub profile — every gate is off. */
|
|
23877
|
+
recordProfileDegraded(profile) {
|
|
23878
|
+
this.degradedProfile = profile;
|
|
23879
|
+
}
|
|
23880
|
+
/** One read aimed ahead of the finger, and by how much. */
|
|
23881
|
+
recordLead(leadMs) {
|
|
23882
|
+
this.leadMs.add(Math.abs(leadMs));
|
|
23883
|
+
}
|
|
23884
|
+
/** A tick served by the read another tick already had in flight. */
|
|
23885
|
+
recordResolveJoin() {
|
|
23886
|
+
this.resolveJoins += 1;
|
|
23887
|
+
}
|
|
23858
23888
|
/** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
|
|
23859
23889
|
recordFailure() {
|
|
23860
23890
|
this.failures += 1;
|
|
@@ -23870,6 +23900,7 @@ var ScrubDragStats = class {
|
|
|
23870
23900
|
const dmx = this.demuxMs.summary();
|
|
23871
23901
|
const tick = this.tickMs.summary();
|
|
23872
23902
|
const ack = this.ackMs.summary();
|
|
23903
|
+
const lead = this.leadMs.summary();
|
|
23873
23904
|
return {
|
|
23874
23905
|
ticks: this.ticks,
|
|
23875
23906
|
dragMs: Math.round(dragMs),
|
|
@@ -23909,6 +23940,13 @@ var ScrubDragStats = class {
|
|
|
23909
23940
|
prefetchHits: this.prefetchHits,
|
|
23910
23941
|
prefetchSuppressed: this.prefetchSuppressed,
|
|
23911
23942
|
ackTimeouts: this.ackTimeouts,
|
|
23943
|
+
deadlineMisses: this.deadlineMisses,
|
|
23944
|
+
retargets: this.retargets,
|
|
23945
|
+
resolveJoins: this.resolveJoins,
|
|
23946
|
+
leadMsP50: lead.p50Ms,
|
|
23947
|
+
leadMsMax: lead.maxMs,
|
|
23948
|
+
oversizeSegments: this.oversizeSegments,
|
|
23949
|
+
degradedProfile: this.degradedProfile,
|
|
23912
23950
|
failures: this.failures,
|
|
23913
23951
|
creditEndedAt: this.creditEndedAt,
|
|
23914
23952
|
acks: ack.n,
|
|
@@ -24063,11 +24101,29 @@ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
|
|
|
24063
24101
|
*/
|
|
24064
24102
|
var SCRUB_WHOLE_SEGMENT_PROFILE = "low";
|
|
24065
24103
|
/**
|
|
24066
|
-
* Safety ceiling on
|
|
24067
|
-
*
|
|
24068
|
-
*
|
|
24069
|
-
*
|
|
24070
|
-
*
|
|
24104
|
+
* Safety ceiling on ONE scrub read, in bytes.
|
|
24105
|
+
*
|
|
24106
|
+
* `low` is ~230 KB per 10 s, so this is ~6× headroom for a camera whose
|
|
24107
|
+
* substream is unusually fat — but a segment past it (a mis-pinned `high` at
|
|
24108
|
+
* ~5 MB) falls back to the GOP range, because reading 5 MB to push one
|
|
24109
|
+
* keyframe is the trade this change exists to avoid, in the other direction.
|
|
24110
|
+
*
|
|
24111
|
+
* ⚠️ THIS NUMBER IS TIED TO THE SEGMENT DURATION AND THE TIE IS NOT ENFORCED.
|
|
24112
|
+
* It was tuned against a 10-second `low` segment. At the 60-second segments
|
|
24113
|
+
* the 2026-08-25 teardown proposes, a NATIVE `low` is ~1.4 MB (just inside,
|
|
24114
|
+
* and the whole-segment read gets six times better — one seek leaves a minute
|
|
24115
|
+
* of access units behind) while a DERIVED `low` is ~4.8 MB (outside, and every
|
|
24116
|
+
* scrub read silently degrades to a 3-seek GOP range that leaves nothing
|
|
24117
|
+
* behind). Same constant, opposite outcomes, no signal either way — which is
|
|
24118
|
+
* why the degradation is now COUNTED and LOGGED
|
|
24119
|
+
* (`scrub-feeder:oversize-segment`, `oversizeSegments` on the drag census)
|
|
24120
|
+
* instead of being inferred from a falling hit rate.
|
|
24121
|
+
*
|
|
24122
|
+
* The real fix, when 60 s segments land, is a read unit that is a BYTE BUDGET
|
|
24123
|
+
* over the fragment's own keyframe table — "as many consecutive GOPs as fit in
|
|
24124
|
+
* N bytes" — which needs a provider method we do not have
|
|
24125
|
+
* (`readGopBytes` reads exactly one GOP). Until then this is an absolute cap
|
|
24126
|
+
* on a single read and the log is the guard.
|
|
24071
24127
|
*/
|
|
24072
24128
|
var SCRUB_WHOLE_SEGMENT_MAX_BYTES = 15e5;
|
|
24073
24129
|
/**
|
|
@@ -24128,6 +24184,115 @@ var SCRUB_SLOW_READ_MS = 600;
|
|
|
24128
24184
|
*/
|
|
24129
24185
|
var SCRUB_SWEEP_WINDOW_MS = 4e3;
|
|
24130
24186
|
/**
|
|
24187
|
+
* THE DEADLINE ON ONE SCRUB FRAME — and the reason it is not
|
|
24188
|
+
* {@link SEGMENT_TIMEOUT_MS}.
|
|
24189
|
+
*
|
|
24190
|
+
* A scrub tick used to be bounded only by the 8 s per-RPC timeout, against a
|
|
24191
|
+
* cold read measured at p50 531 ms, p95 1.4–2 s and a 9.2 s max (census
|
|
24192
|
+
* 2026-08-25). So the feeder WAITED. The frame it eventually pushed was for a
|
|
24193
|
+
* finger position from seconds earlier, and the gesture spent its whole
|
|
24194
|
+
* duration on a handful of stale frames — the operator's "quasi 0 frame, e
|
|
24195
|
+
* l'immagine insegue il dito".
|
|
24196
|
+
*
|
|
24197
|
+
* The reference design (Scrypted NVR, teardown 2026-08-25) spends at most
|
|
24198
|
+
* ~1000 ms on the WHOLE scrub animation and gives every single frame the
|
|
24199
|
+
* REMAINING window as its deadline: a frame that cannot be produced in time is
|
|
24200
|
+
* dropped, never awaited. This is the same contract on our constraints.
|
|
24201
|
+
*
|
|
24202
|
+
* 700 ms and not 1000: the deadline must be shorter than the gesture's own
|
|
24203
|
+
* animation window or it can never fire twice inside one, and it must be above
|
|
24204
|
+
* the cold-read p50 (531 ms) or the median frame would be dropped — a deadline
|
|
24205
|
+
* that drops the median makes the picture WORSE, not fresher. 700 ms sits
|
|
24206
|
+
* between: it serves the p50–p60 of cold reads and cuts the p90 tail that used
|
|
24207
|
+
* to eat the gesture.
|
|
24208
|
+
*
|
|
24209
|
+
* Dropping is never silent: `deadlineMisses` on the drag aggregate plus one
|
|
24210
|
+
* `scrub-feeder:deadline-miss` line per drag.
|
|
24211
|
+
*/
|
|
24212
|
+
var SCRUB_TICK_DEADLINE_MS = 700;
|
|
24213
|
+
/**
|
|
24214
|
+
* The FIRST frame of a drag gets a longer deadline, because nothing is painted
|
|
24215
|
+
* yet.
|
|
24216
|
+
*
|
|
24217
|
+
* Every later frame has a predecessor on screen, so dropping it costs
|
|
24218
|
+
* freshness. The first one has none: dropping it costs the whole picture, and
|
|
24219
|
+
* the operator reads a blank recorded view as "playback parte dopo una vita".
|
|
24220
|
+
* The reference design has the same asymmetry for the same reason — its
|
|
24221
|
+
* animation frames are deadline-dropped while the LANDING seek is unbounded.
|
|
24222
|
+
*
|
|
24223
|
+
* 2500 ms covers the cold-read p95 (1.4–2 s) and still cuts the 9.2 s tail.
|
|
24224
|
+
*/
|
|
24225
|
+
var SCRUB_FIRST_TICK_DEADLINE_MS = 2500;
|
|
24226
|
+
/**
|
|
24227
|
+
* How far a new target may be from an in-flight read's target and still RIDE
|
|
24228
|
+
* it instead of starting a second one.
|
|
24229
|
+
*
|
|
24230
|
+
* A `low` segment is ~10 s of timeline and the read unit is the whole segment
|
|
24231
|
+
* ({@link SCRUB_WHOLE_SEGMENT_PROFILE}), so a target within one segment of the
|
|
24232
|
+
* one being read is very likely already inside the bytes on their way. Riding
|
|
24233
|
+
* costs nothing and saves a random seek on the spindle that is the bottleneck;
|
|
24234
|
+
* being wrong costs one wasted deadline, which is now cheap by construction.
|
|
24235
|
+
*/
|
|
24236
|
+
var SCRUB_RIDE_WINDOW_MS = 1e4;
|
|
24237
|
+
/**
|
|
24238
|
+
* Hard bound on concurrent fragment resolutions for one drag.
|
|
24239
|
+
*
|
|
24240
|
+
* A deadline that drops frames is only safe if dropping does not multiply the
|
|
24241
|
+
* work: without this bound a drag over slow footage would abandon a read every
|
|
24242
|
+
* {@link SCRUB_TICK_DEADLINE_MS} and start another, turning one saturated
|
|
24243
|
+
* spindle into N. Two: the one that is landing, and the one the finger has
|
|
24244
|
+
* moved on to. At the cap, a tick rides the newest in-flight read instead.
|
|
24245
|
+
*/
|
|
24246
|
+
var SCRUB_RESOLVE_MAX_INFLIGHT = 2;
|
|
24247
|
+
/**
|
|
24248
|
+
* READ-AHEAD LEAD — the fix for "the picture chases the finger".
|
|
24249
|
+
*
|
|
24250
|
+
* A scrub frame is always the frame a read STARTED for, one read time ago.
|
|
24251
|
+
* On this disk that is 531 ms of wall clock (p50, census 2026-08-25), and on
|
|
24252
|
+
* a wide window 531 ms of gesture is TWENTY MINUTES of timeline: measured in
|
|
24253
|
+
* the drag bench, the median painted frame on a 2 h fling was 1 220 000 ms
|
|
24254
|
+
* behind the finger. Nothing about that is I/O — it is aiming at where the
|
|
24255
|
+
* finger WAS.
|
|
24256
|
+
*
|
|
24257
|
+
* So a steady drag reads where the finger WILL BE: `mediaVelocity ×
|
|
24258
|
+
* medianRecentReadMs`. It costs nothing (the same one read, at a different
|
|
24259
|
+
* offset), and it is bounded by the two things that make it a prediction
|
|
24260
|
+
* rather than a guess:
|
|
24261
|
+
*
|
|
24262
|
+
* - a direction RUN of at least {@link SCRUB_LEAD_MIN_DIR_RUN} POINTER
|
|
24263
|
+
* samples, so a jittery or reversing drag never leads. Pointer samples and
|
|
24264
|
+
* not pushes: on this disk a 3 s drag produces ~75 pointer samples and only
|
|
24265
|
+
* ~4 pushes, so a gate counted in pushes has no runway inside the gesture
|
|
24266
|
+
* it is supposed to help — measured in the drag bench, it engaged once, at
|
|
24267
|
+
* the very end;
|
|
24268
|
+
* - at least {@link SCRUB_LEAD_MIN_READ_SAMPLES} measured read, so the lead
|
|
24269
|
+
* is derived from this disk right now and not from a constant. ONE is
|
|
24270
|
+
* enough: against a read cost that is unknown, a one-sample estimate is not
|
|
24271
|
+
* a good prediction — it is simply a far better one than assuming zero.
|
|
24272
|
+
*
|
|
24273
|
+
* Overshoot is self-correcting: the retarget below serves the newest target
|
|
24274
|
+
* the landed fragment covers, so a lead that is slightly long lands on the
|
|
24275
|
+
* finger exactly, and one that is wrong costs the same single read it would
|
|
24276
|
+
* have cost anyway.
|
|
24277
|
+
*/
|
|
24278
|
+
var SCRUB_LEAD_MIN_READ_SAMPLES = 1;
|
|
24279
|
+
/**
|
|
24280
|
+
* Smoothing for the media-velocity estimate (ms of timeline per ms of wall
|
|
24281
|
+
* clock), folded on every pointer sample. Half-and-half: fast enough to track
|
|
24282
|
+
* a fling that starts mid-gesture, slow enough that one jittery sample cannot
|
|
24283
|
+
* aim a read an hour away.
|
|
24284
|
+
*/
|
|
24285
|
+
var SCRUB_VELOCITY_EMA = .5;
|
|
24286
|
+
/** Same-direction POINTER samples required before a read may aim ahead. */
|
|
24287
|
+
var SCRUB_LEAD_MIN_DIR_RUN = 2;
|
|
24288
|
+
/**
|
|
24289
|
+
* Pointer-sample gaps outside this range do not update the velocity estimate:
|
|
24290
|
+
* below it the delta is dominated by timer jitter, above it the finger has
|
|
24291
|
+
* effectively paused and the "velocity" is an artefact of the pause.
|
|
24292
|
+
*/
|
|
24293
|
+
var SCRUB_VELOCITY_MIN_GAP_MS = 10;
|
|
24294
|
+
var SCRUB_VELOCITY_MAX_GAP_MS = 500;
|
|
24295
|
+
/**
|
|
24131
24296
|
* Receiver-credit cap for scrub pushes: at most this many PUSHES may be
|
|
24132
24297
|
* OUTSTANDING (on the track, not yet acknowledged by the viewer via
|
|
24133
24298
|
* `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
|
|
@@ -24384,6 +24549,28 @@ var RecordedFeeder = class {
|
|
|
24384
24549
|
scrubWarmGen = 0;
|
|
24385
24550
|
/** Tick demand-read in flight: the fill waits so the finger wins the spindle. */
|
|
24386
24551
|
scrubTickReadInFlight = false;
|
|
24552
|
+
/**
|
|
24553
|
+
* The newest fragment resolution in flight for this drag — what a later tick
|
|
24554
|
+
* rides instead of issuing its own read. Cleared by the resolution itself.
|
|
24555
|
+
*/
|
|
24556
|
+
scrubResolve = null;
|
|
24557
|
+
/** Concurrent resolutions, bounded by {@link SCRUB_RESOLVE_MAX_INFLIGHT}. */
|
|
24558
|
+
scrubResolvesInFlight = 0;
|
|
24559
|
+
/** One `deadline-miss` line per drag; the count lives on the aggregate. */
|
|
24560
|
+
scrubDeadlineLogged = false;
|
|
24561
|
+
/** One `oversize-segment` line per drag; the count lives on the aggregate. */
|
|
24562
|
+
scrubOversizeLogged = false;
|
|
24563
|
+
/** One `profile-degraded` line per drag — the `low` pin is lost. */
|
|
24564
|
+
scrubProfileLogged = false;
|
|
24565
|
+
/** Has this drag put a frame on the wire? Gates the first-frame deadline. */
|
|
24566
|
+
scrubPushedThisDrag = false;
|
|
24567
|
+
/** Smoothed media-ms per wall-ms of the drag, signed. Null until measurable. */
|
|
24568
|
+
scrubVelocity = null;
|
|
24569
|
+
/** Previous pointer sample, for the velocity estimate above. */
|
|
24570
|
+
scrubLastSampleMs = null;
|
|
24571
|
+
scrubLastSampleAtMs = 0;
|
|
24572
|
+
/** Signed run of consecutive same-direction POINTER samples. */
|
|
24573
|
+
scrubVelRun = 0;
|
|
24387
24574
|
/** One fill walker per drag — a fling retargets it, it does not spawn another. */
|
|
24388
24575
|
scrubFillLoopActive = false;
|
|
24389
24576
|
scrubWarmBackMs = SCRUB_WARM_BACK_MS;
|
|
@@ -24757,12 +24944,21 @@ var RecordedFeeder = class {
|
|
|
24757
24944
|
this.scrubCursor = null;
|
|
24758
24945
|
this.scrubNoFootageLogged = false;
|
|
24759
24946
|
this.scrubParkLogged = false;
|
|
24947
|
+
this.scrubDeadlineLogged = false;
|
|
24948
|
+
this.scrubOversizeLogged = false;
|
|
24949
|
+
this.scrubProfileLogged = false;
|
|
24950
|
+
this.scrubPushedThisDrag = false;
|
|
24951
|
+
this.scrubResolve = null;
|
|
24952
|
+
this.scrubVelocity = null;
|
|
24953
|
+
this.scrubLastSampleMs = null;
|
|
24954
|
+
this.scrubVelRun = 0;
|
|
24760
24955
|
this.scrubOutstanding = [];
|
|
24761
24956
|
this.clearScrubAckTimeout();
|
|
24762
24957
|
this.scrubAckTimeoutLogged = false;
|
|
24763
24958
|
this.scrubPendingHint = null;
|
|
24764
24959
|
this.scrubStartedAtMs = performance.now();
|
|
24765
24960
|
this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
|
|
24961
|
+
this.reportProfileDegradation();
|
|
24766
24962
|
this.scrubDemuxMark = this.deps.demuxTally?.counts() ?? null;
|
|
24767
24963
|
this.scrubLastPushLogAtMs = 0;
|
|
24768
24964
|
this.scrubLastSlowLocateLogAtMs = 0;
|
|
@@ -24977,6 +25173,7 @@ var RecordedFeeder = class {
|
|
|
24977
25173
|
*/
|
|
24978
25174
|
scrubTo(epochMs, hint) {
|
|
24979
25175
|
if (this.disposed || !this.scrubbing) return;
|
|
25176
|
+
this.noteScrubSample(epochMs);
|
|
24980
25177
|
this.scrubPendingEpochMs = epochMs;
|
|
24981
25178
|
this.scrubPendingHint = hint ?? null;
|
|
24982
25179
|
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
@@ -25102,7 +25299,7 @@ var RecordedFeeder = class {
|
|
|
25102
25299
|
return;
|
|
25103
25300
|
}
|
|
25104
25301
|
const target = this.scrubPendingEpochMs;
|
|
25105
|
-
|
|
25302
|
+
let hint = this.scrubPendingHint;
|
|
25106
25303
|
this.scrubPendingEpochMs = null;
|
|
25107
25304
|
this.scrubPendingHint = null;
|
|
25108
25305
|
if (target === null) {
|
|
@@ -25131,70 +25328,57 @@ var RecordedFeeder = class {
|
|
|
25131
25328
|
}
|
|
25132
25329
|
}
|
|
25133
25330
|
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
|
-
profile: this.deps.profile,
|
|
25165
|
-
epochMs: decision.snapMs
|
|
25166
|
-
}));
|
|
25167
|
-
locateCalls += 1;
|
|
25168
|
-
locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
|
|
25169
|
-
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
25170
|
-
if (snapped.kind !== "segment") {
|
|
25171
|
-
if (!this.scrubNoFootageLogged) {
|
|
25172
|
-
this.scrubNoFootageLogged = true;
|
|
25173
|
-
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
25174
|
-
}
|
|
25175
|
-
return;
|
|
25176
|
-
}
|
|
25177
|
-
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
25178
|
-
requestedMs: target,
|
|
25179
|
-
snappedMs: decision.snapMs
|
|
25331
|
+
const cfg = this.deps.scrubDeadline;
|
|
25332
|
+
const rideWindowMs = cfg?.rideWindowMs ?? SCRUB_RIDE_WINDOW_MS;
|
|
25333
|
+
const inFlight = this.scrubResolve;
|
|
25334
|
+
let entry;
|
|
25335
|
+
if (inFlight !== null && (Math.abs(target - inFlight.target) <= rideWindowMs || this.scrubResolvesInFlight >= SCRUB_RESOLVE_MAX_INFLIGHT)) {
|
|
25336
|
+
entry = inFlight;
|
|
25337
|
+
this.scrubStats?.recordResolveJoin();
|
|
25338
|
+
} else {
|
|
25339
|
+
const leadMs = this.scrubLeadMs();
|
|
25340
|
+
if (leadMs !== 0) this.scrubStats?.recordLead(leadMs);
|
|
25341
|
+
const readTarget = target + leadMs;
|
|
25342
|
+
entry = {
|
|
25343
|
+
target: readTarget,
|
|
25344
|
+
promise: this.startScrubResolve(readTarget, gen)
|
|
25345
|
+
};
|
|
25346
|
+
this.scrubResolve = entry;
|
|
25347
|
+
}
|
|
25348
|
+
const deadlineMs = this.scrubPushedThisDrag ? cfg?.tickMs ?? SCRUB_TICK_DEADLINE_MS : cfg?.firstTickMs ?? SCRUB_FIRST_TICK_DEADLINE_MS;
|
|
25349
|
+
const res = await this.raceDeadline(entry.promise, performance.now() + deadlineMs);
|
|
25350
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
25351
|
+
if (res === "deadline") {
|
|
25352
|
+
this.scrubStats?.recordDeadlineMiss();
|
|
25353
|
+
if (this.scrubPendingEpochMs === null) this.scrubPendingEpochMs = target;
|
|
25354
|
+
if (!this.scrubDeadlineLogged) {
|
|
25355
|
+
this.scrubDeadlineLogged = true;
|
|
25356
|
+
this.deps.onLog?.("scrub-feeder:deadline-miss", {
|
|
25357
|
+
epochMs: target,
|
|
25358
|
+
deadlineMs,
|
|
25359
|
+
first: !this.scrubPushedThisDrag,
|
|
25360
|
+
inFlight: this.scrubResolvesInFlight
|
|
25180
25361
|
});
|
|
25181
|
-
loc = snapped;
|
|
25182
|
-
effTarget = decision.snapMs;
|
|
25183
25362
|
}
|
|
25184
|
-
|
|
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
|
-
}
|
|
25195
|
-
} finally {
|
|
25196
|
-
this.scrubTickReadInFlight = false;
|
|
25363
|
+
return;
|
|
25197
25364
|
}
|
|
25365
|
+
if (res === null) return;
|
|
25366
|
+
seg = res.seg;
|
|
25367
|
+
const newest = this.scrubPendingEpochMs;
|
|
25368
|
+
if (res.effTarget !== res.requested) effTarget = res.effTarget;
|
|
25369
|
+
else if (newest !== null && fragmentCovers(seg, newest)) {
|
|
25370
|
+
effTarget = newest;
|
|
25371
|
+
hint = this.scrubPendingHint;
|
|
25372
|
+
this.scrubPendingEpochMs = null;
|
|
25373
|
+
this.scrubPendingHint = null;
|
|
25374
|
+
} else effTarget = newest ?? target;
|
|
25375
|
+
if (effTarget !== res.effTarget) this.scrubStats?.recordRetarget();
|
|
25376
|
+
locateCalls = res.locateCalls;
|
|
25377
|
+
locateMs = res.locateMs;
|
|
25378
|
+
readMs = res.readMs;
|
|
25379
|
+
readBytes = res.readBytes;
|
|
25380
|
+
demuxMs = res.demuxMs;
|
|
25381
|
+
wholeSegmentRead = res.wholeSegmentRead;
|
|
25198
25382
|
}
|
|
25199
25383
|
this.scrubSegment = seg;
|
|
25200
25384
|
const prevTarget = this.scrubPrevTarget;
|
|
@@ -25261,6 +25445,7 @@ var RecordedFeeder = class {
|
|
|
25261
25445
|
pushedAtMs
|
|
25262
25446
|
}];
|
|
25263
25447
|
this.scrubCredit -= 1;
|
|
25448
|
+
this.scrubPushedThisDrag = true;
|
|
25264
25449
|
this.armScrubAckTimeout();
|
|
25265
25450
|
this.clockHasAdvanced = true;
|
|
25266
25451
|
let runBytes = 0;
|
|
@@ -25322,6 +25507,107 @@ var RecordedFeeder = class {
|
|
|
25322
25507
|
}
|
|
25323
25508
|
}
|
|
25324
25509
|
/**
|
|
25510
|
+
* ONE fragment resolution for the drag: locate (with gap snapping), read,
|
|
25511
|
+
* demux, cache. Deliberately NOT bound to the tick that started it.
|
|
25512
|
+
*
|
|
25513
|
+
* The tick races this with the frame's deadline and may walk away; this
|
|
25514
|
+
* promise then finishes on its own and puts the fragment in the session
|
|
25515
|
+
* cache, because the read is already on the wire and abandoning its RESULT
|
|
25516
|
+
* as well would turn a dropped frame into wasted disk. That is what makes
|
|
25517
|
+
* the deadline cheap: the tick after a miss is usually a cache hit.
|
|
25518
|
+
*
|
|
25519
|
+
* Null ⇒ nothing to serve (dead zone, superseded generation, or a load the
|
|
25520
|
+
* seek token invalidated). Throws propagate to the tick's `tick-failed`.
|
|
25521
|
+
*/
|
|
25522
|
+
async startScrubResolve(target, gen) {
|
|
25523
|
+
this.scrubResolvesInFlight += 1;
|
|
25524
|
+
this.scrubTickReadInFlight = true;
|
|
25525
|
+
let locateCalls = 0;
|
|
25526
|
+
let locateMs = null;
|
|
25527
|
+
try {
|
|
25528
|
+
this.maybeRecenterScrubWarm(target);
|
|
25529
|
+
const locateStartMs = performance.now();
|
|
25530
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
25531
|
+
deviceId: this.deps.deviceId,
|
|
25532
|
+
profile: this.deps.profile,
|
|
25533
|
+
epochMs: target
|
|
25534
|
+
}));
|
|
25535
|
+
locateCalls += 1;
|
|
25536
|
+
locateMs = performance.now() - locateStartMs;
|
|
25537
|
+
this.maybeLogSlowLocate(target, locateMs);
|
|
25538
|
+
if (this.disposed || gen !== this.scrubToken) return null;
|
|
25539
|
+
let effTarget = target;
|
|
25540
|
+
if (loc.kind === "gap") {
|
|
25541
|
+
const decision = resolveSnapTarget(loc, target);
|
|
25542
|
+
if (decision.kind !== "snap") {
|
|
25543
|
+
if (!this.scrubNoFootageLogged) {
|
|
25544
|
+
this.scrubNoFootageLogged = true;
|
|
25545
|
+
if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
|
|
25546
|
+
epochMs: target,
|
|
25547
|
+
nearestMs: decision.snapMs,
|
|
25548
|
+
distanceMs: decision.distanceMs
|
|
25549
|
+
});
|
|
25550
|
+
else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
25551
|
+
}
|
|
25552
|
+
return null;
|
|
25553
|
+
}
|
|
25554
|
+
const snapStartMs = performance.now();
|
|
25555
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
25556
|
+
deviceId: this.deps.deviceId,
|
|
25557
|
+
profile: this.deps.profile,
|
|
25558
|
+
epochMs: decision.snapMs
|
|
25559
|
+
}));
|
|
25560
|
+
locateCalls += 1;
|
|
25561
|
+
locateMs = (locateMs ?? 0) + (performance.now() - snapStartMs);
|
|
25562
|
+
if (this.disposed || gen !== this.scrubToken) return null;
|
|
25563
|
+
if (snapped.kind !== "segment") {
|
|
25564
|
+
if (!this.scrubNoFootageLogged) {
|
|
25565
|
+
this.scrubNoFootageLogged = true;
|
|
25566
|
+
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
25567
|
+
}
|
|
25568
|
+
return null;
|
|
25569
|
+
}
|
|
25570
|
+
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
25571
|
+
requestedMs: target,
|
|
25572
|
+
snappedMs: decision.snapMs
|
|
25573
|
+
});
|
|
25574
|
+
loc = snapped;
|
|
25575
|
+
effTarget = decision.snapMs;
|
|
25576
|
+
}
|
|
25577
|
+
const warm = this.scrubCache.covering(this.deps.profile, effTarget);
|
|
25578
|
+
if (warm) return {
|
|
25579
|
+
seg: warm,
|
|
25580
|
+
requested: target,
|
|
25581
|
+
effTarget,
|
|
25582
|
+
locateCalls,
|
|
25583
|
+
locateMs,
|
|
25584
|
+
readMs: null,
|
|
25585
|
+
readBytes: null,
|
|
25586
|
+
demuxMs: null,
|
|
25587
|
+
wholeSegmentRead: false
|
|
25588
|
+
};
|
|
25589
|
+
const load = await this.loadScrubFragment(loc, effTarget);
|
|
25590
|
+
if (this.disposed || load === null) return null;
|
|
25591
|
+
this.scrubCache.put(this.deps.profile, load.seg);
|
|
25592
|
+
if (gen !== this.scrubToken) return null;
|
|
25593
|
+
return {
|
|
25594
|
+
seg: load.seg,
|
|
25595
|
+
requested: target,
|
|
25596
|
+
effTarget,
|
|
25597
|
+
locateCalls,
|
|
25598
|
+
locateMs,
|
|
25599
|
+
readMs: load.readMs,
|
|
25600
|
+
readBytes: load.readBytes,
|
|
25601
|
+
demuxMs: load.demuxMs,
|
|
25602
|
+
wholeSegmentRead: load.wholeSegmentRead
|
|
25603
|
+
};
|
|
25604
|
+
} finally {
|
|
25605
|
+
this.scrubResolvesInFlight -= 1;
|
|
25606
|
+
if (this.scrubResolvesInFlight === 0) this.scrubTickReadInFlight = false;
|
|
25607
|
+
if (this.scrubResolve?.target === target) this.scrubResolve = null;
|
|
25608
|
+
}
|
|
25609
|
+
}
|
|
25610
|
+
/**
|
|
25325
25611
|
* Index of the nearest AU at-or-before the drag target within `seg`
|
|
25326
25612
|
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
25327
25613
|
* guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
|
|
@@ -25414,7 +25700,20 @@ var RecordedFeeder = class {
|
|
|
25414
25700
|
*/
|
|
25415
25701
|
readsWholeSegment(loc) {
|
|
25416
25702
|
if (this.deps.profile !== SCRUB_WHOLE_SEGMENT_PROFILE) return false;
|
|
25417
|
-
|
|
25703
|
+
if (loc.bytes === void 0 || loc.bytes <= SCRUB_WHOLE_SEGMENT_MAX_BYTES) return true;
|
|
25704
|
+
this.scrubStats?.recordOversizeSegment();
|
|
25705
|
+
if (!this.scrubOversizeLogged) {
|
|
25706
|
+
this.scrubOversizeLogged = true;
|
|
25707
|
+
this.deps.onLog?.("scrub-feeder:oversize-segment", {
|
|
25708
|
+
profile: this.deps.profile,
|
|
25709
|
+
startMs: loc.startMs,
|
|
25710
|
+
durMs: loc.durMs,
|
|
25711
|
+
bytes: loc.bytes,
|
|
25712
|
+
maxBytes: SCRUB_WHOLE_SEGMENT_MAX_BYTES,
|
|
25713
|
+
bytesPerSecond: loc.durMs > 0 ? Math.round(loc.bytes / (loc.durMs / 1e3)) : null
|
|
25714
|
+
});
|
|
25715
|
+
}
|
|
25716
|
+
return false;
|
|
25418
25717
|
}
|
|
25419
25718
|
/**
|
|
25420
25719
|
* Fold one read into the recent-read window that governs speculation. The
|
|
@@ -25425,15 +25724,84 @@ var RecordedFeeder = class {
|
|
|
25425
25724
|
this.scrubReadMsWindow = [...this.scrubReadMsWindow, readMs].slice(-5);
|
|
25426
25725
|
}
|
|
25427
25726
|
/**
|
|
25428
|
-
*
|
|
25727
|
+
* A drag that is NOT on the scrub profile runs with every scrub optimisation
|
|
25728
|
+
* off: the whole-segment read unit, the 48 MB fragment-cache budget and the
|
|
25729
|
+
* entry warm are all gated on the exact string `low`
|
|
25730
|
+
* ({@link SCRUB_WHOLE_SEGMENT_PROFILE}). A momentary hole in `low` therefore
|
|
25731
|
+
* turns a drag into 3-seek GOP reads over ~5 MB `high` segments — measured at
|
|
25732
|
+
* 2 642 ms cold — and the client cannot repair it.
|
|
25733
|
+
*
|
|
25734
|
+
* It used to be indistinguishable from a slow disk. Now it says so, once per
|
|
25735
|
+
* drag, and the census carries the flag beside the numbers it explains.
|
|
25736
|
+
*/
|
|
25737
|
+
reportProfileDegradation() {
|
|
25738
|
+
if (this.deps.profile === SCRUB_WHOLE_SEGMENT_PROFILE) return;
|
|
25739
|
+
this.scrubStats?.recordProfileDegraded(this.deps.profile);
|
|
25740
|
+
if (this.scrubProfileLogged) return;
|
|
25741
|
+
this.scrubProfileLogged = true;
|
|
25742
|
+
this.deps.onLog?.("scrub-feeder:profile-degraded", {
|
|
25743
|
+
profile: this.deps.profile,
|
|
25744
|
+
scrubProfile: SCRUB_WHOLE_SEGMENT_PROFILE
|
|
25745
|
+
});
|
|
25746
|
+
}
|
|
25747
|
+
/**
|
|
25748
|
+
* Median of the recent reads — what a read costs on this disk RIGHT NOW.
|
|
25749
|
+
* Null until there are enough samples to call it anything: a single cold
|
|
25750
|
+
* first read is not a median, and acting on it would aim a whole gesture.
|
|
25751
|
+
*/
|
|
25752
|
+
scrubReadMedianMs() {
|
|
25753
|
+
if (this.scrubReadMsWindow.length < SCRUB_LEAD_MIN_READ_SAMPLES) return null;
|
|
25754
|
+
const sorted = this.scrubReadMsWindow.toSorted((a, b) => a - b);
|
|
25755
|
+
return sorted[Math.floor(sorted.length / 2)] ?? null;
|
|
25756
|
+
}
|
|
25757
|
+
/**
|
|
25758
|
+
* Is the disk the problem right now? The recent-read median against
|
|
25429
25759
|
* {@link SCRUB_SLOW_READ_MS}. Median and not mean because one slow read is
|
|
25430
25760
|
* noise and the decision must not swing on it; and never before a few
|
|
25431
25761
|
* samples, or a cold first read would suppress the whole drag.
|
|
25432
25762
|
*/
|
|
25433
25763
|
scrubReadsAreSlow() {
|
|
25434
|
-
|
|
25435
|
-
|
|
25436
|
-
return
|
|
25764
|
+
const median = this.scrubReadMedianMs();
|
|
25765
|
+
if (median === null) return false;
|
|
25766
|
+
return median >= (this.deps.slowReadMs ?? SCRUB_SLOW_READ_MS);
|
|
25767
|
+
}
|
|
25768
|
+
/**
|
|
25769
|
+
* Fold one pointer sample into the drag's media velocity (ms of timeline per
|
|
25770
|
+
* ms of wall clock, signed). Cheap on purpose: this runs at pointer rate.
|
|
25771
|
+
*/
|
|
25772
|
+
noteScrubSample(epochMs) {
|
|
25773
|
+
const now = performance.now();
|
|
25774
|
+
const prev = this.scrubLastSampleMs;
|
|
25775
|
+
const prevAt = this.scrubLastSampleAtMs;
|
|
25776
|
+
this.scrubLastSampleMs = epochMs;
|
|
25777
|
+
this.scrubLastSampleAtMs = now;
|
|
25778
|
+
if (prev === null) return;
|
|
25779
|
+
const gapMs = now - prevAt;
|
|
25780
|
+
if (gapMs < SCRUB_VELOCITY_MIN_GAP_MS || gapMs > SCRUB_VELOCITY_MAX_GAP_MS) return;
|
|
25781
|
+
const deltaMs = epochMs - prev;
|
|
25782
|
+
const dir = Math.sign(deltaMs);
|
|
25783
|
+
if (dir === 0) {
|
|
25784
|
+
this.scrubVelRun = 0;
|
|
25785
|
+
return;
|
|
25786
|
+
}
|
|
25787
|
+
this.scrubVelRun = Math.sign(this.scrubVelRun) === dir ? this.scrubVelRun + dir : dir;
|
|
25788
|
+
const v = deltaMs / gapMs;
|
|
25789
|
+
this.scrubVelocity = this.scrubVelocity === null ? v : this.scrubVelocity * (1 - SCRUB_VELOCITY_EMA) + v * SCRUB_VELOCITY_EMA;
|
|
25790
|
+
}
|
|
25791
|
+
/**
|
|
25792
|
+
* How far AHEAD of the finger this read should aim — see the
|
|
25793
|
+
* {@link SCRUB_LEAD_MIN_READ_SAMPLES} docblock for why this exists and what
|
|
25794
|
+
* bounds it. Zero whenever the prediction is not earned: no measured reads,
|
|
25795
|
+
* no sustained direction, or a velocity whose sign disagrees with the run.
|
|
25796
|
+
*/
|
|
25797
|
+
scrubLeadMs() {
|
|
25798
|
+
const velocity = this.scrubVelocity;
|
|
25799
|
+
if (velocity === null) return 0;
|
|
25800
|
+
if (Math.abs(this.scrubVelRun) < SCRUB_LEAD_MIN_DIR_RUN) return 0;
|
|
25801
|
+
if (Math.sign(velocity) !== Math.sign(this.scrubVelRun)) return 0;
|
|
25802
|
+
const readMs = this.scrubReadMedianMs();
|
|
25803
|
+
if (readMs === null) return 0;
|
|
25804
|
+
return Math.round(velocity * readMs);
|
|
25437
25805
|
}
|
|
25438
25806
|
/**
|
|
25439
25807
|
* A single slow `locate` gets its own line, throttled per drag. Reserved for
|
|
@@ -26158,6 +26526,14 @@ var RecordedFeeder = class {
|
|
|
26158
26526
|
this.scrubCursor = null;
|
|
26159
26527
|
this.scrubNoFootageLogged = false;
|
|
26160
26528
|
this.scrubParkLogged = false;
|
|
26529
|
+
this.scrubDeadlineLogged = false;
|
|
26530
|
+
this.scrubOversizeLogged = false;
|
|
26531
|
+
this.scrubProfileLogged = false;
|
|
26532
|
+
this.scrubPushedThisDrag = false;
|
|
26533
|
+
this.scrubResolve = null;
|
|
26534
|
+
this.scrubVelocity = null;
|
|
26535
|
+
this.scrubLastSampleMs = null;
|
|
26536
|
+
this.scrubVelRun = 0;
|
|
26161
26537
|
this.scrubOutstanding = [];
|
|
26162
26538
|
this.clearScrubAckTimeout();
|
|
26163
26539
|
this.scrubPendingHint = null;
|