@camstack/addon-pipeline 1.2.6 → 1.2.8

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 (41) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +2 -3
  3. package/dist/{chunk-D6vf50IK.js → chunk-emK7D4bc.js} +7 -0
  4. package/dist/detection-pipeline/index.js +104 -8
  5. package/dist/detection-pipeline/index.mjs +103 -8
  6. package/dist/{dist-Bh0H8jM6.js → dist-DNjy_sr4.js} +1374 -925
  7. package/dist/{dist-Bw_V5L0r.mjs → dist-owmpaeIY.mjs} +1379 -924
  8. package/dist/fmp4-keyframes-BzTPoPVn.js +6676 -0
  9. package/dist/fmp4-keyframes-qOgJBjMO.mjs +6669 -0
  10. package/dist/{sheet-geometry-DcpuGTCm.mjs → hub-hostname-DAJXlOgV.js} +6 -39
  11. package/dist/{sheet-geometry-BR-ip0CP.js → hub-hostname-cCknRYKj.mjs} +1 -56
  12. package/dist/{model-download-service-Cp9f4dk6-CwUb5v3Y.mjs → model-download-service-Cp9f4dk6-WO-foD7W.mjs} +26 -1
  13. package/dist/{model-download-service-Cp9f4dk6-Dv-oFwjN.js → model-download-service-Cp9f4dk6-fsdDExML.js} +1 -1
  14. package/dist/motion-wasm/index.js +1 -1
  15. package/dist/motion-wasm/index.mjs +1 -1
  16. package/dist/pipeline-runner/index.js +51 -132
  17. package/dist/pipeline-runner/index.mjs +49 -130
  18. package/dist/recorder/index.js +695 -974
  19. package/dist/recorder/index.mjs +694 -972
  20. package/dist/session-decode/decode-worker-child.js +9 -5
  21. package/dist/session-decode/decode-worker-child.mjs +8 -4
  22. package/dist/{step-definitions-xxdpJX9-.js → step-definitions-C2bNlww3.js} +1 -1
  23. package/dist/{step-definitions-aY4D9Loy.mjs → step-definitions-CkvctwuS.mjs} +1 -1
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DX-Ef8CP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-XdI0RgvI.mjs} +2 -2
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DSfbOjdi.mjs +26 -0
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BdxY9zld.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js--zEZtV2N.mjs} +1 -1
  28. package/dist/stream-broker/{hostInit-DUL71Vjq.mjs → hostInit-DsMIdlwP.mjs} +2 -2
  29. package/dist/stream-broker/index.js +680 -15
  30. package/dist/stream-broker/index.mjs +679 -15
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CGWEeRkz.js → MaskShapeCanvas-DI4BY7W2-CftzR7Pj.js} +1 -1
  33. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-4YbTa3Wp.js → MotionZonesSettings-NcxxQN8r-DPgVIdEn.js} +1 -1
  34. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DL_tAs9-.js → PrivacyMaskSettings-APgPLF7p-D0HqiXHW.js} +1 -1
  35. package/embed-dist/assets/{index-CvBwcYl_.js → index-DRtbV_en.js} +17 -15
  36. package/embed-dist/index.html +1 -1
  37. package/package.json +1 -1
  38. package/python/postprocessors/ctc.py +14 -5
  39. package/python/postprocessors/test_ctc.py +35 -0
  40. package/dist/chunk-BdkLduGY.mjs +0 -5
  41. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-gchR4WlX.mjs +0 -26
