@camstack/addon-pipeline 1.2.76 → 1.2.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/{addon-utils-CTpQfjSR.js → addon-utils-C-XbUkiG.js} +1 -1
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +1 -1
  4. package/dist/detection-pipeline/index.js +4 -4
  5. package/dist/detection-pipeline/index.mjs +2 -2
  6. package/dist/{dist-CZLjObZZ.js → dist-BdVCXl5n.js} +429 -44
  7. package/dist/{dist-zksfWnEA.mjs → dist-CsP_DikG.mjs} +429 -44
  8. package/dist/{event-loop-stall-monitor-CcBQAI28.js → event-loop-stall-monitor-BOu8lGee.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-BuZA3loB.mjs → event-loop-stall-monitor-C3cvE_Xk.mjs} +1 -1
  10. package/dist/{lazy-sharp-SWR_D1Um.js → lazy-sharp-1LkmyWqV.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/recorder/index.js +655 -46
  16. package/dist/recorder/index.mjs +654 -45
  17. package/dist/session-decode/decode-worker-child.js +2 -2
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dd6XOtVn.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DUi-lR4R.mjs} +3 -3
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-1QMyGZMB.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dg08SxUW.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-COc_Q_xX.mjs → hostInit-DEtsjgBO.mjs} +3 -3
  24. package/dist/stream-broker/index.js +411 -93
  25. package/dist/stream-broker/index.mjs +411 -93
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DAQ1iZFK.js → worker-protocol-B4fPjmXk.js} +1 -1
  28. package/dist/{worker-protocol-DyX_HbaJ.mjs → worker-protocol-BRwzX3f_.mjs} +1 -1
  29. package/package.json +1 -1
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-RYTOS6B-.mjs +0 -26
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DfsLsn2e.mjs +0 -26
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
2
- import { At as string, Ct as boolean, D as cameraStreamsCapability, Dt as number, Et as literal, F as egressTranscodeSharingKey, I as egressTransportFromRequest, Mt as EventCategory, O as createHwAccelCache, Ot as object, Q as streamBrokerCapability, S as RingBuffer, St as array, V as maskUrlCredentials, Y as resolveEgressDecodeHwAccel, at as invocationFromEncodeProfile, ct as CAM_PROFILE_ORDER, dt as createEvent, et as webrtcSessionCapability, f as EncodeProfileSchema, gt as nodePin, ht as makeSourceBrokerId, it as buildFfmpegArgs$1, jt as union, kt as record, lt as DeviceFeature, mt as makeProfileBrokerId, nt as AUDIO_PRESETS, ot as isSoftwareDecode, pt as isEvent, rt as Fmp4BoxSplitter, st as BaseAddon, tt as errMsg, ut as DeviceType, v as RATE_CONTROL_RELAXED, vt as parseProfileBrokerId, w as addonWidgetsSourceCapability, wt as discriminatedUnion, xt as _enum, y as RATE_CONTROL_TIGHT } from "../dist-zksfWnEA.mjs";
2
+ import { At as string, Ct as boolean, D as cameraStreamsCapability, Dt as number, Et as literal, F as egressTranscodeSharingKey, I as egressTransportFromRequest, Mt as EventCategory, O as createHwAccelCache, Ot as object, Q as streamBrokerCapability, S as RingBuffer, St as array, V as maskUrlCredentials, Y as resolveEgressDecodeHwAccel, at as invocationFromEncodeProfile, ct as CAM_PROFILE_ORDER, dt as createEvent, et as webrtcSessionCapability, f as EncodeProfileSchema, gt as nodePin, ht as makeSourceBrokerId, it as buildFfmpegArgs$1, jt as union, kt as record, lt as DeviceFeature, mt as makeProfileBrokerId, nt as AUDIO_PRESETS, ot as isSoftwareDecode, pt as isEvent, rt as Fmp4BoxSplitter, st as BaseAddon, tt as errMsg, ut as DeviceType, v as RATE_CONTROL_RELAXED, vt as parseProfileBrokerId, w as addonWidgetsSourceCapability, wt as discriminatedUnion, xt as _enum, y as RATE_CONTROL_TIGHT } from "../dist-CsP_DikG.mjs";
3
3
  import { n as profileForStreamId, r as parseRestreamPath } from "../remote-restream-Ci7RXNGb.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import * as crypto$1 from "node:crypto";
