@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
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CZLjObZZ.js");
5
+ const require_dist = require("../dist-BdVCXl5n.js");
6
6
  const require_remote_restream = require("../remote-restream-BYbAsgUf.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  node_crypto = require_dist.__toESM(node_crypto, 1);
@@ -973,7 +973,93 @@ var DerivedStreamFeeder = class extends FfmpegPushReader {
973
973
  * deleted, and with it the two hacks this module needed to bend it into shape:
974
974
  * a sink-retargeting string surgery on the tail of the arg list, and a fake
975
975
  * `inputFormat` chosen only to defeat smart-copy.
976
+ *
977
+ * ## The audio rule, and the outage that wrote it
978
+ *
979
+ * A derived stream is a self-contained MPEG-TS, so its audio must travel
980
+ * IN-BAND — `passthrough` (`-an`, right for WebRTC, where werift carries audio
981
+ * out of band) silently drops the track. That was fixed on 2026-07-17 by
982
+ * remapping `passthrough` → `-c:a copy`, and the track stayed missing anyway,
983
+ * because a copy has to survive TWO hops and it only clears the first:
984
+ *
985
+ * 1. the MPEG-TS MUXER. Measured with ffmpeg 7.1: a `pcm_mulaw` track copied
986
+ * into `-f mpegts` does not fail the invocation. ffmpeg logs
987
+ * `Stream 1, codec pcm_mulaw, is muxed as a private data stream and may
988
+ * not be recognized upon reading` and writes stream_type `0x06` — which
989
+ * is why the derived stream looked perfectly healthy: video never
990
+ * stopped;
991
+ * 2. {@link MpegTsDemux}, which adopts an elementary audio PID only for
992
+ * stream_type `0x0f` (AAC) or `0x03`/`0x04` (MP2). `0x06` is not audio to
993
+ * it, so no audio packet was ever forwarded, `onAudioInfo` never fired,
994
+ * the derived broker never learned it had audio, and its probe read
995
+ * `audio: null` forever.
996
+ *
997
+ * Every camera in this fleet speaks G.711 (PCMU/PCMA), so the copy could never
998
+ * work for any of them: no `derived:*` stream has EVER carried audio, and
999
+ * camera 618 — whose `low` (motion, and therefore audio) profile is mapped to
1000
+ * a derived stream — went silent the day it was remapped.
1001
+ *
1002
+ * So the carrier decides, explicitly and loudly, not the profile: source audio
1003
+ * is COPIED when it is already AAC, and RE-ENCODED to AAC otherwise (including
1004
+ * when the source codec is unknown — the safe direction, since an unknown that
1005
+ * guesses `copy` mutes the camera). The decision is returned to the caller so
1006
+ * it can be logged per camera; it is never taken silently.
1007
+ */
1008
+ /**
1009
+ * Audio codecs that survive BOTH hops of the derived carrier untouched: a
1010
+ * standard MPEG-TS stream type AND a stream type {@link MpegTsDemux} adopts.
1011
+ *
1012
+ * AAC only, deliberately. MP2 clears both hops as a container matter, but the
1013
+ * broker's push-audio path has no MP2 decode (`StreamBroker.pushAudioInfo` →
1014
+ * `buildAudioCodecSessionConfigFromPush` handles `aac`/`opus`), so a copied
1015
+ * MP2 track would arrive as an announced-but-undecodable stream — a second way
1016
+ * to be silent, with a track descriptor to make it look fine.
1017
+ */
1018
+ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
1019
+ /**
1020
+ * The AAC re-encode used when the source cannot be copied. Mono, and
1021
+ * deliberately WITHOUT a forced sample rate: a G.711 camera mic is 8 kHz, and
1022
+ * upsampling it to the 48 kHz camera preset would spend bitrate on nothing.
1023
+ * ffmpeg's native AAC encoder accepts 8 kHz, the ADTS header carries whatever
1024
+ * rate came out, and `parseAdtsAudioParams` reads it back for the broker.
1025
+ */
1026
+ var DERIVED_AAC_RECODE = {
1027
+ codec: "aac",
1028
+ channels: 1
1029
+ };
1030
+ /** `true` when this profile audio means "carry whatever the source has". */
1031
+ function wantsSourceAudio(audio) {
1032
+ return audio === "passthrough" || audio.codec === "copy";
1033
+ }
1034
+ /**
1035
+ * Decide the derived stream's audio from the profile's intent and the SOURCE's
1036
+ * observed codec. Pure; see this module's header for why `copy` is not the
1037
+ * default answer to "carry the source's audio".
1038
+ *
1039
+ * `sourceAudioCodec` is matched case-insensitively (`BrokerStats.audio.codec`
1040
+ * is upper-cased, SDP spellings are not).
976
1041
  */
1042
+ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
1043
+ if (!wantsSourceAudio(profileAudio)) return {
1044
+ audio: profileAudio,
1045
+ decision: { kind: "profile" }
1046
+ };
1047
+ const observed = sourceAudioCodec?.toLowerCase() ?? null;
1048
+ if (observed !== null && TS_COPYABLE_AUDIO_CODECS.has(observed)) return {
1049
+ audio: { codec: "copy" },
1050
+ decision: {
1051
+ kind: "copy",
1052
+ sourceCodec: observed
1053
+ }
1054
+ };
1055
+ return {
1056
+ audio: DERIVED_AAC_RECODE,
1057
+ decision: {
1058
+ kind: "recode-aac",
1059
+ sourceCodec: observed
1060
+ }
1061
+ };
1062
+ }
977
1063
  /**
978
1064
  * Build the ffmpeg argv that reads a source loopback RTSP url, transcodes it
979
1065
  * per `profile`, and writes `-f mpegts pipe:1` (what the feeder's `MpegTsDemux`
@@ -982,37 +1068,33 @@ var DerivedStreamFeeder = class extends FfmpegPushReader {
982
1068
  * Two things are specific to a derived stream and are expressed as first-class
983
1069
  * builder inputs rather than post-hoc edits:
984
1070
  *
985
- * - `forceReencode` — a derived stream ALWAYS applies its profile; that is its
986
- * entire reason to exist. Without it the builder's smart-copy would elide
987
- * the encode whenever the source already spoke the target codec. A `copy`
988
- * profile still copies (a legitimate derived remux).
989
- * - audio `copy` in place of `passthrough` — a derived stream is a
990
- * self-contained MPEG-TS, so its audio MUST travel in-band. `passthrough`
991
- * means `-an`, which is right for WebRTC (werift carries audio out-of-band)
992
- * but silently DROPS the track here.
993
- *
994
- * `decodeHwAccel` is threaded from the caller. It used to be hardcoded `null`;
995
- * the derived source is a local loopback restream, but decoding 4K in software
996
- * to re-encode it is exactly the cost the hub's working vaapi exists to avoid.
997
- * The caller resolves it through `resolveEgressDecodeHwAccel`, and the shared
998
- * `FfmpegProcess` retries in software — loudly — if the hardware child dies.
1071
+ * - `forceReencode` — a derived stream ALWAYS applies its video profile; that
1072
+ * is its entire reason to exist. Without it the builder's smart-copy would
1073
+ * elide the encode whenever the source already spoke the target codec. A
1074
+ * `copy` profile still copies (a legitimate derived remux).
1075
+ * - the audio plan from {@link resolveDerivedAudio} in place of the profile's
1076
+ * own — the MPEG-TS carrier, not the operator, decides whether the source
1077
+ * track can be copied.
999
1078
  */
1000
- function buildDerivedTranscodeArgs(profile, loopbackUrl, decodeHwAccel = null) {
1001
- const audio = profile.audio === "passthrough" ? { codec: "copy" } : profile.audio;
1002
- return require_dist.buildFfmpegArgs(require_dist.invocationFromEncodeProfile({
1003
- profile: {
1004
- ...profile,
1005
- audio
1006
- },
1007
- sourceCodec: "h264",
1008
- forceReencode: true,
1009
- sourceUrl: loopbackUrl,
1010
- sink: {
1011
- kind: "stdout",
1012
- container: "mpegts"
1013
- },
1014
- decodeHwAccel
1015
- }));
1079
+ function buildDerivedTranscodeArgs(input) {
1080
+ const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
1081
+ return {
1082
+ args: require_dist.buildFfmpegArgs(require_dist.invocationFromEncodeProfile({
1083
+ profile: {
1084
+ ...input.profile,
1085
+ audio: audio.audio
1086
+ },
1087
+ sourceCodec: "h264",
1088
+ forceReencode: true,
1089
+ sourceUrl: input.loopbackUrl,
1090
+ sink: {
1091
+ kind: "stdout",
1092
+ container: "mpegts"
1093
+ },
1094
+ decodeHwAccel: input.decodeHwAccel ?? null
1095
+ })),
1096
+ audio
1097
+ };
1016
1098
  }
1017
1099
  /**
1018
1100
  * Ceiling for a configured pre-roll.
@@ -17957,7 +18039,19 @@ var StreamBrokerManager = class StreamBrokerManager {
17957
18039
  loopbackUrl = entry.url;
17958
18040
  release = async () => {};
17959
18041
  }
17960
- const args = buildDerivedTranscodeArgs(encodeProfile, loopbackUrl);
18042
+ const sourceAudioCodec = this.observedSourceAudioCodec(deviceId, source);
18043
+ const { args, audio } = buildDerivedTranscodeArgs({
18044
+ profile: encodeProfile,
18045
+ loopbackUrl,
18046
+ sourceAudioCodec
18047
+ });
18048
+ if (audio.decision.kind === "recode-aac") this.logger.info("derived audio re-encoded to AAC — MPEG-TS cannot copy this source codec", {
18049
+ tags: { deviceId },
18050
+ meta: {
18051
+ camStreamId,
18052
+ sourceAudioCodec: audio.decision.sourceCodec ?? "unknown"
18053
+ }
18054
+ });
17961
18055
  const feeder = new DerivedStreamFeeder({
17962
18056
  ffmpegPath: this.ffmpegConfig.binaryPath,
17963
18057
  args,
@@ -17992,6 +18086,21 @@ var StreamBrokerManager = class StreamBrokerManager {
17992
18086
  };
17993
18087
  }
17994
18088
  /**
18089
+ * The audio codec the derived stream's SOURCE broker has actually observed,
18090
+ * or `null` when there is nothing to read (source not resolved, broker not
18091
+ * created yet, cold dial, no audio track).
18092
+ *
18093
+ * Only ever an input to "can this track be COPIED into the derived MPEG-TS".
18094
+ * `null` resolves to the AAC re-encode, so every failure of this read lands
18095
+ * on the branch that still produces audible audio — the read can never mute
18096
+ * a camera, which is the direction that cost 618 four silent days.
18097
+ */
18098
+ observedSourceAudioCodec(deviceId, source) {
18099
+ const camStreamId = source.kind === "camStream" ? source.camStreamId : this.assignments.get(deviceId)?.map[source.profile] ?? null;
18100
+ if (camStreamId === null) return null;
18101
+ return this.brokers.get(brokerIdFor(deviceId, camStreamId))?.getStats().audio?.codec ?? null;
18102
+ }
18103
+ /**
17995
18104
  * Whether a stream is warmed BY DEFAULT (no explicit override): the device's
17996
18105
  * `high` profile, on a non-battery camera, from a source that is worth
17997
18106
  * keeping hot. Single source of truth used by both the broker-config default
@@ -22285,7 +22394,10 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
22285
22394
  m: require_dist._enum(["fine", "coarse"]).optional(),
22286
22395
  v: require_dist.number().optional()
22287
22396
  }),
22288
- require_dist.object({ t: require_dist.literal("scrubAck") }),
22397
+ require_dist.object({
22398
+ t: require_dist.literal("scrubAck"),
22399
+ seq: require_dist.number().optional()
22400
+ }),
22289
22401
  require_dist.object({
22290
22402
  t: require_dist.literal("warm"),
22291
22403
  epoch: require_dist.number(),
@@ -22330,7 +22442,8 @@ var ServerMessageSchema = require_dist.discriminatedUnion("t", [
22330
22442
  }),
22331
22443
  require_dist.object({
22332
22444
  t: require_dist.literal("position"),
22333
- epochMs: require_dist.number()
22445
+ epochMs: require_dist.number(),
22446
+ seq: require_dist.number().optional()
22334
22447
  }),
22335
22448
  require_dist.object({
22336
22449
  t: require_dist.literal("liveProfile"),
@@ -22426,11 +22539,19 @@ var MonotonicClock = class {
22426
22539
  this.rate = rate;
22427
22540
  }
22428
22541
  };
22542
+ var SCRUB_FRAGMENT_CACHE_MAX_BYTES = 2 * 1024 * 1024;
22543
+ /** Demuxed bytes an entry holds — what the byte bound actually counts. */
22544
+ function fragmentBytes(frag) {
22545
+ let total = 0;
22546
+ for (const au of frag.aus) total += au.data.length;
22547
+ return total;
22548
+ }
22429
22549
  var ScrubFragmentCache = class {
22430
- max;
22550
+ maxBytes;
22431
22551
  map = /* @__PURE__ */ new Map();
22432
- constructor(max = 8) {
22433
- this.max = max;
22552
+ bytes = 0;
22553
+ constructor(maxBytes = SCRUB_FRAGMENT_CACHE_MAX_BYTES) {
22554
+ this.maxBytes = maxBytes;
22434
22555
  }
22435
22556
  key(profile, startMs) {
22436
22557
  return `${profile}|${startMs}`;
@@ -22461,17 +22582,26 @@ var ScrubFragmentCache = class {
22461
22582
  }
22462
22583
  return null;
22463
22584
  }
22464
- /** Insert (or refresh) a fragment, evicting the oldest past the bound. */
22585
+ /** Insert (or refresh) a fragment, evicting the oldest past the byte bound. */
22465
22586
  put(profile, frag) {
22466
22587
  const k = this.key(profile, frag.startMs);
22588
+ const held = this.map.get(k);
22589
+ if (held) this.bytes -= fragmentBytes(held);
22467
22590
  this.map.delete(k);
22468
22591
  this.map.set(k, frag);
22469
- while (this.map.size > this.max) {
22592
+ this.bytes += fragmentBytes(frag);
22593
+ while (this.bytes > this.maxBytes && this.map.size > 4) {
22470
22594
  const oldest = this.map.keys().next().value;
22471
22595
  if (oldest === void 0) break;
22596
+ const evicted = this.map.get(oldest);
22472
22597
  this.map.delete(oldest);
22598
+ if (evicted) this.bytes -= fragmentBytes(evicted);
22473
22599
  }
22474
22600
  }
22601
+ /** Demuxed bytes currently held — the number the MB bound governs. */
22602
+ get byteSize() {
22603
+ return this.bytes;
22604
+ }
22475
22605
  get size() {
22476
22606
  return this.map.size;
22477
22607
  }
@@ -22537,6 +22667,10 @@ var ScrubDragStats = class {
22537
22667
  wholeReads = 0;
22538
22668
  readBytes = 0;
22539
22669
  parks = 0;
22670
+ prefetchReads = 0;
22671
+ prefetchBytes = 0;
22672
+ prefetchHits = 0;
22673
+ ackTimeouts = 0;
22540
22674
  failures = 0;
22541
22675
  firstPushMs = null;
22542
22676
  creditEndedAt = null;
@@ -22583,6 +22717,19 @@ var ScrubDragStats = class {
22583
22717
  recordPark() {
22584
22718
  this.parks += 1;
22585
22719
  }
22720
+ /** One speculative GOP read and what it cost the disk. */
22721
+ recordPrefetchRead(bytes) {
22722
+ this.prefetchReads += 1;
22723
+ this.prefetchBytes += bytes;
22724
+ }
22725
+ /** A tick served by a fragment speculation had already landed. */
22726
+ recordPrefetchHit() {
22727
+ this.prefetchHits += 1;
22728
+ }
22729
+ /** Pushes written off after the ack timeout — dropped work, never silent. */
22730
+ recordAckTimeout(pushes) {
22731
+ this.ackTimeouts += pushes;
22732
+ }
22586
22733
  /** A swallowed `scrubTick` throw — the branch where a dark failure hides. */
22587
22734
  recordFailure() {
22588
22735
  this.failures += 1;
@@ -22632,6 +22779,10 @@ var ScrubDragStats = class {
22632
22779
  travel: this.travel,
22633
22780
  bytes: this.bytes,
22634
22781
  parks: this.parks,
22782
+ prefetchReads: this.prefetchReads,
22783
+ prefetchBytes: this.prefetchBytes,
22784
+ prefetchHits: this.prefetchHits,
22785
+ ackTimeouts: this.ackTimeouts,
22635
22786
  failures: this.failures,
22636
22787
  creditEndedAt: this.creditEndedAt,
22637
22788
  acks: ack.n,
@@ -22726,6 +22877,24 @@ var SCRUB_JUMP_TRAVEL_MAX_STILLS = 3;
22726
22877
  */
22727
22878
  var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
22728
22879
  /**
22880
+ * How many speculative GOP reads may be in flight at once, i.e. how many drag
22881
+ * steps ahead the feeder reads.
22882
+ *
22883
+ * The scrub tick IS the read: measured on salone 590 over `low`,
22884
+ * `tickMsP50 = 270` of which `readMsP50 = 263` — a cold ~135 KB fragment off
22885
+ * the HDD array through shfs/FUSE, `demuxMsP50 = 1`. One read at a time is a
22886
+ * hard ~3.8 push/s ceiling no cache can lift, because a drag whose steps are
22887
+ * tens of seconds apart revisits nothing. Reading N steps ahead is the only
22888
+ * thing that converts wall-clock the drag is ALREADY spending into pushes.
22889
+ *
22890
+ * 2 and not more: these reads land on the same spindles the recorder is writing
22891
+ * every camera to, and past a small number concurrent random reads stop
22892
+ * overlapping and start thrashing. Raise it only against a measured
22893
+ * `prefetchHits` / `readMsP50` pair — it is the knob, and the census carries
22894
+ * both halves of the evidence.
22895
+ */
22896
+ var SCRUB_PREFETCH_MAX_INFLIGHT = 2;
22897
+ /**
22729
22898
  * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
22730
22899
  * that SWEEPS predicted frames instead of jumping. A steady forward drag is
22731
22900
  * playback with a moving target: once a keyframe decoded, the predicted frames
@@ -22738,16 +22907,37 @@ var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
22738
22907
  */
22739
22908
  var SCRUB_SWEEP_WINDOW_MS = 4e3;
22740
22909
  /**
22741
- * Receiver-credit cap for scrub pushes: at most this many keyframes may be
22742
- * OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
22743
- * the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
22744
- * a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
22745
- * floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
22746
- * Backpressure is the receiver's: a push spends a credit, a rendered frame
22747
- * restores one, and with zero credit the newest target PARKS on
22748
- * `scrubPendingEpochMs` until an ack arrives a timer never releases work.
22910
+ * Receiver-credit cap for scrub pushes: at most this many PUSHES may be
22911
+ * OUTSTANDING (on the track, not yet acknowledged by the viewer via
22912
+ * `scrubAck`). The coalescing window above shapes the INPUT; on a thin pipe it
22913
+ * is not backpressure — pushing a ~45 KB keyframe every 60 ms floods a link
22914
+ * that delivers one every 500 ms, RTP drops, and nothing paints. Backpressure
22915
+ * is the receiver's: a push spends a credit, the first ack naming it restores
22916
+ * that credit, and with zero credit the newest target PARKS on
22917
+ * `scrubPendingEpochMs` until an ack (or {@link SCRUB_ACK_TIMEOUT_MS}) arrives.
22918
+ *
22919
+ * THE UNIT IS THE PUSH, NOT THE FRAME — and getting that wrong is what made a
22920
+ * slowed drag die. A push carries one AU in `jump` mode but a whole sweep (~25
22921
+ * AUs at 25 fps over a 1 s step) in `sweep` mode, while the viewer emits ONE
22922
+ * position per push. Charging one credit per push and repaying one per FRAME
22923
+ * meant two sweeps drained the cap against a viewer that could only ever send
22924
+ * two acks: the drag parked permanently at the exact moment the operator
22925
+ * slowed down to look at something.
22749
22926
  */
22750
22927
  var SCRUB_CREDIT_CAP = 2;
22928
+ /**
22929
+ * How long an outstanding push may go unacknowledged before the feeder writes
22930
+ * it off and takes its credit back.
22931
+ *
22932
+ * The credit gate is released by an ack and by nothing else — which is right
22933
+ * while acks flow and fatal when one is lost: the drag parks with no timer to
22934
+ * release it, and `beginScrub` (the only other reset) is a documented no-op
22935
+ * mid-drag. Generous on purpose: this is not pacing, it is the bound that
22936
+ * makes a lost ack cost one stalled tick instead of the rest of the gesture.
22937
+ * Every write-off is logged and counted (`ackTimeouts` on the drag census) —
22938
+ * dropped work is never silent.
22939
+ */
22940
+ var SCRUB_ACK_TIMEOUT_MS = 2e3;
22751
22941
  /** Minimum gap between two lines of the same pathological kind, per drag. */
22752
22942
  var SCRUB_PATHOLOGY_LOG_INTERVAL_MS = 1e3;
22753
22943
  /**
@@ -22845,13 +23035,27 @@ var RecordedFeeder = class {
22845
23035
  /** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
22846
23036
  scrubCredit = SCRUB_CREDIT_CAP;
22847
23037
  /**
22848
- * Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
22849
- * `1`; a sweep enqueues however many predicted frames it put on the wire. Each
22850
- * `scrubAck` settles one frame off the head, and the credit that push spent
22851
- * comes back only when the head reaches zero — so a sweep cannot be refunded
22852
- * once per frame for the single credit it cost.
23038
+ * The pushes on the wire and not yet settled, OLDEST FIRST (and therefore
23039
+ * ascending in both `seq` and `pushedAtMs` every settle and every write-off
23040
+ * takes a prefix). One entry per push, whatever it carried: the first
23041
+ * `scrubAck` naming it returns its credit.
22853
23042
  */
22854
23043
  scrubOutstanding = [];
23044
+ /**
23045
+ * The push sequence the viewer echoes on `scrubAck`. Monotonic for the life of
23046
+ * the feeder — see {@link OutstandingPush} for why it must NOT restart per
23047
+ * drag.
23048
+ */
23049
+ scrubPushSeq = 0;
23050
+ /**
23051
+ * The write-off timer for the oldest outstanding push
23052
+ * ({@link SCRUB_ACK_TIMEOUT_MS}). Separate from `scrubTimer` on purpose: that
23053
+ * one is the coalescing slot, and an ack arriving while this is pending must
23054
+ * be able to schedule a leading-edge tick without waiting on it.
23055
+ */
23056
+ scrubAckTimer = null;
23057
+ /** One `scrub-feeder:ack-timeout` line per drag — the count is on the census. */
23058
+ scrubAckTimeoutLogged = false;
22855
23059
  /** The newest un-processed drag target (null when the tick cycle has drained). */
22856
23060
  scrubPendingEpochMs = null;
22857
23061
  /** The mapping hint that came with the newest target (latest-wins with it). */
@@ -22909,12 +23113,25 @@ var RecordedFeeder = class {
22909
23113
  */
22910
23114
  scrubDirRun = 0;
22911
23115
  /**
22912
- * The SECOND bounded concurrency slot: `true` while a direction-aware prefetch
22913
- * demux is in flight. At most one prefetch runs at a time and it only fills
22914
- * `scrubCache` — it never pushes, never touches the pending/timer/latest-wins
22915
- * state, and never blocks the live coalesced tick.
23116
+ * Prefetch reads in flight. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}:
23117
+ * these are the SPECULATIVE concurrency slots, and they only fill
23118
+ * `scrubCache` — they never push, never touch the pending/timer/latest-wins
23119
+ * state, and never block the live coalesced tick.
23120
+ *
23121
+ * More than one because the read IS the tick (263 of 270 ms measured): with a
23122
+ * single slot the drag can never go faster than one cold read, however well
23123
+ * the speculation guesses.
23124
+ */
23125
+ scrubPrefetchInFlight = 0;
23126
+ /** Probe instants currently being read, so two horizons never duplicate one. */
23127
+ scrubPrefetchProbes = /* @__PURE__ */ new Set();
23128
+ /**
23129
+ * Fragment starts this drag put in the cache SPECULATIVELY. A later tick that
23130
+ * hits one of them is what makes speculation provably worth its I/O
23131
+ * (`prefetchHits`) — without this the feeder could double the load on the disk
23132
+ * that is the bottleneck and the census would look calm.
22916
23133
  */
22917
- scrubPrefetchInFlight = false;
23134
+ scrubPrefetched = /* @__PURE__ */ new Set();
22918
23135
  constructor(deps) {
22919
23136
  this.deps = deps;
22920
23137
  this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
@@ -22952,6 +23169,7 @@ var RecordedFeeder = class {
22952
23169
  this.scrubSegment = null;
22953
23170
  this.scrubCursor = null;
22954
23171
  this.scrubOutstanding = [];
23172
+ this.clearScrubAckTimeout();
22955
23173
  this.scrubPendingHint = null;
22956
23174
  this.scrubPrevTarget = null;
22957
23175
  this.scrubDirRun = 0;
@@ -23203,10 +23421,13 @@ var RecordedFeeder = class {
23203
23421
  this.scrubCredit = SCRUB_CREDIT_CAP;
23204
23422
  this.scrubPrevTarget = null;
23205
23423
  this.scrubDirRun = 0;
23424
+ this.scrubPrefetched = /* @__PURE__ */ new Set();
23206
23425
  this.scrubCursor = null;
23207
23426
  this.scrubNoFootageLogged = false;
23208
23427
  this.scrubParkLogged = false;
23209
23428
  this.scrubOutstanding = [];
23429
+ this.clearScrubAckTimeout();
23430
+ this.scrubAckTimeoutLogged = false;
23210
23431
  this.scrubPendingHint = null;
23211
23432
  this.scrubStartedAtMs = performance.now();
23212
23433
  this.scrubStats = new ScrubDragStats(this.scrubStartedAtMs);
@@ -23233,28 +23454,97 @@ var RecordedFeeder = class {
23233
23454
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
23234
23455
  }
23235
23456
  /**
23236
- * Receiver credit: the viewer RENDERED a scrub frame. Restores one push
23237
- * credit (cappedover-acking never banks a burst) and, when a target sat
23238
- * parked on an exhausted budget, resumes the tick. This not any timer — is
23239
- * what paces scrub pushes to what the link actually delivers.
23457
+ * Receiver credit: the viewer received a pushed scrub frame. Settles the push
23458
+ * `seq` namesand every older one still outstanding, since the channel is
23459
+ * ordered restores their credit (capped: over-acking never banks a burst)
23460
+ * and, when a target sat parked on an exhausted budget, resumes the tick.
23461
+ * This — not any timer — is what paces scrub pushes to what the link
23462
+ * actually delivers; {@link SCRUB_ACK_TIMEOUT_MS} is only the bound for when
23463
+ * an ack never comes.
23464
+ *
23465
+ * THE UNIT IS THE PUSH. A sweep puts ~25 AUs on the wire under ONE position
23466
+ * message, so a viewer without `requestVideoFrameCallback` can only ever send
23467
+ * ONE ack for it; the old per-FRAME settle waited for 25 and the drag parked
23468
+ * forever the moment the operator slowed down. A viewer WITH rvfc sends one
23469
+ * ack per rendered frame instead — those surplus acks all name the same
23470
+ * settled `seq` and are no-ops, which is what `seq` is for.
23471
+ *
23472
+ * A `seq` no outstanding push matches (a late ack from a drag that already
23473
+ * ended) banks nothing. An ack without `seq` comes from a viewer older than
23474
+ * the field: it settles the oldest outstanding push, which can only repay too
23475
+ * early — never strand the drag.
23240
23476
  */
23241
- scrubAck() {
23477
+ scrubAck(seq) {
23242
23478
  if (this.disposed) return;
23479
+ const settled = seq === void 0 ? this.scrubOutstanding.length > 0 ? 1 : 0 : this.scrubOutstanding.filter((p) => p.seq <= seq).length;
23480
+ if (settled === 0) return;
23481
+ const now = performance.now();
23482
+ for (const push of this.scrubOutstanding.slice(0, settled)) this.scrubStats?.recordAckRtt(Math.round(now - push.pushedAtMs));
23483
+ this.scrubOutstanding = this.scrubOutstanding.slice(settled);
23484
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + settled);
23485
+ this.resumeScrubTick();
23486
+ }
23487
+ /**
23488
+ * Resume a parked drag: the newest target is still pending and the coalescing
23489
+ * slot is free, so run a leading-edge tick. Never touches a LIVE slot — a
23490
+ * pending re-arm is this tick cycle's own coalescing window and pre-empting it
23491
+ * would push faster than {@link SCRUB_COALESCE_MS}.
23492
+ */
23493
+ resumeScrubTick() {
23494
+ if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
23495
+ }
23496
+ /**
23497
+ * Arm the write-off timer for the OLDEST outstanding push, if one is not
23498
+ * already pending. Newer entries are always due later, so a timer armed for
23499
+ * an entry that gets settled first simply fires early and re-arms — one
23500
+ * harmless wakeup, no bookkeeping.
23501
+ */
23502
+ armScrubAckTimeout() {
23503
+ if (this.scrubAckTimer !== null) return;
23243
23504
  const head = this.scrubOutstanding[0];
23244
23505
  if (head === void 0) return;
23506
+ const dueInMs = Math.max(0, SCRUB_ACK_TIMEOUT_MS - (performance.now() - head.pushedAtMs));
23507
+ this.scrubAckTimer = setTimeout(() => {
23508
+ this.scrubAckTimer = null;
23509
+ this.expireScrubAcks();
23510
+ }, dueInMs);
23511
+ }
23512
+ /** Drop the ack timer (drag exit / teardown). */
23513
+ clearScrubAckTimeout() {
23514
+ if (this.scrubAckTimer === null) return;
23515
+ clearTimeout(this.scrubAckTimer);
23516
+ this.scrubAckTimer = null;
23517
+ }
23518
+ /**
23519
+ * Write off every push that has waited longer than
23520
+ * {@link SCRUB_ACK_TIMEOUT_MS}, give its credit back, and resume the drag.
23521
+ *
23522
+ * This is DROPPED WORK — a frame the viewer may never have painted — so it is
23523
+ * counted on the census every time and logged once per drag. Without it a
23524
+ * single lost ack ends the gesture: the gate is released by acks alone, and
23525
+ * `beginScrub` (its only other reset) is a no-op mid-drag.
23526
+ */
23527
+ expireScrubAcks() {
23528
+ if (this.disposed || !this.scrubbing) return;
23245
23529
  const now = performance.now();
23246
- if (head.firstAckAtMs === null) this.scrubStats?.recordAckRtt(Math.round(now - head.pushedAtMs));
23247
- if (head.frames > 1) {
23248
- this.scrubOutstanding = [{
23249
- frames: head.frames - 1,
23250
- pushedAtMs: head.pushedAtMs,
23251
- firstAckAtMs: head.firstAckAtMs ?? now
23252
- }, ...this.scrubOutstanding.slice(1)];
23253
- return;
23530
+ const expired = this.scrubOutstanding.filter((p) => now - p.pushedAtMs >= SCRUB_ACK_TIMEOUT_MS).length;
23531
+ if (expired > 0) {
23532
+ const frames = this.scrubOutstanding.slice(0, expired).reduce((sum, p) => sum + p.frames, 0);
23533
+ this.scrubOutstanding = this.scrubOutstanding.slice(expired);
23534
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + expired);
23535
+ this.scrubStats?.recordAckTimeout(expired);
23536
+ if (!this.scrubAckTimeoutLogged) {
23537
+ this.scrubAckTimeoutLogged = true;
23538
+ this.deps.onLog?.("scrub-feeder:ack-timeout", {
23539
+ pushes: expired,
23540
+ frames,
23541
+ timeoutMs: SCRUB_ACK_TIMEOUT_MS,
23542
+ pendingEpochMs: this.scrubPendingEpochMs
23543
+ });
23544
+ }
23545
+ this.resumeScrubTick();
23254
23546
  }
23255
- this.scrubOutstanding = this.scrubOutstanding.slice(1);
23256
- this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
23257
- if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
23547
+ this.armScrubAckTimeout();
23258
23548
  }
23259
23549
  /**
23260
23550
  * One coalesced feeder-scrub push. Captures the newest pending target + the
@@ -23307,7 +23597,10 @@ var RecordedFeeder = class {
23307
23597
  coverage = "memo";
23308
23598
  } else {
23309
23599
  seg = this.scrubCache.covering(this.deps.profile, target);
23310
- if (seg) coverage = "cache";
23600
+ if (seg) {
23601
+ coverage = "cache";
23602
+ if (this.scrubPrefetched.has(seg.startMs)) this.scrubStats?.recordPrefetchHit();
23603
+ }
23311
23604
  }
23312
23605
  if (!seg) {
23313
23606
  const locateStartMs = performance.now();
@@ -23427,12 +23720,14 @@ var RecordedFeeder = class {
23427
23720
  else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
23428
23721
  for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
23429
23722
  const pushedAtMs = performance.now();
23723
+ const pushSeq = ++this.scrubPushSeq;
23430
23724
  this.scrubOutstanding = [...this.scrubOutstanding, {
23725
+ seq: pushSeq,
23431
23726
  frames: jumpTravel.length + aus.length,
23432
- pushedAtMs,
23433
- firstAckAtMs: null
23727
+ pushedAtMs
23434
23728
  }];
23435
23729
  this.scrubCredit -= 1;
23730
+ this.armScrubAckTimeout();
23436
23731
  this.clockHasAdvanced = true;
23437
23732
  let runBytes = 0;
23438
23733
  for (const s of jumpTravel) runBytes += s.au.data.length;
@@ -23479,7 +23774,7 @@ var RecordedFeeder = class {
23479
23774
  tickMs: Math.round(pushedAtMs - tickStartMs)
23480
23775
  });
23481
23776
  }
23482
- this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
23777
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget), pushSeq);
23483
23778
  this.maybeScrubPrefetch(seg, effTarget, gen);
23484
23779
  } catch (err) {
23485
23780
  this.scrubStats?.recordFailure();
@@ -23727,28 +24022,41 @@ var RecordedFeeder = class {
23727
24022
  this.scrubDirRun = 0;
23728
24023
  return;
23729
24024
  }
23730
- const dir = target > prev ? 1 : target < prev ? -1 : 0;
24025
+ const stepMs = target - prev;
24026
+ const dir = stepMs > 0 ? 1 : stepMs < 0 ? -1 : 0;
23731
24027
  if (dir === 0) {
23732
24028
  this.scrubDirRun = 0;
23733
24029
  return;
23734
24030
  }
23735
24031
  this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
23736
24032
  if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
23737
- if (this.scrubPrefetchInFlight) return;
23738
- const probeMs = dir === 1 ? seg.startMs + seg.durMs + 1 : seg.startMs - 1;
23739
- if (probeMs < 0) return;
23740
- this.scrubPrefetch(probeMs, gen);
24033
+ const aheadMs = Math.max(Math.abs(stepMs), seg.durMs + 1);
24034
+ const horizons = Math.abs(stepMs) > seg.durMs ? SCRUB_PREFETCH_MAX_INFLIGHT : 1;
24035
+ for (let horizon = 1; horizon <= horizons; horizon++) {
24036
+ if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
24037
+ const probeMs = target + dir * aheadMs * horizon;
24038
+ if (probeMs < 0) continue;
24039
+ if (this.scrubPrefetchProbes.has(probeMs)) continue;
24040
+ if (this.scrubCache.covering(this.deps.profile, probeMs)) continue;
24041
+ this.scrubPrefetch(probeMs, gen);
24042
+ }
23741
24043
  }
23742
24044
  /**
23743
- * Background (second-slot) prefetch: locate + read + demux the fragment
23744
- * covering `probeMs` into `scrubCache`. Bounded to one in flight; aborts at any
24045
+ * Speculative read: locate + read + demux the fragment covering `probeMs` into
24046
+ * `scrubCache`. Bounded by {@link SCRUB_PREFETCH_MAX_INFLIGHT}; aborts at any
23745
24047
  * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
23746
- * swallowed (the neighbour just demuxes on demand). Never pushes and never
24048
+ * swallowed (the fragment just demuxes on demand). Never pushes and never
23747
24049
  * mutates latest-wins state.
24050
+ *
24051
+ * Every read it issues is COUNTED (`prefetchReads` / `prefetchBytes`), and the
24052
+ * fragment it lands is remembered so a later tick hitting it counts as a
24053
+ * `prefetchHit`. Speculation that cannot be shown to pay for itself is just
24054
+ * load on the disk the drag is already waiting for.
23748
24055
  */
23749
24056
  async scrubPrefetch(probeMs, gen) {
23750
- if (this.scrubPrefetchInFlight) return;
23751
- this.scrubPrefetchInFlight = true;
24057
+ if (this.scrubPrefetchInFlight >= SCRUB_PREFETCH_MAX_INFLIGHT) return;
24058
+ this.scrubPrefetchInFlight += 1;
24059
+ this.scrubPrefetchProbes.add(probeMs);
23752
24060
  try {
23753
24061
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
23754
24062
  if (this.scrubCache.covering(this.deps.profile, probeMs)) return;
@@ -23761,9 +24069,12 @@ var RecordedFeeder = class {
23761
24069
  if (loc.kind !== "segment") return;
23762
24070
  const load = await this.loadScrubFragment(loc, probeMs);
23763
24071
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || load === null) return;
24072
+ this.scrubStats?.recordPrefetchRead(load.readBytes);
23764
24073
  this.scrubCache.put(this.deps.profile, load.seg);
24074
+ this.scrubPrefetched.add(load.seg.startMs);
23765
24075
  } catch {} finally {
23766
- this.scrubPrefetchInFlight = false;
24076
+ this.scrubPrefetchInFlight -= 1;
24077
+ this.scrubPrefetchProbes.delete(probeMs);
23767
24078
  }
23768
24079
  }
23769
24080
  /**
@@ -24192,6 +24503,7 @@ var RecordedFeeder = class {
24192
24503
  this.scrubNoFootageLogged = false;
24193
24504
  this.scrubParkLogged = false;
24194
24505
  this.scrubOutstanding = [];
24506
+ this.clearScrubAckTimeout();
24195
24507
  this.scrubPendingHint = null;
24196
24508
  if (this.scrubTimer) {
24197
24509
  clearTimeout(this.scrubTimer);
@@ -24271,7 +24583,7 @@ var TimelineSession = class {
24271
24583
  break;
24272
24584
  }
24273
24585
  case "scrubAck":
24274
- if (this.mode === "recorded") this.feeder?.scrubAck();
24586
+ if (this.mode === "recorded") this.feeder?.scrubAck(msg.seq);
24275
24587
  break;
24276
24588
  case "setRate":
24277
24589
  this.rate = msg.rate;
@@ -24319,12 +24631,18 @@ var TimelineSession = class {
24319
24631
  state
24320
24632
  }));
24321
24633
  }
24322
- /** Emit seam: the broker wires the feeder's onPosition callback to this. */
24323
- _emitPosition(epochMs) {
24634
+ /** Emit seam: the broker wires the feeder's onPosition callback to this.
24635
+ * `pushSeq` rides along only on the position a scrub push emitted — the
24636
+ * viewer echoes it on `scrubAck` so the credit is repaid to the right push. */
24637
+ _emitPosition(epochMs, pushSeq) {
24324
24638
  this.lastPositionMs = epochMs;
24325
- this.deps.session.sendControl(serializeServerMessage({
24639
+ this.deps.session.sendControl(serializeServerMessage(pushSeq === void 0 ? {
24326
24640
  t: "position",
24327
24641
  epochMs
24642
+ } : {
24643
+ t: "position",
24644
+ epochMs,
24645
+ seq: pushSeq
24328
24646
  }));
24329
24647
  }
24330
24648
  emitLiveProfile() {
@@ -33002,7 +33320,7 @@ var BrokerWebrtcServer = class {
33002
33320
  } });
33003
33321
  timeline._emitState(s);
33004
33322
  },
33005
- onPosition: (epochMs) => timeline._emitPosition(epochMs),
33323
+ onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
33006
33324
  onLog: (msg, data) => sessionLogger.info(msg, {
33007
33325
  tags: { deviceId },
33008
33326
  meta: {