@@ -2,10 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_chunk = require("../chunk-D6vf50IK.js");
6
- const require_dist = require("../dist-Bh0H8jM6.js");
5
+ const require_chunk = require("../chunk-emK7D4bc.js");
6
+ const require_dist = require("../dist-DNjy_sr4.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
- const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Dv-oFwjN.js");
8
+ const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
9
+ const require_fmp4_keyframes = require("../fmp4-keyframes-BzTPoPVn.js");
9
10
  let node_child_process = require("node:child_process");
10
11
  let node_url = require("node:url");
11
12
  let node_fs = require("node:fs");
@@ -10411,6 +10412,17 @@ var StreamBroker = class StreamBroker {
10411
10412
  placeholderTimer;
10412
10413
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
10413
10414
  /**
10415
+ * Dial rate-limiter (2026-07-23 storm fix): the earliest wall-clock at
10416
+ * which ANY caller may dial the source again. Armed on every dial ATTEMPT
10417
+ * with the current backoff window. The 30s reconnect timer was honored on
10418
+ * ITS path, but parallel dial channels (demand resume, broker recreation)
10419
+ * re-dialed instantly — an unreachable camera (614 EHOSTUNREACH) looped
10420
+ * hundreds of dials/sec, the relayed log flood ballooned hub-main to
10421
+ * 1.9 GB in 4 h and starved the HTTPS API. This gate makes the backoff a
10422
+ * BROKER-level invariant instead of a per-path courtesy.
10423
+ */
10424
+ nextDialAllowedAt = 0;
10425
+ /**
10414
10426
  * "First video" watchdog. Battery-cam rfc4571 sources sometimes
10415
10427
  * complete TCP handshake on the loopback but the lib's dedicated
10416
10428
  * session never delivers video bytes (camera fails to start the
@@ -10723,6 +10735,7 @@ var StreamBroker = class StreamBroker {
10723
10735
  clearTimeout(this.reconnectTimer);
10724
10736
  this.reconnectTimer = void 0;
10725
10737
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
10738
+ this.nextDialAllowedAt = 0;
10726
10739
  this.lastRefreshRequestAt = 0;
10727
10740
  this.logger?.info("reconnect kicked — publisher refreshed source");
10728
10741
  this.scheduleReconnect();
@@ -10762,6 +10775,21 @@ var StreamBroker = class StreamBroker {
10762
10775
  this.logger?.info("Broker registered idle — no demand, source dial deferred");
10763
10776
  return;
10764
10777
  }
10778
+ const dialWaitMs = this.nextDialAllowedAt - Date.now();
10779
+ if (dialWaitMs > 0) {
10780
+ this._status = "connecting";
10781
+ if (!this.reconnectTimer) {
10782
+ this.logger?.info("dial rate-limited — deferring to backoff window", { meta: { waitMs: dialWaitMs } });
10783
+ this.reconnectTimer = setTimeout(() => {
10784
+ this.reconnectTimer = void 0;
10785
+ if (this.manualStop) return;
10786
+ const src = this.resolveCurrentSource();
10787
+ if (src) this.start(src);
10788
+ }, dialWaitMs);
10789
+ }
10790
+ return;
10791
+ }
10792
+ this.nextDialAllowedAt = Date.now() + this.reconnectDelayMs;
10765
10793
  this._status = "connecting";
10766
10794
  if (source.ffmpegParser) {
10767
10795
  this._status = "connecting";
@@ -11708,6 +11736,7 @@ var StreamBroker = class StreamBroker {
11708
11736
  this.logger?.info("rtmp: streaming");
11709
11737
  this._status = "streaming";
11710
11738
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11739
+ this.nextDialAllowedAt = 0;
11711
11740
  this.stopErrorPlaceholder();
11712
11741
  },
11713
11742
  onError: (error) => {
@@ -11782,6 +11811,7 @@ var StreamBroker = class StreamBroker {
11782
11811
  this.logger?.info("ffmpeg source: streaming");
11783
11812
  this._status = "streaming";
11784
11813
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11814
+ this.nextDialAllowedAt = 0;
11785
11815
  this.stopErrorPlaceholder();
11786
11816
  },
11787
11817
  onError: (error) => {
@@ -11851,6 +11881,7 @@ var StreamBroker = class StreamBroker {
11851
11881
  if (this.stopping) return;
11852
11882
  this._status = "streaming";
11853
11883
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11884
+ this.nextDialAllowedAt = 0;
11854
11885
  this.stopErrorPlaceholder();
11855
11886
  this.cancelFirstVideoWatchdog();
11856
11887
  this.kickRtpActivityWatchdog();
@@ -12160,6 +12191,7 @@ var StreamBroker = class StreamBroker {
12160
12191
  this.destroyRfc4571Reader();
12161
12192
  this._status = "error";
12162
12193
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12194
+ this.nextDialAllowedAt = 0;
12163
12195
  this.lastRefreshRequestAt = 0;
12164
12196
  this.notifySourceRefresh();
12165
12197
  this.scheduleReconnect();
@@ -12221,6 +12253,7 @@ var StreamBroker = class StreamBroker {
12221
12253
  this.destroyNativeClient();
12222
12254
  this._status = "error";
12223
12255
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12256
+ this.nextDialAllowedAt = 0;
12224
12257
  this.scheduleReconnect();
12225
12258
  return;
12226
12259
  }
@@ -12229,6 +12262,7 @@ var StreamBroker = class StreamBroker {
12229
12262
  this.destroyRfc4571Reader();
12230
12263
  this._status = "error";
12231
12264
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12265
+ this.nextDialAllowedAt = 0;
12232
12266
  this.notifySourceRefresh();
12233
12267
  this.scheduleReconnect();
12234
12268
  return;
@@ -12288,11 +12322,7 @@ var StreamBroker = class StreamBroker {
12288
12322
  if (this.manualStop) return;
12289
12323
  const src = this.resolveCurrentSource();
12290
12324
  if (!src) return;
12291
- this._status = "connecting";
12292
- if (src.ffmpegParser) this.startFfmpegSourceReader(src);
12293
- else if (src.type === "rtmp") this.startRtmpReader(src);
12294
- else if (src.type === "rfc4571") this.startRfc4571Reader(src);
12295
- else this.startRtspReader(src);
12325
+ this.start(src);
12296
12326
  }, this.reconnectDelayMs);
12297
12327
  this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS);
12298
12328
  }
@@ -13548,6 +13578,15 @@ var StreamBrokerManager = class StreamBrokerManager {
13548
13578
  * time without an activation handshake.
13549
13579
  */
13550
13580
  brokers = /* @__PURE__ */ new Map();
13581
+ /**
13582
+ * Devices whose `deviceManager` meta says `disabled:true` — mirrored by the
13583
+ * SAME `reconcileAllCatalogs` poll that already reads `listAll` (the D8
13584
+ * reconcile backstop; no event plumbing). While a device is in this set,
13585
+ * `ensureBroker` refuses (re)creation so a retrying audio/frame subscriber
13586
+ * cannot resurrect a released broker, and the reconcile releases any broker
13587
+ * that still exists. Cleared by the same poll on re-enable.
13588
+ */
13589
+ disabledDevices = /* @__PURE__ */ new Set();
13551
13590
  /** profileBrokerId (`${deviceId}/${profile}`) → sourceBrokerId currently aliased. */
13552
13591
  profileRestreamerBindings = /* @__PURE__ */ new Map();
13553
13592
  /** Per-device published cam-stream registry. */
@@ -14162,6 +14201,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14162
14201
  * unchanged behavior.
14163
14202
  */
14164
14203
  async ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId, tier) {
14204
+ if (this.disabledDevices.has(deviceId)) return;
14165
14205
  if (this.brokers.has(brokerId)) return;
14166
14206
  if (this.remoteBrokerAdapters.has(brokerId)) return;
14167
14207
  if (camStreamId === void 0) return;
@@ -14586,7 +14626,33 @@ var StreamBrokerManager = class StreamBrokerManager {
14586
14626
  return;
14587
14627
  }
14588
14628
  const cameras = devices.filter((d) => d.isCamera || d.type === require_dist.DeviceType.Camera);
14589
- await Promise.allSettled(cameras.map((d) => this.reconcileDeviceCatalog(d.id)));
14629
+ for (const d of cameras) if (d.disabled) {
14630
+ const firstSeen = !this.disabledDevices.has(d.id);
14631
+ this.disabledDevices.add(d.id);
14632
+ await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
14633
+ } else this.disabledDevices.delete(d.id);
14634
+ await Promise.allSettled(cameras.filter((d) => !d.disabled).map((d) => this.reconcileDeviceCatalog(d.id)));
14635
+ }
14636
+ /** Release every live broker of a disabled device (idempotent — later poll
14637
+ * ticks find none and no-op). `logTransition` marks the disable EDGE. */
14638
+ async releaseDisabledDeviceBrokers(deviceId, logTransition) {
14639
+ const streams = this.cameraStreams.get(deviceId);
14640
+ if (!streams) return;
14641
+ const live = Array.from(streams.keys()).filter((camStreamId) => this.brokers.has(brokerIdFor(deviceId, camStreamId)));
14642
+ if (live.length === 0) return;
14643
+ if (logTransition || live.length > 0) this.logger.info("device disabled — releasing its brokers (no dial while disabled)", {
14644
+ tags: { deviceId },
14645
+ meta: { camStreamIds: live }
14646
+ });
14647
+ for (const camStreamId of live) await this.releaseBroker(deviceId, camStreamId).catch((err) => {
14648
+ this.logger.warn("disabled-device broker release failed", {
14649
+ tags: {
14650
+ deviceId,
14651
+ camStreamId
14652
+ },
14653
+ meta: { error: require_dist.errMsg(err) }
14654
+ });
14655
+ });
14590
14656
  }
14591
14657
  /**
14592
14658
  * Startup catalog pull: run a full reconcile, then a bounded number of extra
@@ -16330,6 +16396,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16330
16396
  }
16331
16397
  }
16332
16398
  async ensureBroker(deviceId, camStreamId) {
16399
+ if (this.disabledDevices.has(deviceId)) return;
16333
16400
  const cam = this.cameraStreams.get(deviceId)?.get(camStreamId);
16334
16401
  if (!cam) return;
16335
16402
  const brokerId = brokerIdFor(deviceId, camStreamId);
@@ -16880,6 +16947,11 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
16880
16947
  t: require_dist.literal("scrubCommit"),
16881
16948
  epoch: require_dist.number()
16882
16949
  }),
16950
+ require_dist.object({ t: require_dist.literal("scrubStart") }),
16951
+ require_dist.object({
16952
+ t: require_dist.literal("scrubTo"),
16953
+ epoch: require_dist.number()
16954
+ }),
16883
16955
  require_dist.object({
16884
16956
  t: require_dist.literal("setRate"),
16885
16957
  rate: require_dist.number().min(0).max(4)
@@ -17028,6 +17100,31 @@ var WAIT_TICK_MS = 250;
17028
17100
  * instead of merging the two feeds.
17029
17101
  */
17030
17102
  var SEEK_DISCONTINUITY_MS = 250;
17103
+ /**
17104
+ * Coalescing window for feeder-scrub `scrubTo` (experimental). Pointer-move
17105
+ * targets arrive far faster than a segment demux completes; the feeder collapses
17106
+ * them to at most one keyframe push per this window (≈ 16 pushes/s), always
17107
+ * pushing only the LATEST pending target (latest-wins). The FIRST move fires a
17108
+ * leading-edge tick immediately; subsequent moves ride the pending timer.
17109
+ *
17110
+ * Tightened 100 → 60 ms: same-fragment moves are memo/cache hits (zero demux) so
17111
+ * the window is now the dominant per-push cost inside a fragment, and 60 ms is
17112
+ * sustainable there — cross-fragment moves remain paced by the demux + the
17113
+ * one-in-flight guard, so this cannot spawn parallel demuxes.
17114
+ */
17115
+ var SCRUB_COALESCE_MS = 60;
17116
+ /**
17117
+ * Upper bound on the per-scrub-session fragment cache (LRU by segment `startMs`).
17118
+ * A demuxed keyframe set is one AU per fragment, so 8 fragments is a few KB;
17119
+ * back-and-forth drags over this window push instantly with zero re-demux.
17120
+ */
17121
+ var SCRUB_CACHE_MAX = 8;
17122
+ /**
17123
+ * Consecutive same-direction `scrubTo` deltas required before a direction-aware
17124
+ * prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
17125
+ * sustained direction, so a jittery/reversing drag never wastes the slot.
17126
+ */
17127
+ var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
17031
17128
  var RecordedFeeder = class {
17032
17129
  deps;
17033
17130
  clock = new MonotonicClock();
@@ -17058,6 +17155,61 @@ var RecordedFeeder = class {
17058
17155
  * the pre-seek position when the client changes rate right after a seek.
17059
17156
  */
17060
17157
  inFlightSeekEpochMs = null;
17158
+ /**
17159
+ * EXPERIMENTAL feeder-scrub sub-mode. While true the normal advance/feed loop
17160
+ * is suspended and each coalesced `scrubTick` pushes the covering fragment's
17161
+ * nearest keyframe ≤ the drag target. `scrubToken` is the MODE-EXIT guard (not
17162
+ * a per-move token): it is bumped on `beginScrub`, on the commit `seek`, and on
17163
+ * `dispose`, so an in-flight tick whose captured generation no longer matches
17164
+ * drops itself. Latest-wins across rapid moves is handled by COALESCING —
17165
+ * `scrubPendingEpochMs` always holds the newest target, and one tick per
17166
+ * `SCRUB_COALESCE_MS` window processes it.
17167
+ */
17168
+ scrubbing = false;
17169
+ scrubToken = 0;
17170
+ /** The newest un-processed drag target (null when the tick cycle has drained). */
17171
+ scrubPendingEpochMs = null;
17172
+ /**
17173
+ * The active coalescing timer. Non-null means a tick cycle is live (scheduled
17174
+ * OR running its async load) — it is the single re-entrancy guard, so a burst
17175
+ * of `scrubTo` calls coalesce onto `scrubPendingEpochMs` instead of spawning
17176
+ * parallel demuxes.
17177
+ */
17178
+ scrubTimer = null;
17179
+ /**
17180
+ * The fragment demuxed by the last tick (same-fragment memo): consecutive drag
17181
+ * targets inside ONE fragment reuse these access units instead of re-running
17182
+ * readBytes/demux (ffmpeg) per pointer move. Reset on fragment change + exit.
17183
+ */
17184
+ scrubSegment = null;
17185
+ /**
17186
+ * Per-scrub-session LRU of demuxed keyframe sets, keyed by segment `startMs`
17187
+ * (Map insertion order IS the LRU order; a hit re-inserts to mark it most
17188
+ * recent). Held ACROSS re-entrant drags in the same session — recorded footage
17189
+ * is immutable, so a revisited fragment (back-and-forth drag) pushes instantly
17190
+ * with zero re-demux. Bounded to {@link SCRUB_CACHE_MAX}; cleared only on the
17191
+ * commit `seek`, `dispose`, and goLive teardown (all of which dispose/reset the
17192
+ * feeder), never on `beginScrub`.
17193
+ */
17194
+ scrubCache = /* @__PURE__ */ new Map();
17195
+ /**
17196
+ * The last drag target a `scrubTick` PROCESSED (null before the first). The
17197
+ * sign of `(newTarget − this)` is the drag direction that steers prefetch.
17198
+ */
17199
+ scrubPrevTarget = null;
17200
+ /**
17201
+ * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
17202
+ * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
17203
+ * a reversal or an in-fragment no-move resets it.
17204
+ */
17205
+ scrubDirRun = 0;
17206
+ /**
17207
+ * The SECOND bounded concurrency slot: `true` while a direction-aware prefetch
17208
+ * demux is in flight. At most one prefetch runs at a time and it only fills
17209
+ * `scrubCache` — it never pushes, never touches the pending/timer/latest-wins
17210
+ * state, and never blocks the live coalesced tick.
17211
+ */
17212
+ scrubPrefetchInFlight = false;
17061
17213
  constructor(deps) {
17062
17214
  this.deps = deps;
17063
17215
  }
@@ -17069,6 +17221,18 @@ var RecordedFeeder = class {
17069
17221
  */
17070
17222
  async seek(epochMs) {
17071
17223
  const token = ++this.seekToken;
17224
+ if (this.scrubbing) {
17225
+ this.scrubbing = false;
17226
+ this.scrubToken++;
17227
+ this.scrubSegment = null;
17228
+ this.scrubCache.clear();
17229
+ this.scrubPrevTarget = null;
17230
+ this.scrubDirRun = 0;
17231
+ if (this.scrubTimer) {
17232
+ clearTimeout(this.scrubTimer);
17233
+ this.scrubTimer = null;
17234
+ }
17235
+ }
17072
17236
  this.advanceToken++;
17073
17237
  this.inFlightSeekEpochMs = epochMs;
17074
17238
  try {
@@ -17147,6 +17311,200 @@ var RecordedFeeder = class {
17147
17311
  this.seek(this.inFlightSeekEpochMs ?? this.cursorMs);
17148
17312
  }
17149
17313
  /**
17314
+ * EXPERIMENTAL feeder-scrub: enter the scrub sub-mode. Aborts any in-flight
17315
+ * seek/advance loop (latest-wins, exactly like `seek()` does up-front) and
17316
+ * suspends the ~1 Hz position timer — during a drag position is reported per
17317
+ * pushed keyframe instead. `this.rate` is preserved for the post-commit resume
17318
+ * (the commit `seek` re-enters at the same rate).
17319
+ *
17320
+ * Idempotent WITHIN a drag: a second `beginScrub` while already scrubbing is a
17321
+ * NO-OP. The app may re-send `scrubStart` mid-gesture (the observed repeated
17322
+ * `scrubStart` storm), and resetting the token/coalescing state on each re-send
17323
+ * would abort in-flight keyframe work and restart the drag — the exact stutter
17324
+ * this guards against. The ONLY exit from scrub mode is the commit `seek()`
17325
+ * (which flips `scrubbing` false and clears the session cache) or `dispose()`;
17326
+ * a genuinely NEW drag is therefore always preceded by that exit, so its
17327
+ * `beginScrub` runs the full reset below. (D-scrub-ux)
17328
+ */
17329
+ beginScrub() {
17330
+ if (this.disposed || this.scrubbing) return;
17331
+ this.seekToken++;
17332
+ this.advanceToken++;
17333
+ this.scrubbing = true;
17334
+ this.scrubToken++;
17335
+ this.scrubPendingEpochMs = null;
17336
+ this.scrubSegment = null;
17337
+ this.scrubPrevTarget = null;
17338
+ this.scrubDirRun = 0;
17339
+ if (this.scrubTimer) {
17340
+ clearTimeout(this.scrubTimer);
17341
+ this.scrubTimer = null;
17342
+ }
17343
+ this.stopPositionReports();
17344
+ }
17345
+ /**
17346
+ * EXPERIMENTAL feeder-scrub: register a drag target. Records the newest target
17347
+ * and, if no tick cycle is live, starts a leading-edge tick. A burst of moves
17348
+ * within the coalescing window collapses onto `scrubPendingEpochMs` (latest-
17349
+ * wins) so only one keyframe is demuxed+pushed per `SCRUB_COALESCE_MS`. A
17350
+ * `scrubTo` without a prior `beginScrub` (or after commit/dispose) is a no-op.
17351
+ */
17352
+ scrubTo(epochMs) {
17353
+ if (this.disposed || !this.scrubbing) return;
17354
+ this.scrubPendingEpochMs = epochMs;
17355
+ if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
17356
+ }
17357
+ /**
17358
+ * One coalesced feeder-scrub push. Captures the newest pending target + the
17359
+ * current mode generation, resolves the covering fragment (reusing the memo'd
17360
+ * one when the target stays inside it), picks the nearest keyframe ≤ the
17361
+ * target, stamps a discontinuity, and pushes that single access unit. Re-arms
17362
+ * itself after `SCRUB_COALESCE_MS` while more moves are pending; otherwise
17363
+ * ends the cycle. Video only — no audio during scrub. A gap target holds the
17364
+ * last pushed frame (no push, no error).
17365
+ */
17366
+ async scrubTick() {
17367
+ const gen = this.scrubToken;
17368
+ const target = this.scrubPendingEpochMs;
17369
+ this.scrubPendingEpochMs = null;
17370
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken || target === null) {
17371
+ this.scrubTimer = null;
17372
+ return;
17373
+ }
17374
+ try {
17375
+ const loc = await this.withTimeout(this.deps.locate({
17376
+ deviceId: this.deps.deviceId,
17377
+ profile: this.deps.profile,
17378
+ epochMs: target
17379
+ }));
17380
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
17381
+ if (loc.kind === "gap") return;
17382
+ let seg = this.scrubSegment;
17383
+ if (!seg || seg.startMs !== loc.startMs) {
17384
+ seg = this.scrubCacheGet(loc.startMs);
17385
+ if (!seg) {
17386
+ const loaded = await this.loadAus(loc.startMs, false, true);
17387
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
17388
+ seg = {
17389
+ startMs: loc.startMs,
17390
+ durMs: loc.durMs,
17391
+ aus: loaded.video
17392
+ };
17393
+ this.scrubCachePut(seg);
17394
+ }
17395
+ this.scrubSegment = seg;
17396
+ }
17397
+ const au = seg.aus[this.pickScrubKeyframe(seg, target)];
17398
+ if (!au) return;
17399
+ if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
17400
+ this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
17401
+ this.clockHasAdvanced = true;
17402
+ this.deps.onPosition(this.clampIntoSegment(seg, target));
17403
+ this.maybeScrubPrefetch(seg, target, gen);
17404
+ } catch {} finally {
17405
+ if (gen !== this.scrubToken) {} else if (!this.disposed && this.scrubbing && this.scrubPendingEpochMs !== null) this.scrubTimer = setTimeout(() => void this.scrubTick(), SCRUB_COALESCE_MS);
17406
+ else this.scrubTimer = null;
17407
+ }
17408
+ }
17409
+ /**
17410
+ * Index of the nearest keyframe at-or-before the drag target within `seg`
17411
+ * (default 0 — the fragment's params-fused IDR, which the demux contract
17412
+ * guarantees). Scans the access units whose PTS offset is ≤ the target offset
17413
+ * and returns the LAST keyframe among them, so the pushed frame is standalone-
17414
+ * decodable yet as close to the target as the GOP structure allows.
17415
+ */
17416
+ pickScrubKeyframe(seg, target) {
17417
+ const first = seg.aus[0];
17418
+ if (!first) return 0;
17419
+ const offsetMs = target - seg.startMs;
17420
+ let idx = 0;
17421
+ for (let i = 0; i < seg.aus.length; i++) {
17422
+ const au = seg.aus[i];
17423
+ if (!au) continue;
17424
+ if (au.ptsMs - first.ptsMs > offsetMs) break;
17425
+ if (au.keyframe) idx = i;
17426
+ }
17427
+ return idx;
17428
+ }
17429
+ /**
17430
+ * Scrub fragment cache lookup with LRU touch: a hit is re-inserted so it moves
17431
+ * to the most-recent end of the Map's insertion order (the eviction cursor is
17432
+ * the oldest key).
17433
+ */
17434
+ scrubCacheGet(startMs) {
17435
+ const seg = this.scrubCache.get(startMs);
17436
+ if (!seg) return null;
17437
+ this.scrubCache.delete(startMs);
17438
+ this.scrubCache.set(startMs, seg);
17439
+ return seg;
17440
+ }
17441
+ /** Insert (or refresh) a demuxed fragment, evicting the oldest over the cap. */
17442
+ scrubCachePut(seg) {
17443
+ this.scrubCache.delete(seg.startMs);
17444
+ this.scrubCache.set(seg.startMs, seg);
17445
+ while (this.scrubCache.size > SCRUB_CACHE_MAX) {
17446
+ const oldest = this.scrubCache.keys().next().value;
17447
+ if (oldest === void 0) break;
17448
+ this.scrubCache.delete(oldest);
17449
+ }
17450
+ }
17451
+ /**
17452
+ * Update the drag-direction run from the just-served target and, once the drag
17453
+ * shows a sustained direction, kick off a single background prefetch of the
17454
+ * NEXT fragment in that direction. Prefetch fills the cache only — it never
17455
+ * pushes, so crossing into the neighbour later is an instant cache hit.
17456
+ */
17457
+ maybeScrubPrefetch(seg, target, gen) {
17458
+ const prev = this.scrubPrevTarget;
17459
+ this.scrubPrevTarget = target;
17460
+ if (prev === null) {
17461
+ this.scrubDirRun = 0;
17462
+ return;
17463
+ }
17464
+ const dir = target > prev ? 1 : target < prev ? -1 : 0;
17465
+ if (dir === 0) {
17466
+ this.scrubDirRun = 0;
17467
+ return;
17468
+ }
17469
+ this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
17470
+ if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
17471
+ if (this.scrubPrefetchInFlight) return;
17472
+ const probeMs = dir === 1 ? seg.startMs + seg.durMs + 1 : seg.startMs - 1;
17473
+ if (probeMs < 0) return;
17474
+ this.scrubPrefetch(probeMs, gen);
17475
+ }
17476
+ /**
17477
+ * Background (second-slot) prefetch: locate + keyframe-only demux the fragment
17478
+ * covering `probeMs` into `scrubCache`. Bounded to one in flight; aborts at any
17479
+ * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
17480
+ * swallowed (the neighbour just demuxes on demand). Never pushes and never
17481
+ * mutates latest-wins state.
17482
+ */
17483
+ async scrubPrefetch(probeMs, gen) {
17484
+ if (this.scrubPrefetchInFlight) return;
17485
+ this.scrubPrefetchInFlight = true;
17486
+ try {
17487
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
17488
+ const loc = await this.withTimeout(this.deps.locate({
17489
+ deviceId: this.deps.deviceId,
17490
+ profile: this.deps.profile,
17491
+ epochMs: probeMs
17492
+ }));
17493
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
17494
+ if (loc.kind !== "segment") return;
17495
+ if (this.scrubCache.has(loc.startMs)) return;
17496
+ const loaded = await this.loadAus(loc.startMs, false, true);
17497
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
17498
+ this.scrubCachePut({
17499
+ startMs: loc.startMs,
17500
+ durMs: loc.durMs,
17501
+ aus: loaded.video
17502
+ });
17503
+ } catch {} finally {
17504
+ this.scrubPrefetchInFlight = false;
17505
+ }
17506
+ }
17507
+ /**
17150
17508
  * Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
17151
17509
  * paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
17152
17510
  * at a segment end crosses into the next segment (gaps are skipped via the
@@ -17158,7 +17516,7 @@ var RecordedFeeder = class {
17158
17516
  * step is still in flight.
17159
17517
  */
17160
17518
  stepFrame(dir) {
17161
- if (this.disposed || this.rate !== 0 || this.stepInFlight) return;
17519
+ if (this.disposed || this.rate !== 0 || this.stepInFlight || this.scrubbing) return;
17162
17520
  this.stepInFlight = true;
17163
17521
  this.doStep(dir).catch(() => {}).finally(() => {
17164
17522
  this.stepInFlight = false;
@@ -17485,8 +17843,10 @@ var RecordedFeeder = class {
17485
17843
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
17486
17844
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
17487
17845
  * never blocks video). `wantAudio: false` skips the audio transcode entirely
17488
- * (paused scrubs would throw it away). */
17489
- async loadAus(startMs, wantAudio = true) {
17846
+ * (paused scrubs would throw it away). `useScrubDemux: true` selects the
17847
+ * keyframe-only scrub demux (`deps.demuxScrub`, falling back to `deps.demux`
17848
+ * when absent — flag-off byte-identity). */
17849
+ async loadAus(startMs, wantAudio = true, useScrubDemux = false) {
17490
17850
  const token = this.seekToken;
17491
17851
  const bytes = await this.withTimeout(this.deps.readBytes({
17492
17852
  deviceId: this.deps.deviceId,
@@ -17494,7 +17854,8 @@ var RecordedFeeder = class {
17494
17854
  startMs
17495
17855
  }));
17496
17856
  if (this.disposed || token !== this.seekToken) return null;
17497
- const video = await this.withTimeout(this.deps.demux(bytes));
17857
+ const demux = useScrubDemux && this.deps.demuxScrub ? this.deps.demuxScrub : this.deps.demux;
17858
+ const video = await this.withTimeout(demux(bytes));
17498
17859
  if (this.disposed || token !== this.seekToken) return null;
17499
17860
  let audio = [];
17500
17861
  if (wantAudio && this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
@@ -17543,6 +17904,16 @@ var RecordedFeeder = class {
17543
17904
  this.disposed = true;
17544
17905
  this.seekToken++;
17545
17906
  this.advanceToken++;
17907
+ this.scrubbing = false;
17908
+ this.scrubToken++;
17909
+ this.scrubSegment = null;
17910
+ this.scrubCache.clear();
17911
+ this.scrubPrevTarget = null;
17912
+ this.scrubDirRun = 0;
17913
+ if (this.scrubTimer) {
17914
+ clearTimeout(this.scrubTimer);
17915
+ this.scrubTimer = null;
17916
+ }
17546
17917
  this.stopPositionReports();
17547
17918
  }
17548
17919
  };
@@ -17576,6 +17947,12 @@ var TimelineSession = class {
17576
17947
  case "scrubCommit":
17577
17948
  await (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).seek(msg.epoch);
17578
17949
  break;
17950
+ case "scrubStart":
17951
+ (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
17952
+ break;
17953
+ case "scrubTo":
17954
+ if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
17955
+ break;
17579
17956
  case "setRate":
17580
17957
  this.feeder?.setRate(msg.rate);
17581
17958
  break;
@@ -17895,7 +18272,7 @@ function demuxMpegTsToAnnexB(ts) {
17895
18272
  let current = null;
17896
18273
  const finalize = () => {
17897
18274
  if (current === null) return;
17898
- const pes = current.length === 1 ? current[0] : concat(current);
18275
+ const pes = current.length === 1 ? current[0] : concat$1(current);
17899
18276
  current = null;
17900
18277
  const split = splitPes(pes);
17901
18278
  if (split === null || split.es.length === 0) return;
@@ -17926,7 +18303,7 @@ function demuxMpegTsToAnnexB(ts) {
17926
18303
  };
17927
18304
  }
17928
18305
  /** Concatenate TS payload fragments into one contiguous PES buffer. */
17929
- function concat(parts) {
18306
+ function concat$1(parts) {
17930
18307
  let total = 0;
17931
18308
  for (const p of parts) total += p.length;
17932
18309
  const out = new Uint8Array(total);
@@ -17983,6 +18360,33 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
17983
18360
  "pipe:1"
17984
18361
  ];
17985
18362
  /**
18363
+ * ffmpeg arguments for the SCRUB keyframe-only demux: same crash-isolated
18364
+ * `-c:v copy` mpegts remux as {@link SEGMENT_DEMUX_FFMPEG_ARGS}, but `-frames:v 1`
18365
+ * stops after the FIRST output access unit — a recorded fragment is a single
18366
+ * IDR-led GOP, so frame 1 is the params-fused keyframe the scrub sub-mode needs
18367
+ * (it never renders a delta frame). This reliably yields exactly the IDR with
18368
+ * its VPS/SPS/PPS fused, whereas input-side `-discard nokey` is a no-op over the
18369
+ * `pipe:0` fragmented-mp4 demuxer (empirically kept 39/40 packets). Exported for
18370
+ * the spec.
18371
+ */
18372
+ var SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS = [
18373
+ "-hide_banner",
18374
+ "-loglevel",
18375
+ "error",
18376
+ "-i",
18377
+ "pipe:0",
18378
+ "-map",
18379
+ "0:v:0",
18380
+ "-frames:v",
18381
+ "1",
18382
+ "-c:v",
18383
+ "copy",
18384
+ "-an",
18385
+ "-f",
18386
+ "mpegts",
18387
+ "pipe:1"
18388
+ ];
18389
+ /**
17986
18390
  * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
17987
18391
  * access units.
17988
18392
  *
@@ -18008,6 +18412,253 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
18008
18412
  codec
18009
18413
  }));
18010
18414
  }
18415
+ /**
18416
+ * Keyframe-only demux for the feeder-scrub sub-mode: remux with `-frames:v 1`
18417
+ * (single-GOP fragment ⇒ the one IDR) and return ONLY keyframe access units.
18418
+ * The scrub path never renders delta frames — it always pushes the nearest
18419
+ * keyframe ≤ the drag target — so extracting only the IDR keeps the per-session
18420
+ * scrub fragment cache tiny (one AU/fragment instead of the full ~40) with no
18421
+ * loss of scrub fidelity.
18422
+ *
18423
+ * Wall time per fragment is dominated by the ffmpeg subprocess spawn, so this is
18424
+ * NOT meaningfully faster per fragment than {@link demuxSegmentToAnnexB}; the
18425
+ * frequency win comes from the feeder's fragment cache + direction-aware
18426
+ * prefetch (which eliminate / overlap the demux), for which this compact
18427
+ * keyframe-only payload is the cache unit.
18428
+ *
18429
+ * Defensive parse-side filter: keeps `keyframe` AUs, falling back to the first
18430
+ * AU (the demux contract's params-fused IDR) if none were flagged, so the result
18431
+ * is keyframes-only yet never empty for a valid fragment.
18432
+ *
18433
+ * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
18434
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
18435
+ */
18436
+ async function demuxSegmentKeyframesToAnnexB(bytes, ffmpegPath = "ffmpeg") {
18437
+ const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
18438
+ ffmpegPath,
18439
+ args: SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS,
18440
+ input: bytes
18441
+ }));
18442
+ const mapped = accessUnits.map((au) => ({
18443
+ data: au.data,
18444
+ ptsMs: au.ptsMs,
18445
+ keyframe: au.keyframe,
18446
+ codec
18447
+ }));
18448
+ const keyframes = mapped.filter((au) => au.keyframe);
18449
+ return keyframes.length > 0 ? keyframes : mapped.slice(0, 1);
18450
+ }
18451
+ //#endregion
18452
+ //#region src/stream-broker/recorded/avcc-annexb.ts
18453
+ /**
18454
+ * avcc-annexb — pure byte helpers to turn an ISO-BMFF (mp4) length-prefixed
18455
+ * video sample plus its `avcC`/`hvcC` decoder config into an Annex-B access
18456
+ * unit with the parameter sets prepended (SPS/PPS for H.264, VPS/SPS/PPS for
18457
+ * H.265).
18458
+ *
18459
+ * mp4 stores VCL NALs length-prefixed (AVCC/HVCC: a 1–4 byte big-endian length
18460
+ * field, `lengthSizeMinusOne+1`, before each NAL) and keeps the parameter sets
18461
+ * OUT of the samples, inside the `avcC`/`hvcC` box. A raw decoder / RTP
18462
+ * packetizer needs Annex-B start codes (`00 00 00 01`) AND the parameter sets
18463
+ * fused onto every IDR. These helpers do exactly that in JS — no ffmpeg, no
18464
+ * decode, byte copies only — so the feeder-scrub keyframe path can skip the
18465
+ * per-fragment ffmpeg spawn.
18466
+ *
18467
+ * All functions are pure and total for well-formed input; malformed config /
18468
+ * sample bytes throw a `RangeError` (the caller treats a throw as "fall back to
18469
+ * ffmpeg"). The `description` bytes are the box payload WITH the 8-byte box
18470
+ * header already stripped (i.e. starting at `configurationVersion`), matching
18471
+ * `mp4box`'s `avcC`/`hvcC` DataStream output minus header — see
18472
+ * `recorder/scrub/fmp4-keyframes.ts` `extractDescription`.
18473
+ */
18474
+ /** Annex-B 4-byte start code prepended before every emitted NAL. */
18475
+ var START_CODE = Uint8Array.of(0, 0, 0, 1);
18476
+ /** Big-endian unsigned read of `size` (1..4) bytes at `off`. */
18477
+ function readUint(bytes, off, size) {
18478
+ if (off + size > bytes.length) throw new RangeError(`readUint out of range: off=${off} size=${size} len=${bytes.length}`);
18479
+ let v = 0;
18480
+ for (let i = 0; i < size; i++) v = v * 256 + bytes[off + i];
18481
+ return v;
18482
+ }
18483
+ /**
18484
+ * NAL length-prefix size in bytes (`lengthSizeMinusOne + 1`, 1..4) read from the
18485
+ * decoder config. `avcC` carries it at byte 4 (low 2 bits); `hvcC` at byte 21
18486
+ * (low 2 bits).
18487
+ */
18488
+ function parseNalLengthSize(description, isHevc) {
18489
+ const idx = isHevc ? 21 : 4;
18490
+ if (description.length <= idx) throw new RangeError(`config too short for lengthSizeMinusOne (len=${description.length})`);
18491
+ return (description[idx] & 3) + 1;
18492
+ }
18493
+ /** Concatenate byte chunks into one Uint8Array. */
18494
+ function concat(chunks) {
18495
+ let total = 0;
18496
+ for (const c of chunks) total += c.length;
18497
+ const out = new Uint8Array(total);
18498
+ let off = 0;
18499
+ for (const c of chunks) {
18500
+ out.set(c, off);
18501
+ off += c.length;
18502
+ }
18503
+ return out;
18504
+ }
18505
+ /** Push one Annex-B NAL (start code + payload) onto `chunks`. */
18506
+ function pushNal(chunks, payload) {
18507
+ chunks.push(START_CODE, payload);
18508
+ }
18509
+ /**
18510
+ * Parse the `avcC` parameter sets (SPS then PPS), emitting each as an Annex-B
18511
+ * NAL. Layout after the 4-byte profile/level prefix: byte 4 low-2 = length
18512
+ * size; byte 5 low-5 = numSPS; then `[u16 len][SPS]…`; then `[u8 numPPS]`; then
18513
+ * `[u16 len][PPS]…`.
18514
+ */
18515
+ function extractAvcParameterSets(desc) {
18516
+ const chunks = [];
18517
+ const numSps = desc[5] & 31;
18518
+ let p = 6;
18519
+ for (let i = 0; i < numSps; i++) {
18520
+ const len = readUint(desc, p, 2);
18521
+ p += 2;
18522
+ if (p + len > desc.length) throw new RangeError("avcC SPS length overruns config");
18523
+ pushNal(chunks, desc.subarray(p, p + len));
18524
+ p += len;
18525
+ }
18526
+ const numPps = readUint(desc, p, 1);
18527
+ p += 1;
18528
+ for (let i = 0; i < numPps; i++) {
18529
+ const len = readUint(desc, p, 2);
18530
+ p += 2;
18531
+ if (p + len > desc.length) throw new RangeError("avcC PPS length overruns config");
18532
+ pushNal(chunks, desc.subarray(p, p + len));
18533
+ p += len;
18534
+ }
18535
+ return chunks;
18536
+ }
18537
+ /**
18538
+ * Parse the `hvcC` parameter-set arrays (typically VPS, SPS, PPS in order),
18539
+ * emitting each contained NAL as Annex-B. Layout: byte 22 = numOfArrays; then
18540
+ * per array `[u8 flags+NALtype][u16 numNalus]` followed by `numNalus` ×
18541
+ * `[u16 len][NAL]`.
18542
+ */
18543
+ function extractHevcParameterSets(desc) {
18544
+ const chunks = [];
18545
+ const numArrays = readUint(desc, 22, 1);
18546
+ let p = 23;
18547
+ for (let a = 0; a < numArrays; a++) {
18548
+ p += 1;
18549
+ const numNalus = readUint(desc, p, 2);
18550
+ p += 2;
18551
+ for (let n = 0; n < numNalus; n++) {
18552
+ const len = readUint(desc, p, 2);
18553
+ p += 2;
18554
+ if (p + len > desc.length) throw new RangeError("hvcC NAL length overruns config");
18555
+ pushNal(chunks, desc.subarray(p, p + len));
18556
+ p += len;
18557
+ }
18558
+ }
18559
+ return chunks;
18560
+ }
18561
+ /**
18562
+ * Parameter sets from the decoder config as one Annex-B blob (each NAL led by a
18563
+ * `00 00 00 01` start code): SPS+PPS for H.264, VPS+SPS+PPS (+ any hvcC arrays)
18564
+ * for H.265. Throws on a malformed config.
18565
+ */
18566
+ function extractParameterSetsAnnexB(description, isHevc) {
18567
+ return concat(isHevc ? extractHevcParameterSets(description) : extractAvcParameterSets(description));
18568
+ }
18569
+ /**
18570
+ * Convert one length-prefixed (AVCC/HVCC) sample into Annex-B: walk each
18571
+ * `nalLengthSize`-byte length field, replacing it with a `00 00 00 01` start
18572
+ * code. Handles multi-NAL samples (multiple slices / SEI + VCL). Throws if a
18573
+ * length field overruns the sample.
18574
+ */
18575
+ function avccSampleToAnnexB(sample, nalLengthSize) {
18576
+ const chunks = [];
18577
+ let p = 0;
18578
+ while (p + nalLengthSize <= sample.length) {
18579
+ const len = readUint(sample, p, nalLengthSize);
18580
+ p += nalLengthSize;
18581
+ if (len === 0) continue;
18582
+ if (p + len > sample.length) throw new RangeError("AVCC NAL length overruns sample");
18583
+ pushNal(chunks, sample.subarray(p, p + len));
18584
+ p += len;
18585
+ }
18586
+ if (chunks.length === 0) throw new RangeError("AVCC sample yielded no NALs");
18587
+ return concat(chunks);
18588
+ }
18589
+ /**
18590
+ * Build a standalone-decodable Annex-B keyframe access unit: the config's
18591
+ * parameter sets, then the sample's NALs, all Annex-B framed. Throws on
18592
+ * malformed input (caller falls back to the ffmpeg demux).
18593
+ */
18594
+ function buildKeyframeAnnexB(sample, description, isHevc) {
18595
+ const nalLengthSize = parseNalLengthSize(description, isHevc);
18596
+ return concat([extractParameterSetsAnnexB(description, isHevc), avccSampleToAnnexB(sample, nalLengthSize)]);
18597
+ }
18598
+ //#endregion
18599
+ //#region src/stream-broker/recorded/segment-demux-js.ts
18600
+ /**
18601
+ * segment-demux-js — PURE-JS keyframe demux for the feeder-scrub sub-mode.
18602
+ *
18603
+ * Replaces the per-fragment ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB},
18604
+ * a ~140 ms subprocess floor) with an in-process mp4box parse. A recorded `.m4s`
18605
+ * is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
18606
+ * carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
18607
+ * sample(s) are all in the bytes we already hold — no init segment to fetch.
18608
+ *
18609
+ * Flow: reuse {@link extractKeyframes} (the proven mp4box sync-sample demux the
18610
+ * scrub-pack service already ships — box parsing is NOT duplicated here) to get
18611
+ * the codec `description` + length-prefixed sync samples, then
18612
+ * {@link buildKeyframeAnnexB} converts each to a standalone-decodable Annex-B
18613
+ * access unit with the parameter sets fused on. The result matches the
18614
+ * {@link AccessUnit} contract the ffmpeg path produces: `codec` H264|H265,
18615
+ * `keyframe:true`, and `ptsMs` segment-relative (offset from the first sync
18616
+ * sample — the only thing the feeder reads, via `au.ptsMs - first.ptsMs`).
18617
+ *
18618
+ * Anchoring `extractKeyframes(bytes, 0)` makes the first sync sample's `ts` = 0,
18619
+ * so `ptsMs` is a plain segment-relative offset in ms, exactly like the ffmpeg
18620
+ * demux's PES-derived deltas — `clampIntoSegment`/position reporting are
18621
+ * unaffected.
18622
+ */
18623
+ /** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
18624
+ function isHevcCodec(codec) {
18625
+ return /^h[ev][vc]1\./i.test(codec);
18626
+ }
18627
+ /**
18628
+ * Pure-JS keyframe-only demux of one self-contained `.m4s` fragment. Returns the
18629
+ * fragment's sync-sample access unit(s) as params-fused Annex-B, or `null` if
18630
+ * mp4box found no video / no decodable keyframe (caller falls back to ffmpeg).
18631
+ * Throws only on a genuinely malformed decoder config / sample — the wrapper
18632
+ * ({@link demuxSegmentKeyframesPreferJs}) turns that into an ffmpeg fallback.
18633
+ */
18634
+ async function demuxSegmentKeyframesJs(bytes) {
18635
+ const extracted = await require_fmp4_keyframes.extractKeyframes(bytes, 0);
18636
+ if (!extracted || extracted.keyframes.length === 0) return null;
18637
+ const isHevc = isHevcCodec(extracted.codec);
18638
+ const codec = isHevc ? "H265" : "H264";
18639
+ return extracted.keyframes.slice(0, 1).map((k) => ({
18640
+ data: buildKeyframeAnnexB(k.data, extracted.description, isHevc),
18641
+ ptsMs: k.ts,
18642
+ keyframe: true,
18643
+ codec
18644
+ }));
18645
+ }
18646
+ /**
18647
+ * Feeder-scrub demux: try the pure-JS keyframe extractor first, fall back to the
18648
+ * ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB}) if the JS parse yields no
18649
+ * keyframe or throws on malformed bytes. Preserves the exact {@link AccessUnit}
18650
+ * contract either way, so it is a drop-in for `RecordedFeederDeps.demuxScrub`.
18651
+ */
18652
+ async function demuxSegmentKeyframesPreferJs(bytes, opts = {}) {
18653
+ try {
18654
+ const js = await demuxSegmentKeyframesJs(bytes);
18655
+ if (js && js.length > 0) return js;
18656
+ opts.onFfmpegFallback?.("mp4box parse found no keyframe");
18657
+ } catch (err) {
18658
+ opts.onFfmpegFallback?.(`mp4box parse threw: ${String(err)}`);
18659
+ }
18660
+ return demuxSegmentKeyframesToAnnexB(bytes, opts.ffmpegPath);
18661
+ }
18011
18662
  //#endregion
18012
18663
  //#region src/stream-broker/recorded/segment-audio.ts
18013
18664
  /**
@@ -18882,6 +19533,7 @@ var BrokerWebrtcServer = class {
18882
19533
  const locateRecorded = this.locateRecordedSegment;
18883
19534
  const readRecorded = this.readRecordedSegmentBytes;
18884
19535
  const resolveFfmpeg = this.resolveFfmpegPath;
19536
+ let scrubJsFallbackLogged = false;
18885
19537
  if (locateRecorded && readRecorded) {
18886
19538
  let timeline;
18887
19539
  timeline = new TimelineSession({
@@ -18893,6 +19545,19 @@ var BrokerWebrtcServer = class {
18893
19545
  locate: locateRecorded,
18894
19546
  readBytes: readRecorded,
18895
19547
  demux: (b) => demuxSegmentToAnnexB(b, resolveFfmpeg?.()),
19548
+ demuxScrub: (b) => demuxSegmentKeyframesPreferJs(b, {
19549
+ ffmpegPath: resolveFfmpeg?.(),
19550
+ onFfmpegFallback: (reason) => {
19551
+ if (scrubJsFallbackLogged) return;
19552
+ scrubJsFallbackLogged = true;
19553
+ sessionLogger.warn("recorded scrub: JS keyframe demux failed — ffmpeg fallback (once/session)", { meta: {
19554
+ sessionId,
19555
+ deviceId,
19556
+ profile,
19557
+ reason
19558
+ } });
19559
+ }
19560
+ }),
18896
19561
  demuxAudio: (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.()),
18897
19562
  sink: {
18898
19563
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),