@@ -968,7 +968,93 @@ var DerivedStreamFeeder = class extends FfmpegPushReader {
968
968
  * deleted, and with it the two hacks this module needed to bend it into shape:
969
969
  * a sink-retargeting string surgery on the tail of the arg list, and a fake
970
970
  * `inputFormat` chosen only to defeat smart-copy.
971
+ *
972
+ * ## The audio rule, and the outage that wrote it
973
+ *
974
+ * A derived stream is a self-contained MPEG-TS, so its audio must travel
975
+ * IN-BAND — `passthrough` (`-an`, right for WebRTC, where werift carries audio
976
+ * out of band) silently drops the track. That was fixed on 2026-07-17 by
977
+ * remapping `passthrough` → `-c:a copy`, and the track stayed missing anyway,
978
+ * because a copy has to survive TWO hops and it only clears the first:
979
+ *
980
+ * 1. the MPEG-TS MUXER. Measured with ffmpeg 7.1: a `pcm_mulaw` track copied
981
+ * into `-f mpegts` does not fail the invocation. ffmpeg logs
982
+ * `Stream 1, codec pcm_mulaw, is muxed as a private data stream and may
983
+ * not be recognized upon reading` and writes stream_type `0x06` — which
984
+ * is why the derived stream looked perfectly healthy: video never
985
+ * stopped;
986
+ * 2. {@link MpegTsDemux}, which adopts an elementary audio PID only for
987
+ * stream_type `0x0f` (AAC) or `0x03`/`0x04` (MP2). `0x06` is not audio to
988
+ * it, so no audio packet was ever forwarded, `onAudioInfo` never fired,
989
+ * the derived broker never learned it had audio, and its probe read
990
+ * `audio: null` forever.
991
+ *
992
+ * Every camera in this fleet speaks G.711 (PCMU/PCMA), so the copy could never
993
+ * work for any of them: no `derived:*` stream has EVER carried audio, and
994
+ * camera 618 — whose `low` (motion, and therefore audio) profile is mapped to
995
+ * a derived stream — went silent the day it was remapped.
996
+ *
997
+ * So the carrier decides, explicitly and loudly, not the profile: source audio
998
+ * is COPIED when it is already AAC, and RE-ENCODED to AAC otherwise (including
999
+ * when the source codec is unknown — the safe direction, since an unknown that
1000
+ * guesses `copy` mutes the camera). The decision is returned to the caller so
1001
+ * it can be logged per camera; it is never taken silently.
1002
+ */
1003
+ /**
1004
+ * Audio codecs that survive BOTH hops of the derived carrier untouched: a
1005
+ * standard MPEG-TS stream type AND a stream type {@link MpegTsDemux} adopts.
1006
+ *
1007
+ * AAC only, deliberately. MP2 clears both hops as a container matter, but the
1008
+ * broker's push-audio path has no MP2 decode (`StreamBroker.pushAudioInfo` →
1009
+ * `buildAudioCodecSessionConfigFromPush` handles `aac`/`opus`), so a copied
1010
+ * MP2 track would arrive as an announced-but-undecodable stream — a second way
1011
+ * to be silent, with a track descriptor to make it look fine.
1012
+ */
1013
+ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
1014
+ /**
1015
+ * The AAC re-encode used when the source cannot be copied. Mono, and
1016
+ * deliberately WITHOUT a forced sample rate: a G.711 camera mic is 8 kHz, and
1017
+ * upsampling it to the 48 kHz camera preset would spend bitrate on nothing.
1018
+ * ffmpeg's native AAC encoder accepts 8 kHz, the ADTS header carries whatever
1019
+ * rate came out, and `parseAdtsAudioParams` reads it back for the broker.
1020
+ */
1021
+ var DERIVED_AAC_RECODE = {
1022
+ codec: "aac",
1023
+ channels: 1
1024
+ };
1025
+ /** `true` when this profile audio means "carry whatever the source has". */
1026
+ function wantsSourceAudio(audio) {
1027
+ return audio === "passthrough" || audio.codec === "copy";
1028
+ }
1029
+ /**
1030
+ * Decide the derived stream's audio from the profile's intent and the SOURCE's
1031
+ * observed codec. Pure; see this module's header for why `copy` is not the
1032
+ * default answer to "carry the source's audio".
1033
+ *
1034
+ * `sourceAudioCodec` is matched case-insensitively (`BrokerStats.audio.codec`
1035
+ * is upper-cased, SDP spellings are not).
971
1036
  */
1037
+ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
1038
+ if (!wantsSourceAudio(profileAudio)) return {
1039
+ audio: profileAudio,
1040
+ decision: { kind: "profile" }
1041
+ };
1042
+ const observed = sourceAudioCodec?.toLowerCase() ?? null;
1043
+ if (observed !== null && TS_COPYABLE_AUDIO_CODECS.has(observed)) return {
1044
+ audio: { codec: "copy" },
1045
+ decision: {
1046
+ kind: "copy",
1047
+ sourceCodec: observed
1048
+ }
1049
+ };
1050
+ return {
1051
+ audio: DERIVED_AAC_RECODE,
1052
+ decision: {
1053
+ kind: "recode-aac",
1054
+ sourceCodec: observed
1055
+ }
1056
+ };
1057
+ }
972
1058
  /**
973
1059
  * Build the ffmpeg argv that reads a source loopback RTSP url, transcodes it
974
1060
  * per `profile`, and writes `-f mpegts pipe:1` (what the feeder's `MpegTsDemux`
@@ -977,37 +1063,33 @@ var DerivedStreamFeeder = class extends FfmpegPushReader {
977
1063
  * Two things are specific to a derived stream and are expressed as first-class
978
1064
  * builder inputs rather than post-hoc edits:
979
1065
  *
980
- * - `forceReencode` — a derived stream ALWAYS applies its profile; that is its
981
- * entire reason to exist. Without it the builder's smart-copy would elide
982
- * the encode whenever the source already spoke the target codec. A `copy`
983
- * profile still copies (a legitimate derived remux).
984
- * - audio `copy` in place of `passthrough` — a derived stream is a
985
- * self-contained MPEG-TS, so its audio MUST travel in-band. `passthrough`
986
- * means `-an`, which is right for WebRTC (werift carries audio out-of-band)
987
- * but silently DROPS the track here.
988
- *
989
- * `decodeHwAccel` is threaded from the caller. It used to be hardcoded `null`;
990
- * the derived source is a local loopback restream, but decoding 4K in software
991
- * to re-encode it is exactly the cost the hub's working vaapi exists to avoid.
992
- * The caller resolves it through `resolveEgressDecodeHwAccel`, and the shared
993
- * `FfmpegProcess` retries in software — loudly — if the hardware child dies.
1066
+ * - `forceReencode` — a derived stream ALWAYS applies its video profile; that
1067
+ * is its entire reason to exist. Without it the builder's smart-copy would
1068
+ * elide the encode whenever the source already spoke the target codec. A
1069
+ * `copy` profile still copies (a legitimate derived remux).
1070
+ * - the audio plan from {@link resolveDerivedAudio} in place of the profile's
1071
+ * own — the MPEG-TS carrier, not the operator, decides whether the source
1072
+ * track can be copied.
994
1073
  */
