@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
@@ -1,7 +1,7 @@
1
- import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { $ as parseProfileBrokerId, G as DeviceFeature, H as errMsg, K as DeviceType, O as maskUrlCredentials, U as BaseAddon, V as webrtcSessionCapability, W as CAM_PROFILE_ORDER, X as makeProfileBrokerId, Y as isEvent, Z as makeSourceBrokerId, at as discriminatedUnion, ct as number, dt as string, ft as union, g as addonWidgetsSourceCapability, it as boolean, j as nodePin, l as EncodeProfileSchema, lt as object, m as RingBuffer, nt as _enum, pt as EventCategory, q as createEvent, rt as array, st as literal, ut as record, y as cameraStreamsCapability, z as streamBrokerCapability } from "../dist-Bw_V5L0r.mjs";
1
+ import { l as __require, n as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-WO-foD7W.mjs";
2
+ import { B as webrtcSessionCapability, G as DeviceType, H as BaseAddon, J as isEvent, K as createEvent, O as maskUrlCredentials, Q as parseProfileBrokerId, R as streamBrokerCapability, U as CAM_PROFILE_ORDER, V as errMsg, W as DeviceFeature, X as makeSourceBrokerId, Y as makeProfileBrokerId, ct as object, dt as union, ft as EventCategory, g as addonWidgetsSourceCapability, it as discriminatedUnion, j as nodePin, l as EncodeProfileSchema, lt as record, m as RingBuffer, nt as array, ot as literal, rt as boolean, st as number, tt as _enum, ut as string, y as cameraStreamsCapability } from "../dist-owmpaeIY.mjs";
3
3
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
4
- import { n as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
4
+ import { t as extractKeyframes } from "../fmp4-keyframes-qOgJBjMO.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import { spawn } from "node:child_process";
7
7
  import { pathToFileURL } from "node:url";
@@ -10406,6 +10406,17 @@ var StreamBroker = class StreamBroker {
10406
10406
  placeholderTimer;
10407
10407
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
10408
10408
  /**
10409
+ * Dial rate-limiter (2026-07-23 storm fix): the earliest wall-clock at
10410
+ * which ANY caller may dial the source again. Armed on every dial ATTEMPT
10411
+ * with the current backoff window. The 30s reconnect timer was honored on
10412
+ * ITS path, but parallel dial channels (demand resume, broker recreation)
10413
+ * re-dialed instantly — an unreachable camera (614 EHOSTUNREACH) looped
10414
+ * hundreds of dials/sec, the relayed log flood ballooned hub-main to
10415
+ * 1.9 GB in 4 h and starved the HTTPS API. This gate makes the backoff a
10416
+ * BROKER-level invariant instead of a per-path courtesy.
10417
+ */
10418
+ nextDialAllowedAt = 0;
10419
+ /**
10409
10420
  * "First video" watchdog. Battery-cam rfc4571 sources sometimes
10410
10421
  * complete TCP handshake on the loopback but the lib's dedicated
10411
10422
  * session never delivers video bytes (camera fails to start the
@@ -10718,6 +10729,7 @@ var StreamBroker = class StreamBroker {
10718
10729
  clearTimeout(this.reconnectTimer);
10719
10730
  this.reconnectTimer = void 0;
10720
10731
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
10732
+ this.nextDialAllowedAt = 0;
10721
10733
  this.lastRefreshRequestAt = 0;
10722
10734
  this.logger?.info("reconnect kicked — publisher refreshed source");
10723
10735
  this.scheduleReconnect();
@@ -10757,6 +10769,21 @@ var StreamBroker = class StreamBroker {
10757
10769
  this.logger?.info("Broker registered idle — no demand, source dial deferred");
10758
10770
  return;
10759
10771
  }
10772
+ const dialWaitMs = this.nextDialAllowedAt - Date.now();
10773
+ if (dialWaitMs > 0) {
10774
+ this._status = "connecting";
10775
+ if (!this.reconnectTimer) {
10776
+ this.logger?.info("dial rate-limited — deferring to backoff window", { meta: { waitMs: dialWaitMs } });
10777
+ this.reconnectTimer = setTimeout(() => {
10778
+ this.reconnectTimer = void 0;
10779
+ if (this.manualStop) return;
10780
+ const src = this.resolveCurrentSource();
10781
+ if (src) this.start(src);
10782
+ }, dialWaitMs);
10783
+ }
10784
+ return;
10785
+ }
10786
+ this.nextDialAllowedAt = Date.now() + this.reconnectDelayMs;
10760
10787
  this._status = "connecting";
10761
10788
  if (source.ffmpegParser) {
10762
10789
  this._status = "connecting";
@@ -11703,6 +11730,7 @@ var StreamBroker = class StreamBroker {
11703
11730
  this.logger?.info("rtmp: streaming");
11704
11731
  this._status = "streaming";
11705
11732
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11733
+ this.nextDialAllowedAt = 0;
11706
11734
  this.stopErrorPlaceholder();
11707
11735
  },
11708
11736
  onError: (error) => {
@@ -11777,6 +11805,7 @@ var StreamBroker = class StreamBroker {
11777
11805
  this.logger?.info("ffmpeg source: streaming");
11778
11806
  this._status = "streaming";
11779
11807
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11808
+ this.nextDialAllowedAt = 0;
11780
11809
  this.stopErrorPlaceholder();
11781
11810
  },
11782
11811
  onError: (error) => {
@@ -11846,6 +11875,7 @@ var StreamBroker = class StreamBroker {
11846
11875
  if (this.stopping) return;
11847
11876
  this._status = "streaming";
11848
11877
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11878
+ this.nextDialAllowedAt = 0;
11849
11879
  this.stopErrorPlaceholder();
11850
11880
  this.cancelFirstVideoWatchdog();
11851
11881
  this.kickRtpActivityWatchdog();
@@ -12155,6 +12185,7 @@ var StreamBroker = class StreamBroker {
12155
12185
  this.destroyRfc4571Reader();
12156
12186
  this._status = "error";
12157
12187
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12188
+ this.nextDialAllowedAt = 0;
12158
12189
  this.lastRefreshRequestAt = 0;
12159
12190
  this.notifySourceRefresh();
12160
12191
  this.scheduleReconnect();
@@ -12216,6 +12247,7 @@ var StreamBroker = class StreamBroker {
12216
12247
  this.destroyNativeClient();
12217
12248
  this._status = "error";
12218
12249
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12250
+ this.nextDialAllowedAt = 0;
12219
12251
  this.scheduleReconnect();
12220
12252
  return;
12221
12253
  }
@@ -12224,6 +12256,7 @@ var StreamBroker = class StreamBroker {
12224
12256
  this.destroyRfc4571Reader();
12225
12257
  this._status = "error";
12226
12258
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12259
+ this.nextDialAllowedAt = 0;
12227
12260
  this.notifySourceRefresh();
12228
12261
  this.scheduleReconnect();
12229
12262
  return;
@@ -12283,11 +12316,7 @@ var StreamBroker = class StreamBroker {
12283
12316
  if (this.manualStop) return;
12284
12317
  const src = this.resolveCurrentSource();
12285
12318
  if (!src) return;
12286
- this._status = "connecting";
12287
- if (src.ffmpegParser) this.startFfmpegSourceReader(src);
12288
- else if (src.type === "rtmp") this.startRtmpReader(src);
12289
- else if (src.type === "rfc4571") this.startRfc4571Reader(src);
12290
- else this.startRtspReader(src);
12319
+ this.start(src);
12291
12320
  }, this.reconnectDelayMs);
12292
12321
  this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS);
12293
12322
  }
@@ -13543,6 +13572,15 @@ var StreamBrokerManager = class StreamBrokerManager {
13543
13572
  * time without an activation handshake.
13544
13573
  */
13545
13574
  brokers = /* @__PURE__ */ new Map();
13575
+ /**
13576
+ * Devices whose `deviceManager` meta says `disabled:true` — mirrored by the
13577
+ * SAME `reconcileAllCatalogs` poll that already reads `listAll` (the D8
13578
+ * reconcile backstop; no event plumbing). While a device is in this set,
13579
+ * `ensureBroker` refuses (re)creation so a retrying audio/frame subscriber
13580
+ * cannot resurrect a released broker, and the reconcile releases any broker
13581
+ * that still exists. Cleared by the same poll on re-enable.
13582
+ */
13583
+ disabledDevices = /* @__PURE__ */ new Set();
13546
13584
  /** profileBrokerId (`${deviceId}/${profile}`) → sourceBrokerId currently aliased. */
13547
13585
  profileRestreamerBindings = /* @__PURE__ */ new Map();
13548
13586
  /** Per-device published cam-stream registry. */
@@ -14157,6 +14195,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14157
14195
  * unchanged behavior.
14158
14196
  */
14159
14197
  async ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId, tier) {
14198
+ if (this.disabledDevices.has(deviceId)) return;
14160
14199
  if (this.brokers.has(brokerId)) return;
14161
14200
  if (this.remoteBrokerAdapters.has(brokerId)) return;
14162
14201
  if (camStreamId === void 0) return;
@@ -14581,7 +14620,33 @@ var StreamBrokerManager = class StreamBrokerManager {
14581
14620
  return;
14582
14621
  }
14583
14622
  const cameras = devices.filter((d) => d.isCamera || d.type === DeviceType.Camera);
14584
- await Promise.allSettled(cameras.map((d) => this.reconcileDeviceCatalog(d.id)));
14623
+ for (const d of cameras) if (d.disabled) {
14624
+ const firstSeen = !this.disabledDevices.has(d.id);
14625
+ this.disabledDevices.add(d.id);
14626
+ await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
14627
+ } else this.disabledDevices.delete(d.id);
14628
+ await Promise.allSettled(cameras.filter((d) => !d.disabled).map((d) => this.reconcileDeviceCatalog(d.id)));
14629
+ }
14630
+ /** Release every live broker of a disabled device (idempotent — later poll
14631
+ * ticks find none and no-op). `logTransition` marks the disable EDGE. */
14632
+ async releaseDisabledDeviceBrokers(deviceId, logTransition) {
14633
+ const streams = this.cameraStreams.get(deviceId);
14634
+ if (!streams) return;
14635
+ const live = Array.from(streams.keys()).filter((camStreamId) => this.brokers.has(brokerIdFor(deviceId, camStreamId)));
14636
+ if (live.length === 0) return;
14637
+ if (logTransition || live.length > 0) this.logger.info("device disabled — releasing its brokers (no dial while disabled)", {
14638
+ tags: { deviceId },
14639
+ meta: { camStreamIds: live }
14640
+ });
14641
+ for (const camStreamId of live) await this.releaseBroker(deviceId, camStreamId).catch((err) => {
14642
+ this.logger.warn("disabled-device broker release failed", {
14643
+ tags: {
14644
+ deviceId,
14645
+ camStreamId
14646
+ },
14647
+ meta: { error: errMsg(err) }
14648
+ });
14649
+ });
14585
14650
  }
14586
14651
  /**
14587
14652
  * Startup catalog pull: run a full reconcile, then a bounded number of extra
@@ -16325,6 +16390,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16325
16390
  }
16326
16391
  }
16327
16392
  async ensureBroker(deviceId, camStreamId) {
16393
+ if (this.disabledDevices.has(deviceId)) return;
16328
16394
  const cam = this.cameraStreams.get(deviceId)?.get(camStreamId);
16329
16395
  if (!cam) return;
16330
16396
  const brokerId = brokerIdFor(deviceId, camStreamId);
@@ -16875,6 +16941,11 @@ var ControlMessageSchema = discriminatedUnion("t", [
16875
16941
  t: literal("scrubCommit"),
16876
16942
  epoch: number()
16877
16943
  }),
16944
+ object({ t: literal("scrubStart") }),
16945
+ object({
16946
+ t: literal("scrubTo"),
16947
+ epoch: number()
16948
+ }),
16878
16949
  object({
16879
16950
  t: literal("setRate"),
16880
16951
  rate: number().min(0).max(4)
@@ -17023,6 +17094,31 @@ var WAIT_TICK_MS = 250;
17023
17094
  * instead of merging the two feeds.
17024
17095
  */
17025
17096
  var SEEK_DISCONTINUITY_MS = 250;
17097
+ /**
17098
+ * Coalescing window for feeder-scrub `scrubTo` (experimental). Pointer-move
17099
+ * targets arrive far faster than a segment demux completes; the feeder collapses
17100
+ * them to at most one keyframe push per this window (≈ 16 pushes/s), always
17101
+ * pushing only the LATEST pending target (latest-wins). The FIRST move fires a
17102
+ * leading-edge tick immediately; subsequent moves ride the pending timer.
17103
+ *
17104
+ * Tightened 100 → 60 ms: same-fragment moves are memo/cache hits (zero demux) so
17105
+ * the window is now the dominant per-push cost inside a fragment, and 60 ms is
17106
+ * sustainable there — cross-fragment moves remain paced by the demux + the
17107
+ * one-in-flight guard, so this cannot spawn parallel demuxes.
17108
+ */
17109
+ var SCRUB_COALESCE_MS = 60;
17110
+ /**
17111
+ * Upper bound on the per-scrub-session fragment cache (LRU by segment `startMs`).
17112
+ * A demuxed keyframe set is one AU per fragment, so 8 fragments is a few KB;
17113
+ * back-and-forth drags over this window push instantly with zero re-demux.
17114
+ */
17115
+ var SCRUB_CACHE_MAX = 8;
17116
+ /**
17117
+ * Consecutive same-direction `scrubTo` deltas required before a direction-aware
17118
+ * prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
17119
+ * sustained direction, so a jittery/reversing drag never wastes the slot.
17120
+ */
17121
+ var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
17026
17122
  var RecordedFeeder = class {
17027
17123
  deps;
17028
17124
  clock = new MonotonicClock();
@@ -17053,6 +17149,61 @@ var RecordedFeeder = class {
17053
17149
  * the pre-seek position when the client changes rate right after a seek.
17054
17150
  */
17055
17151
  inFlightSeekEpochMs = null;
17152
+ /**
17153
+ * EXPERIMENTAL feeder-scrub sub-mode. While true the normal advance/feed loop
17154
+ * is suspended and each coalesced `scrubTick` pushes the covering fragment's
17155
+ * nearest keyframe ≤ the drag target. `scrubToken` is the MODE-EXIT guard (not
17156
+ * a per-move token): it is bumped on `beginScrub`, on the commit `seek`, and on
17157
+ * `dispose`, so an in-flight tick whose captured generation no longer matches
17158
+ * drops itself. Latest-wins across rapid moves is handled by COALESCING —
17159
+ * `scrubPendingEpochMs` always holds the newest target, and one tick per
17160
+ * `SCRUB_COALESCE_MS` window processes it.
17161
+ */
17162
+ scrubbing = false;
17163
+ scrubToken = 0;
17164
+ /** The newest un-processed drag target (null when the tick cycle has drained). */
17165
+ scrubPendingEpochMs = null;
17166
+ /**
17167
+ * The active coalescing timer. Non-null means a tick cycle is live (scheduled
17168
+ * OR running its async load) — it is the single re-entrancy guard, so a burst
17169
+ * of `scrubTo` calls coalesce onto `scrubPendingEpochMs` instead of spawning
17170
+ * parallel demuxes.
17171
+ */
17172
+ scrubTimer = null;
17173
+ /**
17174
+ * The fragment demuxed by the last tick (same-fragment memo): consecutive drag
17175
+ * targets inside ONE fragment reuse these access units instead of re-running
17176
+ * readBytes/demux (ffmpeg) per pointer move. Reset on fragment change + exit.
17177
+ */
17178
+ scrubSegment = null;
17179
+ /**
17180
+ * Per-scrub-session LRU of demuxed keyframe sets, keyed by segment `startMs`
17181
+ * (Map insertion order IS the LRU order; a hit re-inserts to mark it most
17182
+ * recent). Held ACROSS re-entrant drags in the same session — recorded footage
17183
+ * is immutable, so a revisited fragment (back-and-forth drag) pushes instantly
17184
+ * with zero re-demux. Bounded to {@link SCRUB_CACHE_MAX}; cleared only on the
17185
+ * commit `seek`, `dispose`, and goLive teardown (all of which dispose/reset the
17186
+ * feeder), never on `beginScrub`.
17187
+ */
17188
+ scrubCache = /* @__PURE__ */ new Map();
17189
+ /**
17190
+ * The last drag target a `scrubTick` PROCESSED (null before the first). The
17191
+ * sign of `(newTarget − this)` is the drag direction that steers prefetch.
17192
+ */
17193
+ scrubPrevTarget = null;
17194
+ /**
17195
+ * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
17196
+ * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
17197
+ * a reversal or an in-fragment no-move resets it.
17198
+ */
17199
+ scrubDirRun = 0;
17200
+ /**
17201
+ * The SECOND bounded concurrency slot: `true` while a direction-aware prefetch
17202
+ * demux is in flight. At most one prefetch runs at a time and it only fills
17203
+ * `scrubCache` — it never pushes, never touches the pending/timer/latest-wins
17204
+ * state, and never blocks the live coalesced tick.
17205
+ */
17206
+ scrubPrefetchInFlight = false;
17056
17207
  constructor(deps) {
17057
17208
  this.deps = deps;
17058
17209
  }
@@ -17064,6 +17215,18 @@ var RecordedFeeder = class {
17064
17215
  */
17065
17216
  async seek(epochMs) {
17066
17217
  const token = ++this.seekToken;
17218
+ if (this.scrubbing) {
17219
+ this.scrubbing = false;
17220
+ this.scrubToken++;
17221
+ this.scrubSegment = null;
17222
+ this.scrubCache.clear();
17223
+ this.scrubPrevTarget = null;
17224
+ this.scrubDirRun = 0;
17225
+ if (this.scrubTimer) {
17226
+ clearTimeout(this.scrubTimer);
17227
+ this.scrubTimer = null;
17228
+ }
17229
+ }
17067
17230
  this.advanceToken++;
17068
17231
  this.inFlightSeekEpochMs = epochMs;
17069
17232
  try {
@@ -17142,6 +17305,200 @@ var RecordedFeeder = class {
17142
17305
  this.seek(this.inFlightSeekEpochMs ?? this.cursorMs);
17143
17306
  }
17144
17307
  /**
17308
+ * EXPERIMENTAL feeder-scrub: enter the scrub sub-mode. Aborts any in-flight
17309
+ * seek/advance loop (latest-wins, exactly like `seek()` does up-front) and
17310
+ * suspends the ~1 Hz position timer — during a drag position is reported per
17311
+ * pushed keyframe instead. `this.rate` is preserved for the post-commit resume
17312
+ * (the commit `seek` re-enters at the same rate).
17313
+ *
17314
+ * Idempotent WITHIN a drag: a second `beginScrub` while already scrubbing is a
17315
+ * NO-OP. The app may re-send `scrubStart` mid-gesture (the observed repeated
17316
+ * `scrubStart` storm), and resetting the token/coalescing state on each re-send
17317
+ * would abort in-flight keyframe work and restart the drag — the exact stutter
17318
+ * this guards against. The ONLY exit from scrub mode is the commit `seek()`
17319
+ * (which flips `scrubbing` false and clears the session cache) or `dispose()`;
17320
+ * a genuinely NEW drag is therefore always preceded by that exit, so its
17321
+ * `beginScrub` runs the full reset below. (D-scrub-ux)
17322
+ */
17323
+ beginScrub() {
17324
+ if (this.disposed || this.scrubbing) return;
17325
+ this.seekToken++;
17326
+ this.advanceToken++;
17327
+ this.scrubbing = true;
17328
+ this.scrubToken++;
17329
+ this.scrubPendingEpochMs = null;
17330
+ this.scrubSegment = null;
17331
+ this.scrubPrevTarget = null;
17332
+ this.scrubDirRun = 0;
17333
+ if (this.scrubTimer) {
17334
+ clearTimeout(this.scrubTimer);
17335
+ this.scrubTimer = null;
17336
+ }
17337
+ this.stopPositionReports();
17338
+ }
17339
+ /**
17340
+ * EXPERIMENTAL feeder-scrub: register a drag target. Records the newest target
17341
+ * and, if no tick cycle is live, starts a leading-edge tick. A burst of moves
17342
+ * within the coalescing window collapses onto `scrubPendingEpochMs` (latest-
17343
+ * wins) so only one keyframe is demuxed+pushed per `SCRUB_COALESCE_MS`. A
17344
+ * `scrubTo` without a prior `beginScrub` (or after commit/dispose) is a no-op.
17345
+ */
17346
+ scrubTo(epochMs) {
17347
+ if (this.disposed || !this.scrubbing) return;
17348
+ this.scrubPendingEpochMs = epochMs;
17349
+ if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
17350
+ }
17351
+ /**
17352
+ * One coalesced feeder-scrub push. Captures the newest pending target + the
17353
+ * current mode generation, resolves the covering fragment (reusing the memo'd
17354
+ * one when the target stays inside it), picks the nearest keyframe ≤ the
17355
+ * target, stamps a discontinuity, and pushes that single access unit. Re-arms
17356
+ * itself after `SCRUB_COALESCE_MS` while more moves are pending; otherwise
17357
+ * ends the cycle. Video only — no audio during scrub. A gap target holds the
17358
+ * last pushed frame (no push, no error).
17359
+ */
17360
+ async scrubTick() {
17361
+ const gen = this.scrubToken;
17362
+ const target = this.scrubPendingEpochMs;
17363
+ this.scrubPendingEpochMs = null;
17364
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken || target === null) {
17365
+ this.scrubTimer = null;
17366
+ return;
17367
+ }
17368
+ try {
17369
+ const loc = await this.withTimeout(this.deps.locate({
17370
+ deviceId: this.deps.deviceId,
17371
+ profile: this.deps.profile,
17372
+ epochMs: target
17373
+ }));
17374
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
17375
+ if (loc.kind === "gap") return;
17376
+ let seg = this.scrubSegment;
17377
+ if (!seg || seg.startMs !== loc.startMs) {
17378
+ seg = this.scrubCacheGet(loc.startMs);
17379
+ if (!seg) {
17380
+ const loaded = await this.loadAus(loc.startMs, false, true);
17381
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
17382
+ seg = {
17383
+ startMs: loc.startMs,
17384
+ durMs: loc.durMs,
17385
+ aus: loaded.video
17386
+ };
17387
+ this.scrubCachePut(seg);
17388
+ }
17389
+ this.scrubSegment = seg;
17390
+ }
17391
+ const au = seg.aus[this.pickScrubKeyframe(seg, target)];
17392
+ if (!au) return;
17393
+ if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
17394
+ this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
17395
+ this.clockHasAdvanced = true;
17396
+ this.deps.onPosition(this.clampIntoSegment(seg, target));
17397
+ this.maybeScrubPrefetch(seg, target, gen);
17398
+ } catch {} finally {
17399
+ if (gen !== this.scrubToken) {} else if (!this.disposed && this.scrubbing && this.scrubPendingEpochMs !== null) this.scrubTimer = setTimeout(() => void this.scrubTick(), SCRUB_COALESCE_MS);
17400
+ else this.scrubTimer = null;
17401
+ }
17402
+ }
17403
+ /**
17404
+ * Index of the nearest keyframe at-or-before the drag target within `seg`
17405
+ * (default 0 — the fragment's params-fused IDR, which the demux contract
17406
+ * guarantees). Scans the access units whose PTS offset is ≤ the target offset
17407
+ * and returns the LAST keyframe among them, so the pushed frame is standalone-
17408
+ * decodable yet as close to the target as the GOP structure allows.
17409
+ */
17410
+ pickScrubKeyframe(seg, target) {
17411
+ const first = seg.aus[0];
17412
+ if (!first) return 0;
17413
+ const offsetMs = target - seg.startMs;
17414
+ let idx = 0;
17415
+ for (let i = 0; i < seg.aus.length; i++) {
17416
+ const au = seg.aus[i];
17417
+ if (!au) continue;
17418
+ if (au.ptsMs - first.ptsMs > offsetMs) break;
17419
+ if (au.keyframe) idx = i;
17420
+ }
17421
+ return idx;
17422
+ }
17423
+ /**
17424
+ * Scrub fragment cache lookup with LRU touch: a hit is re-inserted so it moves
17425
+ * to the most-recent end of the Map's insertion order (the eviction cursor is
17426
+ * the oldest key).
17427
+ */
17428
+ scrubCacheGet(startMs) {
17429
+ const seg = this.scrubCache.get(startMs);
17430
+ if (!seg) return null;
17431
+ this.scrubCache.delete(startMs);
17432
+ this.scrubCache.set(startMs, seg);
17433
+ return seg;
17434
+ }
17435
+ /** Insert (or refresh) a demuxed fragment, evicting the oldest over the cap. */
17436
+ scrubCachePut(seg) {
17437
+ this.scrubCache.delete(seg.startMs);
17438
+ this.scrubCache.set(seg.startMs, seg);
17439
+ while (this.scrubCache.size > SCRUB_CACHE_MAX) {
17440
+ const oldest = this.scrubCache.keys().next().value;
17441
+ if (oldest === void 0) break;
17442
+ this.scrubCache.delete(oldest);
17443
+ }
17444
+ }
17445
+ /**
17446
+ * Update the drag-direction run from the just-served target and, once the drag
17447
+ * shows a sustained direction, kick off a single background prefetch of the
17448
+ * NEXT fragment in that direction. Prefetch fills the cache only — it never
17449
+ * pushes, so crossing into the neighbour later is an instant cache hit.
17450
+ */
17451
+ maybeScrubPrefetch(seg, target, gen) {
17452
+ const prev = this.scrubPrevTarget;
17453
+ this.scrubPrevTarget = target;
17454
+ if (prev === null) {
17455
+ this.scrubDirRun = 0;
17456
+ return;
17457
+ }
17458
+ const dir = target > prev ? 1 : target < prev ? -1 : 0;
17459
+ if (dir === 0) {
17460
+ this.scrubDirRun = 0;
17461
+ return;
17462
+ }
17463
+ this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
17464
+ if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
17465
+ if (this.scrubPrefetchInFlight) return;
17466
+ const probeMs = dir === 1 ? seg.startMs + seg.durMs + 1 : seg.startMs - 1;
17467
+ if (probeMs < 0) return;
17468
+ this.scrubPrefetch(probeMs, gen);
17469
+ }
17470
+ /**
17471
+ * Background (second-slot) prefetch: locate + keyframe-only demux the fragment
17472
+ * covering `probeMs` into `scrubCache`. Bounded to one in flight; aborts at any
17473
+ * teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
17474
+ * swallowed (the neighbour just demuxes on demand). Never pushes and never
17475
+ * mutates latest-wins state.
17476
+ */
17477
+ async scrubPrefetch(probeMs, gen) {
17478
+ if (this.scrubPrefetchInFlight) return;
17479
+ this.scrubPrefetchInFlight = true;
17480
+ try {
17481
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
17482
+ const loc = await this.withTimeout(this.deps.locate({
17483
+ deviceId: this.deps.deviceId,
17484
+ profile: this.deps.profile,
17485
+ epochMs: probeMs
17486
+ }));
17487
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
17488
+ if (loc.kind !== "segment") return;
17489
+ if (this.scrubCache.has(loc.startMs)) return;
17490
+ const loaded = await this.loadAus(loc.startMs, false, true);
17491
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
17492
+ this.scrubCachePut({
17493
+ startMs: loc.startMs,
17494
+ durMs: loc.durMs,
17495
+ aus: loaded.video
17496
+ });
17497
+ } catch {} finally {
17498
+ this.scrubPrefetchInFlight = false;
17499
+ }
17500
+ }
17501
+ /**
17145
17502
  * Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
17146
17503
  * paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
17147
17504
  * at a segment end crosses into the next segment (gaps are skipped via the
@@ -17153,7 +17510,7 @@ var RecordedFeeder = class {
17153
17510
  * step is still in flight.
17154
17511
  */
17155
17512
  stepFrame(dir) {
17156
- if (this.disposed || this.rate !== 0 || this.stepInFlight) return;
17513
+ if (this.disposed || this.rate !== 0 || this.stepInFlight || this.scrubbing) return;
17157
17514
  this.stepInFlight = true;
17158
17515
  this.doStep(dir).catch(() => {}).finally(() => {
17159
17516
  this.stepInFlight = false;
@@ -17480,8 +17837,10 @@ var RecordedFeeder = class {
17480
17837
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
17481
17838
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
17482
17839
  * never blocks video). `wantAudio: false` skips the audio transcode entirely
17483
- * (paused scrubs would throw it away). */
17484
- async loadAus(startMs, wantAudio = true) {
17840
+ * (paused scrubs would throw it away). `useScrubDemux: true` selects the
17841
+ * keyframe-only scrub demux (`deps.demuxScrub`, falling back to `deps.demux`
17842
+ * when absent — flag-off byte-identity). */
17843
+ async loadAus(startMs, wantAudio = true, useScrubDemux = false) {
17485
17844
  const token = this.seekToken;
17486
17845
  const bytes = await this.withTimeout(this.deps.readBytes({
17487
17846
  deviceId: this.deps.deviceId,
@@ -17489,7 +17848,8 @@ var RecordedFeeder = class {
17489
17848
  startMs
17490
17849
  }));
17491
17850
  if (this.disposed || token !== this.seekToken) return null;
17492
- const video = await this.withTimeout(this.deps.demux(bytes));
17851
+ const demux = useScrubDemux && this.deps.demuxScrub ? this.deps.demuxScrub : this.deps.demux;
17852
+ const video = await this.withTimeout(demux(bytes));
17493
17853
  if (this.disposed || token !== this.seekToken) return null;
17494
17854
  let audio = [];
17495
17855
  if (wantAudio && this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
@@ -17538,6 +17898,16 @@ var RecordedFeeder = class {
17538
17898
  this.disposed = true;
17539
17899
  this.seekToken++;
17540
17900
  this.advanceToken++;
17901
+ this.scrubbing = false;
17902
+ this.scrubToken++;
17903
+ this.scrubSegment = null;
17904
+ this.scrubCache.clear();
17905
+ this.scrubPrevTarget = null;
17906
+ this.scrubDirRun = 0;
17907
+ if (this.scrubTimer) {
17908
+ clearTimeout(this.scrubTimer);
17909
+ this.scrubTimer = null;
17910
+ }
17541
17911
  this.stopPositionReports();
17542
17912
  }
17543
17913
  };
@@ -17571,6 +17941,12 @@ var TimelineSession = class {
17571
17941
  case "scrubCommit":
17572
17942
  await (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).seek(msg.epoch);
17573
17943
  break;
17944
+ case "scrubStart":
17945
+ (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
17946
+ break;
17947
+ case "scrubTo":
17948
+ if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
17949
+ break;
17574
17950
  case "setRate":
17575
17951
  this.feeder?.setRate(msg.rate);
17576
17952
  break;
@@ -17890,7 +18266,7 @@ function demuxMpegTsToAnnexB(ts) {
17890
18266
  let current = null;
17891
18267
  const finalize = () => {
17892
18268
  if (current === null) return;
17893
- const pes = current.length === 1 ? current[0] : concat(current);
18269
+ const pes = current.length === 1 ? current[0] : concat$1(current);
17894
18270
  current = null;
17895
18271
  const split = splitPes(pes);
17896
18272
  if (split === null || split.es.length === 0) return;
@@ -17921,7 +18297,7 @@ function demuxMpegTsToAnnexB(ts) {
17921
18297
  };
17922
18298
  }
17923
18299
  /** Concatenate TS payload fragments into one contiguous PES buffer. */
17924
- function concat(parts) {
18300
+ function concat$1(parts) {
17925
18301
  let total = 0;
17926
18302
  for (const p of parts) total += p.length;
17927
18303
  const out = new Uint8Array(total);
@@ -17978,6 +18354,33 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
17978
18354
  "pipe:1"
17979
18355
  ];
17980
18356
  /**
18357
+ * ffmpeg arguments for the SCRUB keyframe-only demux: same crash-isolated
18358
+ * `-c:v copy` mpegts remux as {@link SEGMENT_DEMUX_FFMPEG_ARGS}, but `-frames:v 1`
18359
+ * stops after the FIRST output access unit — a recorded fragment is a single
18360
+ * IDR-led GOP, so frame 1 is the params-fused keyframe the scrub sub-mode needs
18361
+ * (it never renders a delta frame). This reliably yields exactly the IDR with
18362
+ * its VPS/SPS/PPS fused, whereas input-side `-discard nokey` is a no-op over the
18363
+ * `pipe:0` fragmented-mp4 demuxer (empirically kept 39/40 packets). Exported for
18364
+ * the spec.
18365
+ */
18366
+ var SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS = [
18367
+ "-hide_banner",
18368
+ "-loglevel",
18369
+ "error",
18370
+ "-i",
18371
+ "pipe:0",
18372
+ "-map",
18373
+ "0:v:0",
18374
+ "-frames:v",
18375
+ "1",
18376
+ "-c:v",
18377
+ "copy",
18378
+ "-an",
18379
+ "-f",
18380
+ "mpegts",
18381
+ "pipe:1"
18382
+ ];
18383
+ /**
17981
18384
  * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
17982
18385
  * access units.
17983
18386
  *
@@ -18003,6 +18406,253 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
18003
18406
  codec
18004
18407
  }));
18005
18408
  }
18409
+ /**
18410
+ * Keyframe-only demux for the feeder-scrub sub-mode: remux with `-frames:v 1`
18411
+ * (single-GOP fragment ⇒ the one IDR) and return ONLY keyframe access units.
18412
+ * The scrub path never renders delta frames — it always pushes the nearest
18413
+ * keyframe ≤ the drag target — so extracting only the IDR keeps the per-session
18414
+ * scrub fragment cache tiny (one AU/fragment instead of the full ~40) with no
18415
+ * loss of scrub fidelity.
18416
+ *
18417
+ * Wall time per fragment is dominated by the ffmpeg subprocess spawn, so this is
18418
+ * NOT meaningfully faster per fragment than {@link demuxSegmentToAnnexB}; the
18419
+ * frequency win comes from the feeder's fragment cache + direction-aware
18420
+ * prefetch (which eliminate / overlap the demux), for which this compact
18421
+ * keyframe-only payload is the cache unit.
18422
+ *
18423
+ * Defensive parse-side filter: keeps `keyframe` AUs, falling back to the first
18424
+ * AU (the demux contract's params-fused IDR) if none were flagged, so the result
18425
+ * is keyframes-only yet never empty for a valid fragment.
18426
+ *
18427
+ * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
18428
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
18429
+ */
18430
+ async function demuxSegmentKeyframesToAnnexB(bytes, ffmpegPath = "ffmpeg") {
18431
+ const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
18432
+ ffmpegPath,
18433
+ args: SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS,
18434
+ input: bytes
18435
+ }));
18436
+ const mapped = accessUnits.map((au) => ({
18437
+ data: au.data,
18438
+ ptsMs: au.ptsMs,
18439
+ keyframe: au.keyframe,
18440
+ codec
18441
+ }));
18442
+ const keyframes = mapped.filter((au) => au.keyframe);
18443
+ return keyframes.length > 0 ? keyframes : mapped.slice(0, 1);
18444
+ }
18445
+ //#endregion
18446
+ //#region src/stream-broker/recorded/avcc-annexb.ts
18447
+ /**
18448
+ * avcc-annexb — pure byte helpers to turn an ISO-BMFF (mp4) length-prefixed
18449
+ * video sample plus its `avcC`/`hvcC` decoder config into an Annex-B access
18450
+ * unit with the parameter sets prepended (SPS/PPS for H.264, VPS/SPS/PPS for
18451
+ * H.265).
18452
+ *
18453
+ * mp4 stores VCL NALs length-prefixed (AVCC/HVCC: a 1–4 byte big-endian length
18454
+ * field, `lengthSizeMinusOne+1`, before each NAL) and keeps the parameter sets
18455
+ * OUT of the samples, inside the `avcC`/`hvcC` box. A raw decoder / RTP
18456
+ * packetizer needs Annex-B start codes (`00 00 00 01`) AND the parameter sets
18457
+ * fused onto every IDR. These helpers do exactly that in JS — no ffmpeg, no
18458
+ * decode, byte copies only — so the feeder-scrub keyframe path can skip the
18459
+ * per-fragment ffmpeg spawn.
18460
+ *
18461
+ * All functions are pure and total for well-formed input; malformed config /
18462
+ * sample bytes throw a `RangeError` (the caller treats a throw as "fall back to
18463
+ * ffmpeg"). The `description` bytes are the box payload WITH the 8-byte box
18464
+ * header already stripped (i.e. starting at `configurationVersion`), matching
18465
+ * `mp4box`'s `avcC`/`hvcC` DataStream output minus header — see
18466
+ * `recorder/scrub/fmp4-keyframes.ts` `extractDescription`.
18467
+ */
18468
+ /** Annex-B 4-byte start code prepended before every emitted NAL. */
18469
+ var START_CODE = Uint8Array.of(0, 0, 0, 1);
18470
+ /** Big-endian unsigned read of `size` (1..4) bytes at `off`. */
18471
+ function readUint(bytes, off, size) {
18472
+ if (off + size > bytes.length) throw new RangeError(`readUint out of range: off=${off} size=${size} len=${bytes.length}`);
18473
+ let v = 0;
18474
+ for (let i = 0; i < size; i++) v = v * 256 + bytes[off + i];
18475
+ return v;
18476
+ }
18477
+ /**
18478
+ * NAL length-prefix size in bytes (`lengthSizeMinusOne + 1`, 1..4) read from the
18479
+ * decoder config. `avcC` carries it at byte 4 (low 2 bits); `hvcC` at byte 21
18480
+ * (low 2 bits).
18481
+ */
18482
+ function parseNalLengthSize(description, isHevc) {
18483
+ const idx = isHevc ? 21 : 4;
18484
+ if (description.length <= idx) throw new RangeError(`config too short for lengthSizeMinusOne (len=${description.length})`);
18485
+ return (description[idx] & 3) + 1;
18486
+ }
18487
+ /** Concatenate byte chunks into one Uint8Array. */
18488
+ function concat(chunks) {
18489
+ let total = 0;
18490
+ for (const c of chunks) total += c.length;
18491
+ const out = new Uint8Array(total);
18492
+ let off = 0;
18493
+ for (const c of chunks) {
18494
+ out.set(c, off);
18495
+ off += c.length;
18496
+ }
18497
+ return out;
18498
+ }
18499
+ /** Push one Annex-B NAL (start code + payload) onto `chunks`. */
18500
+ function pushNal(chunks, payload) {
18501
+ chunks.push(START_CODE, payload);
18502
+ }
18503
+ /**
18504
+ * Parse the `avcC` parameter sets (SPS then PPS), emitting each as an Annex-B
18505
+ * NAL. Layout after the 4-byte profile/level prefix: byte 4 low-2 = length
18506
+ * size; byte 5 low-5 = numSPS; then `[u16 len][SPS]…`; then `[u8 numPPS]`; then
18507
+ * `[u16 len][PPS]…`.
18508
+ */
18509
+ function extractAvcParameterSets(desc) {
18510
+ const chunks = [];
18511
+ const numSps = desc[5] & 31;
18512
+ let p = 6;
18513
+ for (let i = 0; i < numSps; i++) {
18514
+ const len = readUint(desc, p, 2);
18515
+ p += 2;
18516
+ if (p + len > desc.length) throw new RangeError("avcC SPS length overruns config");
18517
+ pushNal(chunks, desc.subarray(p, p + len));
18518
+ p += len;
18519
+ }
18520
+ const numPps = readUint(desc, p, 1);
18521
+ p += 1;
18522
+ for (let i = 0; i < numPps; i++) {
18523
+ const len = readUint(desc, p, 2);
18524
+ p += 2;
18525
+ if (p + len > desc.length) throw new RangeError("avcC PPS length overruns config");
18526
+ pushNal(chunks, desc.subarray(p, p + len));
18527
+ p += len;
18528
+ }
18529
+ return chunks;
18530
+ }
18531
+ /**
18532
+ * Parse the `hvcC` parameter-set arrays (typically VPS, SPS, PPS in order),
18533
+ * emitting each contained NAL as Annex-B. Layout: byte 22 = numOfArrays; then
18534
+ * per array `[u8 flags+NALtype][u16 numNalus]` followed by `numNalus` ×
18535
+ * `[u16 len][NAL]`.
18536
+ */
18537
+ function extractHevcParameterSets(desc) {
18538
+ const chunks = [];
18539
+ const numArrays = readUint(desc, 22, 1);
18540
+ let p = 23;
18541
+ for (let a = 0; a < numArrays; a++) {
18542
+ p += 1;
18543
+ const numNalus = readUint(desc, p, 2);
18544
+ p += 2;
18545
+ for (let n = 0; n < numNalus; n++) {
18546
+ const len = readUint(desc, p, 2);
18547
+ p += 2;
18548
+ if (p + len > desc.length) throw new RangeError("hvcC NAL length overruns config");
18549
+ pushNal(chunks, desc.subarray(p, p + len));
18550
+ p += len;
18551
+ }
18552
+ }
18553
+ return chunks;
18554
+ }
18555
+ /**
18556
+ * Parameter sets from the decoder config as one Annex-B blob (each NAL led by a
18557
+ * `00 00 00 01` start code): SPS+PPS for H.264, VPS+SPS+PPS (+ any hvcC arrays)
18558
+ * for H.265. Throws on a malformed config.
18559
+ */
18560
+ function extractParameterSetsAnnexB(description, isHevc) {
18561
+ return concat(isHevc ? extractHevcParameterSets(description) : extractAvcParameterSets(description));
18562
+ }
18563
+ /**
18564
+ * Convert one length-prefixed (AVCC/HVCC) sample into Annex-B: walk each
18565
+ * `nalLengthSize`-byte length field, replacing it with a `00 00 00 01` start
18566
+ * code. Handles multi-NAL samples (multiple slices / SEI + VCL). Throws if a
18567
+ * length field overruns the sample.
18568
+ */
18569
+ function avccSampleToAnnexB(sample, nalLengthSize) {
18570
+ const chunks = [];
18571
+ let p = 0;
18572
+ while (p + nalLengthSize <= sample.length) {
18573
+ const len = readUint(sample, p, nalLengthSize);
18574
+ p += nalLengthSize;
18575
+ if (len === 0) continue;
18576
+ if (p + len > sample.length) throw new RangeError("AVCC NAL length overruns sample");
18577
+ pushNal(chunks, sample.subarray(p, p + len));
18578
+ p += len;
18579
+ }
18580
+ if (chunks.length === 0) throw new RangeError("AVCC sample yielded no NALs");
18581
+ return concat(chunks);
18582
+ }
18583
+ /**
18584
+ * Build a standalone-decodable Annex-B keyframe access unit: the config's
18585
+ * parameter sets, then the sample's NALs, all Annex-B framed. Throws on
18586
+ * malformed input (caller falls back to the ffmpeg demux).
18587
+ */
18588
+ function buildKeyframeAnnexB(sample, description, isHevc) {
18589
+ const nalLengthSize = parseNalLengthSize(description, isHevc);
18590
+ return concat([extractParameterSetsAnnexB(description, isHevc), avccSampleToAnnexB(sample, nalLengthSize)]);
18591
+ }
18592
+ //#endregion
18593
+ //#region src/stream-broker/recorded/segment-demux-js.ts
18594
+ /**
18595
+ * segment-demux-js — PURE-JS keyframe demux for the feeder-scrub sub-mode.
18596
+ *
18597
+ * Replaces the per-fragment ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB},
18598
+ * a ~140 ms subprocess floor) with an in-process mp4box parse. A recorded `.m4s`
18599
+ * is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
18600
+ * carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
18601
+ * sample(s) are all in the bytes we already hold — no init segment to fetch.
18602
+ *
18603
+ * Flow: reuse {@link extractKeyframes} (the proven mp4box sync-sample demux the
18604
+ * scrub-pack service already ships — box parsing is NOT duplicated here) to get
18605
+ * the codec `description` + length-prefixed sync samples, then
18606
+ * {@link buildKeyframeAnnexB} converts each to a standalone-decodable Annex-B
18607
+ * access unit with the parameter sets fused on. The result matches the
18608
+ * {@link AccessUnit} contract the ffmpeg path produces: `codec` H264|H265,
18609
+ * `keyframe:true`, and `ptsMs` segment-relative (offset from the first sync
18610
+ * sample — the only thing the feeder reads, via `au.ptsMs - first.ptsMs`).
18611
+ *
18612
+ * Anchoring `extractKeyframes(bytes, 0)` makes the first sync sample's `ts` = 0,
18613
+ * so `ptsMs` is a plain segment-relative offset in ms, exactly like the ffmpeg
18614
+ * demux's PES-derived deltas — `clampIntoSegment`/position reporting are
18615
+ * unaffected.
18616
+ */
18617
+ /** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
18618
+ function isHevcCodec(codec) {
18619
+ return /^h[ev][vc]1\./i.test(codec);
18620
+ }
18621
+ /**
18622
+ * Pure-JS keyframe-only demux of one self-contained `.m4s` fragment. Returns the
18623
+ * fragment's sync-sample access unit(s) as params-fused Annex-B, or `null` if
18624
+ * mp4box found no video / no decodable keyframe (caller falls back to ffmpeg).
18625
+ * Throws only on a genuinely malformed decoder config / sample — the wrapper
18626
+ * ({@link demuxSegmentKeyframesPreferJs}) turns that into an ffmpeg fallback.
18627
+ */
18628
+ async function demuxSegmentKeyframesJs(bytes) {
18629
+ const extracted = await extractKeyframes(bytes, 0);
18630
+ if (!extracted || extracted.keyframes.length === 0) return null;
18631
+ const isHevc = isHevcCodec(extracted.codec);
18632
+ const codec = isHevc ? "H265" : "H264";
18633
+ return extracted.keyframes.slice(0, 1).map((k) => ({
18634
+ data: buildKeyframeAnnexB(k.data, extracted.description, isHevc),
18635
+ ptsMs: k.ts,
18636
+ keyframe: true,
18637
+ codec
18638
+ }));
18639
+ }
18640
+ /**
18641
+ * Feeder-scrub demux: try the pure-JS keyframe extractor first, fall back to the
18642
+ * ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB}) if the JS parse yields no
18643
+ * keyframe or throws on malformed bytes. Preserves the exact {@link AccessUnit}
18644
+ * contract either way, so it is a drop-in for `RecordedFeederDeps.demuxScrub`.
18645
+ */
18646
+ async function demuxSegmentKeyframesPreferJs(bytes, opts = {}) {
18647
+ try {
18648
+ const js = await demuxSegmentKeyframesJs(bytes);
18649
+ if (js && js.length > 0) return js;
18650
+ opts.onFfmpegFallback?.("mp4box parse found no keyframe");
18651
+ } catch (err) {
18652
+ opts.onFfmpegFallback?.(`mp4box parse threw: ${String(err)}`);
18653
+ }
18654
+ return demuxSegmentKeyframesToAnnexB(bytes, opts.ffmpegPath);
18655
+ }
18006
18656
  //#endregion
18007
18657
  //#region src/stream-broker/recorded/segment-audio.ts
18008
18658
  /**
@@ -18877,6 +19527,7 @@ var BrokerWebrtcServer = class {
18877
19527
  const locateRecorded = this.locateRecordedSegment;
18878
19528
  const readRecorded = this.readRecordedSegmentBytes;
18879
19529
  const resolveFfmpeg = this.resolveFfmpegPath;
19530
+ let scrubJsFallbackLogged = false;
18880
19531
  if (locateRecorded && readRecorded) {
18881
19532
  let timeline;
18882
19533
  timeline = new TimelineSession({
@@ -18888,6 +19539,19 @@ var BrokerWebrtcServer = class {
18888
19539
  locate: locateRecorded,
18889
19540
  readBytes: readRecorded,
18890
19541
  demux: (b) => demuxSegmentToAnnexB(b, resolveFfmpeg?.()),
19542
+ demuxScrub: (b) => demuxSegmentKeyframesPreferJs(b, {
19543
+ ffmpegPath: resolveFfmpeg?.(),
19544
+ onFfmpegFallback: (reason) => {
19545
+ if (scrubJsFallbackLogged) return;
19546
+ scrubJsFallbackLogged = true;
19547
+ sessionLogger.warn("recorded scrub: JS keyframe demux failed — ffmpeg fallback (once/session)", { meta: {
19548
+ sessionId,
19549
+ deviceId,
19550
+ profile,
19551
+ reason
19552
+ } });
19553
+ }
19554
+ }),
18891
19555
  demuxAudio: (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.()),
18892
19556
  sink: {
18893
19557
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),