995
- function buildDerivedTranscodeArgs(profile, loopbackUrl, decodeHwAccel = null) {
996
- const audio = profile.audio === "passthrough" ? { codec: "copy" } : profile.audio;
997
- return buildFfmpegArgs$1(invocationFromEncodeProfile({
998
- profile: {
999
- ...profile,
1000
- audio
1001
- },
1002
- sourceCodec: "h264",
1003
- forceReencode: true,
1004
- sourceUrl: loopbackUrl,
1005
- sink: {
1006
- kind: "stdout",
1007
- container: "mpegts"
1008
- },
1009
- decodeHwAccel
1010
- }));
1074
+ function buildDerivedTranscodeArgs(input) {
1075
+ const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
1076
+ return {
1077
+ args: buildFfmpegArgs$1(invocationFromEncodeProfile({
1078
+ profile: {
1079
+ ...input.profile,
1080
+ audio: audio.audio
1081
+ },
1082
+ sourceCodec: "h264",
1083
+ forceReencode: true,
1084
+ sourceUrl: input.loopbackUrl,
1085
+ sink: {
1086
+ kind: "stdout",
1087
+ container: "mpegts"
1088
+ },
1089
+ decodeHwAccel: input.decodeHwAccel ?? null
1090
+ })),
1091
+ audio
1092
+ };
1011
1093
  }
1012
1094
  /**
1013
1095
  * Ceiling for a configured pre-roll.
@@ -17952,7 +18034,19 @@ var StreamBrokerManager = class StreamBrokerManager {
17952
18034
  loopbackUrl = entry.url;
17953
18035
  release = async () => {};
17954
18036
  }
17955
- const args = buildDerivedTranscodeArgs(encodeProfile, loopbackUrl);
18037
+ const sourceAudioCodec = this.observedSourceAudioCodec(deviceId, source);
18038
+ const { args, audio } = buildDerivedTranscodeArgs({
18039
+ profile: encodeProfile,
18040
+ loopbackUrl,
18041
+ sourceAudioCodec
18042
+ });
18043
+ if (audio.decision.kind === "recode-aac") this.logger.info("derived audio re-encoded to AAC — MPEG-TS cannot copy this source codec", {
18044
+ tags: { deviceId },
18045
+ meta: {
18046
+ camStreamId,
18047
+ sourceAudioCodec: audio.decision.sourceCodec ?? "unknown"
18048
+ }
18049
+ });
17956
18050
  const feeder = new DerivedStreamFeeder({
17957
18051
  ffmpegPath: this.ffmpegConfig.binaryPath,
17958
18052
  args,
@@ -17987,6 +18081,21 @@ var StreamBrokerManager = class StreamBrokerManager {
17987
18081
  };
17988
18082
  }
17989
18083
  /**
18084
+ * The audio codec the derived stream's SOURCE broker has actually observed,
18085
+ * or `null` when there is nothing to read (source not resolved, broker not
18086
+ * created yet, cold dial, no audio track).
18087
+ *
18088
+ * Only ever an input to "can this track be COPIED into the derived MPEG-TS".
18089
+ * `null` resolves to the AAC re-encode, so every failure of this read lands
18090
+ * on the branch that still produces audible audio — the read can never mute
18091
+ * a camera, which is the direction that cost 618 four silent days.
18092
+ */
18093
+ observedSourceAudioCodec(deviceId, source) {
18094
+ const camStreamId = source.kind === "camStream" ? source.camStreamId : this.assignments.get(deviceId)?.map[source.profile] ?? null;
18095
+ if (camStreamId === null) return null;
18096
+ return this.brokers.get(brokerIdFor(deviceId, camStreamId))?.getStats().audio?.codec ?? null;
18097
+ }
18098
+ /**
17990
18099
  * Whether a stream is warmed BY DEFAULT (no explicit override): the device's
17991
18100
  * `high` profile, on a non-battery camera, from a source that is worth
17992
18101
  * keeping hot. Single source of truth used by both the broker-config default
@@ -22280,7 +22389,10 @@ var ControlMessageSchema = discriminatedUnion("t", [
22280
22389
  m: _enum(["fine", "coarse"]).optional(),
22281
22390
  v: number().optional()
22282
22391
  }),
22283
- object({ t: literal("scrubAck") }),
22392
+ object({
22393
+ t: literal("scrubAck"),
22394
+ seq: number().optional()
22395
+ }),
22284
22396
  object({
22285
22397
  t: literal("warm"),
22286
22398
  epoch: number(),
@@ -22325,7 +22437,8 @@ var ServerMessageSchema = discriminatedUnion("t", [
22325
22437
  }),
22326
22438
  object({
22327
22439
  t: literal("position"),
22328
- epochMs: number()
22440
+ epochMs: number(),
22441
+ seq: number().optional()
22329
22442
  }),
22330
22443
  object({
22331
22444
  t: literal("liveProfile"),
@@ -22421,11 +22534,19 @@ var MonotonicClock = class {
22421
22534
  this.rate = rate;
22422
22535
  }
22423
22536
  };
22537
+ var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 2 * 1024 * 1024;
22538
+ /** Demuxed bytes an entry holds — what the byte bound actually counts. */
22539
+ function fragmentBytes(frag) {
22540
+ let total = 0;
22541
+ for (const au of frag.aus) total += au.data.length;
22542
+ return total;
22543
+ }
22424
22544
  var ScrubFragmentCache = class {
22425
- max;
22545
+ maxBytes;
22426
22546
  map = /* @__PURE__ */ new Map();
22427
- constructor(max = 8) {
22428
- this.max = max;
22547
+ bytes = 0;
22548
+ constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES) {
22549
+ this.maxBytes = maxBytes;
22429
22550
  }
22430
22551
  key(profile, startMs) {
22431
22552
  return `${profile}|${startMs}`;
@@ -22456,17 +22577,26 @@ var ScrubFragmentCache = class {
22456
22577
  }
22457
22578
  return null;
22458
22579
  }
22459
- /** Insert (or refresh) a fragment, evicting the oldest past the bound. */
22580
+ /** Insert (or refresh) a fragment, evicting the oldest past the byte bound. */
22460
22581
  put(profile, frag) {
22461
22582
  const k = this.key(profile, frag.startMs);
22583
+ const held = this.map.get(k);
22584
+ if (held) this.bytes -= fragmentBytes(held);
22462
22585
  this.map.delete(k);
22463
22586
  this.map.set(k, frag);
22464
- while (this.map.size > this.max) {
22587
+ this.bytes += fragmentBytes(frag);
22588
+ while (this.bytes > this.maxBytes && this.map.size > 4) {
22465
22589
  const oldest = this.map.keys().next().value;
22466
22590
  if (oldest === void 0) break;
22591
+ const evicted = this.map.get(oldest);
22467
22592
  this.map.delete(oldest);
22593
+ if (evicted) this.bytes -= fragmentBytes(evicted);
22468
22594
  }
22469
22595
  }
22596
+ /** Demuxed bytes currently held — the number the MB bound governs. */
22597
+ get byteSize() {
22598
+ return this.bytes;
22599
+ }
22470
22600
  get size() {
22471
22601
  return this.map.size;
22472
22602
  }
@@ -22532,6 +22662,10 @@ var ScrubDragStats = class {
22532
22662
  wholeReads = 0;
22533
22663
  readBytes = 0;
22534
22664
  parks = 0;
22665
+ prefetchReads = 0;
22666
+ prefetchBytes = 0;
22667
+ prefetchHits = 0;
22668
+ ackTimeouts = 0;
22535
22669
  failures = 0;
22536
22670
  firstPushMs = null;
22537
22671
  creditEndedAt = null;
@@ -22578,6 +22712,19 @@ var ScrubDragStats = class {
22578
22712
  recordPark() {
22579
22713
  this.parks += 1;
22580
22714
  }
22715
+ /** One speculative GOP read and what it cost the disk. */
22716
+ recordPrefetchRead(bytes) {
22717
+ this.prefetchReads += 1;
22718
+ this.prefetchBytes += bytes;
22719
+ }
22720
+ /** A tick served by a fragment speculation had already landed. */
22721
+ recordPrefetchHit() {
22722
+ this.prefetchHits += 1;
22723
+ }
22724
+ /** Pushes written off after the ack timeout — dropped work, never silent. */
22725
+ recordAckTimeout(pushes) {
22726
+ this.ackTimeouts += pushes;
22727
+ }
22581
22728
  /** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
22582
22729
  recordFailure() {
22583
22730
  this.failures += 1;
@@ -22627,6 +22774,10 @@ var ScrubDragStats = class {
22627
22774
  travel: this.travel,
22628
22775
  bytes: this.bytes,
22629
22776
  parks: this.parks,
22777
+ prefetchReads: this.prefetchReads,
22778
+ prefetchBytes: this.prefetchBytes,
22779
+ prefetchHits: this.prefetchHits,
22780
+ ackTimeouts: this.ackTimeouts,
22630
22781
  failures: this.failures,
22631
22782
  creditEndedAt: this.creditEndedAt,
22632
22783
  acks: ack.n,
@@ -22721,6 +22872,24 @@ var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
22721
22872
  */
22722
22873
  var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
22723
22874
  /**
22875
+ * How many speculative GOP reads may be in flight at once, i.e. how many drag
22876
+ * steps ahead the feeder reads.
22877
+ *
22878
+ * The scrub tick IS the read: measured on salone 590 over `low`,
22879
+ * `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
22880
+ * the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
22881
+ * hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
22882
+ * tens of seconds apart revisits nothing. Reading N steps ahead is the only
22883
+ * thing that converts wall-clock the drag is ALREADY spending into pushes.
22884
+ *
22885
+ * 2 and not more: these reads land on the same spindles the recorder is writing
22886
+ * every camera to, and past a small number concurrent random reads stop
22887
+ * overlapping and start thrashing. Raise it only against a measured
22888
+ * `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
22889
+ * both halves of the evidence.
22890
+ */
22891
+ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
22892
+ /**
22724
22893
  * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
22725
22894
  * that SWEEPS predicted frames instead of jumping. A steady forward drag is
22726
22895
  * playback with a moving target: once a keyframe decoded, the predicted frames
@@ -22733,16 +22902,37 @@ var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
22733
22902
  */
22734
22903
  var SCRUB_SWEEP_WINDOW_MS = 4e3;
22735
22904
  /**
22736
- * Receiver-credit cap for scrub pushes: at most this many keyframes may be
22737
- * OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
22738
- * the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
22739
- * a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
22740
- * floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
22741
- * Backpressure is the receiver's: a push spends a credit, a rendered frame
22742
- * restores one, and with zero credit the newest target PARKS on
22743
- * `scrubPendingEpochMs` until an ack arrives a timer never releases work.
22905
+ * Receiver-credit cap for scrub pushes: at most this many PUSHES may be
22906
+ * OUTSTANDING (on the track, not yet acknowledged by the viewer via
22907
+ * `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
22908
+ * is not backpressure — pushing a ~45 KB keyframe every 60 ms floods a link
22909
+ * that delivers one every 500 ms, RTP drops, and nothing paints. Backpressure
22910
+ * is the receiver's: a push spends a credit, the first ack naming it restores
22911
+ * that credit, and with zero credit the newest target PARKS on
22912
+ * `scrubPendingEpochMs` until an ack (or {@link SCRUB_ACK_TIMEOUT_MS}) arrives.
22913
+ *
22914
+ * THE UNIT IS THE PUSH, NOT THE FRAME — and getting that wrong is what made a
22915
+ * slowed drag die. A push carries one AU in `jump` mode but a whole sweep (~25
22916
+ * AUs at 25 fps over a 1 s step) in `sweep` mode, while the viewer emits ONE
22917
+ * position per push. Charging one credit per push and repaying one per FRAME
22918
+ * meant two sweeps drained the cap against a viewer that could only ever send
22919
+ * two acks: the drag parked permanently at the exact moment the operator
22920
+ * slowed down to look at something.
22744
22921
  */
22745
22922
  var SCRUB_CREDIT_CAP = 2;
22923
+ /**
22924
+ * How long an outstanding push may go unacknowledged before the feeder writes
22925
+ * it off and takes its credit back.
22926
+ *
22927
+ * The credit gate is released by an ack and by nothing else — which is right
22928
+ * while acks flow and fatal when one is lost: the drag parks with no timer to
22929
+ * release it, and `beginScrub` (the only other reset) is a documented no-op
22930
+ * mid-drag. Generous on purpose: this is not pacing, it is the bound that
22931
+ * makes a lost ack cost one stalled tick instead of the rest of the gesture.
22932
+ * Every write-off is logged and counted (`ackTimeouts` on the drag census) —
22933
+ * dropped work is never silent.
22934
+ */
22935
+ var SCRUB_ACK_TIMEOUT_MS = 2e3;
22746
22936
  /** Minimum gap between two lines of the same pathological kind, per drag. */
22747
22937
  var SCRUB_PATHOLOGY_LOG_INTERVAL_MS = 1e3;
22748
22938
  /**
@@ -22840,13 +23030,27 @@ var RecordedFeeder = class {
22840
23030
  /** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
22841
23031
  scrubCredit = SCRUB_CREDIT_CAP;
22842
23032
  /**
22843
- * Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
22844
- * `1`; a sweep enqueues however many predicted frames it put on the wire. Each
22845
- * `scrubAck` settles one frame off the head, and the credit that push spent
22846
- * comes back only when the head reaches zero — so a sweep cannot be refunded
22847
- * once per frame for the single credit it cost.
23033
+ * The pushes on the wire and not yet settled, OLDEST FIRST (and therefore
23034
+ * ascending in both `seq` and `pushedAtMs` every settle and every write-off
23035
+ * takes a prefix). One entry per push, whatever it carried: the first
23036
+ * `scrubAck` naming it returns its credit.
22848
23037
  */
22849
23038
  scrubOutstanding = [];
23039
+ /**
23040
+ * The push sequence the viewer echoes on `scrubAck`. Monotonic for the life of
23041
+ * the feeder — see {@link OutstandingPush} for why it must NOT restart per
23042
+ * drag.
23043
+ */
23044
+ scrubPushSeq = 0;
23045
+ /**
23046
+ * The write-off timer for the oldest outstanding push
23047
+ * ({@link SCRUB_ACK_TIMEOUT_MS}). Separate from `scrubTimer` on purpose: that
23048
+ * one is the coalescing slot, and an ack arriving while this is pending must
23049
+ * be able to schedule a leading-edge tick without waiting on it.
23050
+ */
23051
+ scrubAckTimer = null;
23052
+ /** One `scrub-feeder:ack-timeout` line per drag — the count is on the census. */
23053
+ scrubAckTimeoutLogged = false;
22850
23054
  /** The newest un-processed drag target (null when the tick cycle has drained). */
22851
23055
  scrubPendingEpochMs = null;
22852
23056
  /** The mapping hint that came with the newest target (latest-wins with it). */
@@ -22904,12 +23108,25 @@ var RecordedFeeder = class {
22904
23108
  */
22905
23109
  scrubDirRun = 0;
22906
23110
  /**
22907
- * The SECOND bounded concurrency slot: `true` while a direction-aware prefetch
22908
- * demux is in flight. At most one prefetch runs at a time and it only fills
22909
- * `scrubCache` — it never pushes, never touches the pending/timer/latest-wins
22910
- * state, and never blocks the live coalesced tick.
23111
+ * Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
23112
+ * these are the SPECULATIVE concurrency slots, and they only fill
23113
+ * `scrubCache` — they never push, never touch the pending/timer/latest-wins
23114
+ * state, and never block the live coalesced tick.
23115
+ *
23116
+ * More than one because the read IS the tick (263 of 270 ms measured): with a
23117
+ * single slot the drag can never go faster than one cold read, however well
23118
+ * the speculation guesses.
23119
+ */
23120
+ scrubPrefetchInFlight = 0;
23121
+ /** Probe instants currently being read, so two horizons never duplicate one. */
23122
+ scrubPrefetchProbes = /* @__PURE__ */ new Set();
23123
+ /**
23124
+ * Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
23125
+ * hits one of them is what makes speculation provably worth its I/O
23126
+ * (`prefetchHits`) — without this the feeder could double the load on the disk
23127
+ * that is the bottleneck and the census would look calm.
22911
23128
  */
22912
- scrubPrefetchInFlight = false;
23129
+ scrubPrefetched = /* @__PURE__ */ new Set();
22913
23130
  constructor(deps) {
22914
23131
  this.deps = deps;
22915
23132
  this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
@@ -22947,6 +23164,7 @@ var RecordedFeeder = class {
22947
23164
  this.scrubSegment = null;
22948
23165
  this.scrubCursor = null;
22949
23166
  this.scrubOutstanding = [];
23167
+ this.clearScrubAckTimeout();
22950
23168
  this.scrubPendingHint = null;
22951
23169
  this.scrubPrevTarget = null;
22952
23170
  this.scrubDirRun = 0;
@@ -23198,10 +23416,13 @@ var RecordedFeeder = class {
23198
23416
  this.scrubCredit = SCRUB_CREDIT_CAP;
23199
23417
  this.scrubPrevTarget = null;
23200
23418
  this.scrubDirRun = 0;
23419
+ this.scrubPrefetched = /* @__PURE__ */ new Set();
23201
23420
  this.scrubCursor = null;
23202
23421
  this.scrubNoFootageLogged = false;
23203
23422
  this.scrubParkLogged = false;
23204
23423
  this.scrubOutstanding = [];
23424
+ this.clearScrubAckTimeout();
23425
+ this.scrubAckTimeoutLogged = false;
23205
23426
  this.scrubPendingHint = null;
23206
23427
  this.scrubStartedAtMs = performance.now();
23207
23428
  this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
@@ -23228,28 +23449,97 @@ var RecordedFeeder = class {
23228
23449
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
23229
23450
  }
23230
23451
  /**
23231
- * Receiver credit: the viewer RENDERED a scrub frame. Restores one push
23232
- * credit (cappedover-acking never banks a burst) and, when a target sat
23233
- * parked on an exhausted budget, resumes the tick. This not any timer — is
23234
- * what paces scrub pushes to what the link actually delivers.
23452
+ * Receiver credit: the viewer received a pushed scrub frame. Settles the push
23453
+ * `seq` namesand every older one still outstanding, since the channel is
23454
+ * ordered restores their credit (capped: over-acking never banks a burst)
23455
+ * and, when a target sat parked on an exhausted budget, resumes the tick.
23456
+ * This — not any timer — is what paces scrub pushes to what the link
23457
+ * actually delivers; {@link SCRUB_ACK_TIMEOUT_MS} is only the bound for when
23458
+ * an ack never comes.
23459
+ *
23460
+ * THE UNIT IS THE PUSH. A sweep puts ~25 AUs on the wire under ONE position
23461
+ * message, so a viewer without `requestVideoFrameCallback` can only ever send
23462
+ * ONE ack for it; the old per-FRAME settle waited for 25 and the drag parked
23463
+ * forever the moment the operator slowed down. A viewer WITH rvfc sends one
23464
+ * ack per rendered frame instead — those surplus acks all name the same
23465
+ * settled `seq` and are no-ops, which is what `seq` is for.
23466
+ *
23467
+ * A `seq` no outstanding push matches (a late ack from a drag that already
23468
+ * ended) banks nothing. An ack without `seq` comes from a viewer older than
23469
+ * the field: it settles the oldest outstanding push, which can only repay too
23470
+ * early — never strand the drag.
23235
23471
  */
23236
- scrubAck() {
23472
+ scrubAck(seq) {
23237
23473
  if (this.disposed) return;
23474
+ const settled = seq === void 0 ? this.scrubOutstanding.length > 0 ? 1 : 0 : this.scrubOutstanding.filter((p) => p.seq <= seq).length;
23475
+ if (settled === 0) return;
23476
+ const now = performance.now();
23477
+ for (const push of this.scrubOutstanding.slice(0, settled)) this.scrubStats?.recordAckRtt(Math.round(now - push.pushedAtMs));
23478
+ this.scrubOutstanding = this.scrubOutstanding.slice(settled);
23479
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + settled);
23480
+ this.resumeScrubTick();
23481
+ }
23482
+ /**
23483
+ * Resume a parked drag: the newest target is still pending and the coalescing
23484
+ * slot is free, so run a leading-edge tick. Never touches a LIVE slot — a
23485
+ * pending re-arm is this tick cycle's own coalescing window and pre-empting it
23486
+ * would push faster than {@link SCRUB_COALESCE_MS}.
23487
+ */
23488
+ resumeScrubTick() {
23489
+ if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
23490
+ }
23491
+ /**
23492
+ * Arm the write-off timer for the OLDEST outstanding push, if one is not
23493
+ * already pending. Newer entries are always due later, so a timer armed for
23494
+ * an entry that gets settled first simply fires early and re-arms — one
23495
+ * harmless wakeup, no bookkeeping.
23496
+ */
23497
+ armScrubAckTimeout() {
23498
+ if (this.scrubAckTimer !== null) return;
23238
23499
  const head = this.scrubOutstanding[0];
23239
23500
  if (head === void 0) return;
23501
+ const dueInMs = Math.max(0, SCRUB_ACK_TIMEOUT_MS - (performance.now() - head.pushedAtMs));
23502
+ this.scrubAckTimer = setTimeout(() => {
23503
+ this.scrubAckTimer = null;
23504
+ this.expireScrubAcks();
23505
+ }, dueInMs);
23506
+ }
23507
+ /** Drop the ack timer (drag exit / teardown). */
23508
+ clearScrubAckTimeout() {
23509
+ if (this.scrubAckTimer === null) return;
23510
+ clearTimeout(this.scrubAckTimer);
23511
+ this.scrubAckTimer = null;
23512
+ }
23513
+ /**
23514
+ * Write off every push that has waited longer than
23515
+ * {@link SCRUB_ACK_TIMEOUT_MS}, give its credit back, and resume the drag.
23516
+ *
23517
+ * This is DROPPED WORK — a frame the viewer may never have painted — so it is
23518
+ * counted on the census every time and logged once per drag. Without it a
23519
+ * single lost ack ends the gesture: the gate is released by acks alone, and
23520
+ * `beginScrub` (its only other reset) is a no-op mid-drag.
23521
+ */
23522
+ expireScrubAcks() {
23523
+ if (this.disposed || !this.scrubbing) return;
23240
23524
  const now = performance.now();
23241
- if (head.firstAckAtMs === null) this.scrubStats?.recordAckRtt(Math.round(now - head.pushedAtMs));
23242
- if (head.frames > 1) {
23243
- this.scrubOutstanding = [{
23244
- frames: head.frames - 1,
23245
- pushedAtMs: head.pushedAtMs,
23246
- firstAckAtMs: head.firstAckAtMs ?? now
23247
- }, ...this.scrubOutstanding.slice(1)];
23248
- return;
23525
+ const expired = this.scrubOutstanding.filter((p) => now - p.pushedAtMs >= SCRUB_ACK_TIMEOUT_MS).length;
23526
+ if (expired > 0) {
23527
+ const frames = this.scrubOutstanding.slice(0, expired).reduce((sum, p) => sum + p.frames, 0);
23528
+ this.scrubOutstanding = this.scrubOutstanding.slice(expired);
23529
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + expired);
23530
+ this.scrubStats?.recordAckTimeout(expired);
23531
+ if (!this.scrubAckTimeoutLogged) {
23532
+ this.scrubAckTimeoutLogged = true;
23533
+ this.deps.onLog?.("scrub-feeder:ack-timeout", {
23534
+ pushes: expired,
23535
+ frames,
23536
+ timeoutMs: SCRUB_ACK_TIMEOUT_MS,
23537
+ pendingEpochMs: this.scrubPendingEpochMs
23538
+ });
23539
+ }
23540
+ this.resumeScrubTick();
23249
23541
  }
23250
- this.scrubOutstanding = this.scrubOutstanding.slice(1);
23251
- this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
23252
- if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
23542
+ this.armScrubAckTimeout();
23253
23543
  }
23254
23544
  /**
23255
23545
  * One coalesced feeder-scrub push. Captures the newest pending target + the
@@ -23302,7 +23592,10 @@ var RecordedFeeder = class {
23302
23592
  coverage = "memo";
23303
23593
  } else {
23304
23594
  seg = this.scrubCache.covering(this.deps.profile, target);
23305
- if (seg) coverage = "cache";
23595
+ if (seg) {
23596
+ coverage = "cache";
23597
+ if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordPrefetchHit();
23598
+ }
23306
23599
  }
23307
23600
  if (!seg) {
23308
23601
  const locateStartMs = performance.now();
@@ -23422,12 +23715,14 @@ var RecordedFeeder = class {
23422
23715
  else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
23423
23716
  for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
23424
23717
  const pushedAtMs = performance.now();
23718
+ const pushSeq = ++this.scrubPushSeq;
23425
23719
  this.scrubOutstanding = [...this.scrubOutstanding, {
23720
+ seq: pushSeq,
23426
23721
  frames: jumpTravel.length + aus.length,
23427
- pushedAtMs,
23428
- firstAckAtMs: null
23722
+ pushedAtMs
23429
23723
  }];
23430
23724
  this.scrubCredit -= 1;
23725
+ this.armScrubAckTimeout();
23431
23726
  this.clockHasAdvanced = true;
23432
23727
  let runBytes = 0;
23433
23728
  for (const s of jumpTravel) runBytes += s.au.data.length;
@@ -23474,7 +23769,7 @@ var RecordedFeeder = class {
23474
23769
  tickMs: Math.round(pushedAtMs - tickStartMs)
23475
23770
  });
23476
23771
  }
23477
- this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
23772
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget), pushSeq);
23478
23773
  this.maybeScrubPrefetch(seg, effTarget, gen);
23479
23774
  } catch (err) {
23480
23775
  this.scrubStats?.recordFailure();
@@ -23722,28 +24017,41 @@ var RecordedFeeder = class {
23722
24017
  this.scrubDirRun = 0;
23723
24018
  return;
23724
24019
  }
23725
- const dir = target > prev ? 1 : target < prev ? -1 : 0;
24020
+ const stepMs = target - prev;
24021
+ const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
23726
24022
  if (dir === 0) {
23727
24023
  this.scrubDirRun = 0;
23728
24024
  return;
23729
24025
  }
23730
24026
  this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
23731
24027
  if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
23732
- if (this.scrubPrefetchInFlight) return;
23733
- const probeMs = dir === 1 ? seg.startMs + seg.durMs + 1 : seg.startMs - 1;
23734
- if (probeMs < 0) return;
23735
- this.scrubPrefetch(probeMs, gen);
24028
+ const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
24029
+ const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
24030
+ for (let horizon = 1; horizon <= horizons; horizon++) {
24031
+ if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
24032
+ const probeMs = target + dir * aheadMs * horizon;
24033
+ if (probeMs < 0) continue;
24034
+ if (this.scrubPrefetchProbes.has(probeMs)) continue;
24035
+ if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
24036
+ this.scrubPrefetch(probeMs, gen);
24037
+ }
23736
24038
  }
23737
24039
  /**
23738
- * Background (second-slot) prefetch: locate + read + demux the fragment
23739
- * covering `probeMs` into `scrubCache`. Bounded to one in flight; aborts at any
24040
+ * Speculative read: locate + read + demux the fragment covering `probeMs` into
24041
+ * `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
23740
24042
  * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
23741
- * swallowed (the neighbour just demuxes on demand). Never pushes and never
24043
+ * swallowed (the fragment just demuxes on demand). Never pushes and never
23742
24044
  * mutates latest-wins state.
24045
+ *
24046
+ * Every read it issues is COUNTED (`prefetchReads` / `prefetchBytes`), and the
24047
+ * fragment it lands is remembered so a later tick hitting it counts as a
24048
+ * `prefetchHit`. Speculation that cannot be shown to pay for itself is just
24049
+ * load on the disk the drag is already waiting for.
23743
24050
  */
23744
24051
  async scrubPrefetch(probeMs, gen) {
23745
- if (this.scrubPrefetchInFlight) return;
23746
- this.scrubPrefetchInFlight = true;
24052
+ if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
24053
+ this.scrubPrefetchInFlight += 1;
24054
+ this.scrubPrefetchProbes.add(probeMs);
23747
24055
  try {
23748
24056
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
23749
24057
  if (this.scrubCache.covering(this.deps.profile, probeMs)) return;
@@ -23756,9 +24064,12 @@ var RecordedFeeder = class {
23756
24064
  if (loc.kind !== "segment") return;
23757
24065
  const load = await this.loadScrubFragment(loc, probeMs);
23758
24066
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
24067
+ this.scrubStats?.recordPrefetchRead(load.readBytes);
23759
24068
  this.scrubCache.put(this.deps.profile, load.seg);
24069
+ this.scrubPrefetched.add(load.seg.startMs);
23760
24070
  } catch {} finally {
23761
- this.scrubPrefetchInFlight = false;
24071
+ this.scrubPrefetchInFlight -= 1;
24072
+ this.scrubPrefetchProbes.delete(probeMs);
23762
24073
  }
23763
24074
  }
23764
24075
  /**
@@ -24187,6 +24498,7 @@ var RecordedFeeder = class {
24187
24498
  this.scrubNoFootageLogged = false;
24188
24499
  this.scrubParkLogged = false;
24189
24500
  this.scrubOutstanding = [];
24501
+ this.clearScrubAckTimeout();
24190
24502
  this.scrubPendingHint = null;
24191
24503
  if (this.scrubTimer) {
24192
24504
  clearTimeout(this.scrubTimer);
@@ -24266,7 +24578,7 @@ var TimelineSession = class {
24266
24578
  break;
24267
24579
  }
24268
24580
  case "scrubAck":
24269
- if (this.mode === "recorded") this.feeder?.scrubAck();
24581
+ if (this.mode === "recorded") this.feeder?.scrubAck(msg.seq);
24270
24582
  break;
24271
24583
  case "setRate":
24272
24584
  this.rate = msg.rate;
@@ -24314,12 +24626,18 @@ var TimelineSession = class {
24314
24626
  state
24315
24627
  }));
24316
24628
  }
24317
- /** Emit seam: the broker wires the feeder's onPosition callback to this. */
24318
- _emitPosition(epochMs) {
24629
+ /** Emit seam: the broker wires the feeder's onPosition callback to this.
24630
+ * `pushSeq` rides along only on the position a scrub push emitted — the
24631
+ * viewer echoes it on `scrubAck` so the credit is repaid to the right push. */
24632
+ _emitPosition(epochMs, pushSeq) {
24319
24633
  this.lastPositionMs = epochMs;
24320
- this.deps.session.sendControl(serializeServerMessage({
24634
+ this.deps.session.sendControl(serializeServerMessage(pushSeq === void 0 ? {
24321
24635
  t: "position",
24322
24636
  epochMs
24637
+ } : {
24638
+ t: "position",
24639
+ epochMs,
24640
+ seq: pushSeq
24323
24641
  }));
24324
24642
  }
24325
24643
  emitLiveProfile() {
@@ -32994,7 +33312,7 @@ var BrokerWebrtcServer = class {
32994
33312
  } });
32995
33313
  timeline._emitState(s);
32996
33314
  },
32997
- onPosition: (epochMs) => timeline._emitPosition(epochMs),
33315
+ onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
32998
33316
  onLog: (msg, data) => sessionLogger.info(msg, {
32999
33317
  tags: { deviceId },
33000
33318
  meta: {