@camstack/addon-pipeline 1.1.57 → 1.1.59

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 (37) hide show
  1. package/dist/audio-analyzer/index.js +4 -4
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +9 -9
  4. package/dist/detection-pipeline/index.mjs +3 -3
  5. package/dist/{dist-CK8Ur-OS.js → dist-B1u-QN05.js} +4822 -4650
  6. package/dist/{dist-CL531uVA.mjs → dist-DKeNH_5z.mjs} +4823 -4651
  7. package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-Cp9f4dk6-0wh7OK3s.mjs} +2 -2
  8. package/dist/{model-download-service-C-IHWnXx-D326YNnt.js → model-download-service-Cp9f4dk6-Cp_GTKTw.js} +2 -2
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +147 -12
  12. package/dist/pipeline-runner/index.mjs +146 -11
  13. package/dist/recorder/index.js +993 -7
  14. package/dist/recorder/index.mjs +990 -5
  15. package/dist/session-decode/decode-worker-child.js +110 -2
  16. package/dist/session-decode/decode-worker-child.mjs +110 -2
  17. package/dist/{hub-hostname-cCknRYKj.mjs → sheet-geometry-BN80zpCa.js} +56 -1
  18. package/dist/{hub-hostname-DAJXlOgV.js → sheet-geometry-Bk3fR4s7.mjs} +39 -6
  19. package/dist/{step-definitions-DHAxruAQ.js → step-definitions-CPch37vh.js} +1 -1
  20. package/dist/{step-definitions-1d3_vQ6S.mjs → step-definitions-DUB27V8Y.mjs} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DdkfKDgw.mjs} +3 -3
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D3h_W9uu.mjs +26 -0
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DSXctoaw.mjs +26 -0
  25. package/dist/stream-broker/{hostInit-DEaqJpGA.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
  26. package/dist/stream-broker/index.js +626 -152
  27. package/dist/stream-broker/index.mjs +626 -152
  28. package/dist/stream-broker/remoteEntry.js +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CmZbzLQi.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js → PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js} +1 -1
  32. package/embed-dist/assets/index-W9-H5LTj.js +81 -0
  33. package/embed-dist/index.html +1 -1
  34. package/package.json +1 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Ba7Eyd47.mjs +0 -26
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-TWCUWhCm.mjs +0 -26
  37. package/embed-dist/assets/index-DOsK8CZb.js +0 -81
@@ -1,7 +1,7 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { $ as boolean, B as DeviceType, D as nodePin, F as webrtcSessionCapability, G as makeProfileBrokerId, H as createEvent, I as errMsg, J as parseProfileBrokerId, K as makeSourceBrokerId, L as BaseAddon, N as streamBrokerCapability, Q as array, R as CAM_PROFILE_ORDER, V as EventCategory, W as isEvent, Z as _enum, at as record, c as EncodeProfileSchema, d as RingBuffer, et as discriminatedUnion, g as cameraStreamsCapability, it as object, nt as literal, ot as string, p as addonWidgetsSourceCapability, rt as number, st as union, w as maskUrlCredentials, z as DeviceFeature } from "../dist-CL531uVA.mjs";
2
+ import { $ as discriminatedUnion, B as DeviceType, D as nodePin, F as webrtcSessionCapability, G as makeSourceBrokerId, I as errMsg, L as BaseAddon, N as streamBrokerCapability, Q as boolean, R as CAM_PROFILE_ORDER, U as isEvent, V as createEvent, W as makeProfileBrokerId, X as _enum, Z as array, at as string, c as EncodeProfileSchema, d as RingBuffer, g as cameraStreamsCapability, it as record, nt as number, ot as union, p as addonWidgetsSourceCapability, q as parseProfileBrokerId, rt as object, st as EventCategory, tt as literal, w as maskUrlCredentials, z as DeviceFeature } from "../dist-DKeNH_5z.mjs";
3
3
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
4
- import { t as createFileDataPlaneHandler } from "../model-download-service-C-IHWnXx-3Mmeob3l.mjs";
4
+ import { t as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-0wh7OK3s.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import { spawn } from "node:child_process";
7
7
  import { pathToFileURL } from "node:url";
@@ -23,12 +23,28 @@ import { EventEmitter } from "node:events";
23
23
  //#region src/stream-broker/embed-file-handler.ts
24
24
  /** Build a `(req, res)` handler serving the embed bundle at `root` with SPA fallback.
25
25
  * Web MIME types (`.html`/`.js`/`.css`/…) come from the core handler's defaults. */
26
+ /** Cache-Control for the CONTENT-HASHED vite assets (`/assets/index-<hash>.js`):
27
+ * a new build changes the filename, so the old bytes can cache forever. The
28
+ * entry `index.html` keeps the handler's default `no-cache`. Without this the
29
+ * WebView re-downloaded the ~1.5MB bundle on EVERY player open — a real chunk
30
+ * of the "live stream takes ~10s to start" cold-start cost (worse on 4G). */
31
+ var IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
26
32
  function createEmbedSpaHandler(root) {
27
- const fileHandler = createFileDataPlaneHandler({ getRoots: () => [root] });
33
+ const fileHandler = createFileDataPlaneHandler({
34
+ getRoots: () => [root],
35
+ cacheControl: (rel) => rel.startsWith("assets/") ? IMMUTABLE_CACHE : void 0
36
+ });
28
37
  return (req, res) => {
29
38
  const [pathPart = "/", query] = (req.url ?? "/").split("?");
30
39
  const lastSegment = pathPart.split("/").pop() ?? "";
31
40
  if (pathPart === "/" || pathPart === "" || !lastSegment.includes(".")) req.url = query !== void 0 ? `/index.html?${query}` : "/index.html";
41
+ else if (pathPart.startsWith("/assets/")) {
42
+ const origWriteHead = res.writeHead.bind(res);
43
+ res.writeHead = ((status, headers) => origWriteHead(status, {
44
+ ...headers,
45
+ "cache-control": IMMUTABLE_CACHE
46
+ }));
47
+ }
32
48
  return fileHandler(req, res);
33
49
  };
34
50
  }
@@ -2234,6 +2250,133 @@ var H264Repacketizer = class {
2234
2250
  }
2235
2251
  };
2236
2252
  //#endregion
2253
+ //#region src/stream-broker/webrtc/paced-rtp-replay.ts
2254
+ var PACED_REPLAY_DEFAULTS = {
2255
+ singleShotMax: 64,
2256
+ tickMs: 5,
2257
+ targetDurationMs: 300,
2258
+ minChunkSize: 32,
2259
+ maxChunkSize: 128
2260
+ };
2261
+ var defaultSchedule = (fn, ms) => {
2262
+ const t = setTimeout(fn, ms);
2263
+ return () => clearTimeout(t);
2264
+ };
2265
+ /**
2266
+ * Compute the per-tick chunk size for a burst of `total` packets: enough to
2267
+ * drain within `targetDurationMs` at one chunk per `tickMs`, clamped to
2268
+ * [minChunkSize, maxChunkSize]. Bursts ≤ `singleShotMax` are one chunk.
2269
+ */
2270
+ function computeReplayChunkSize(total, tuning) {
2271
+ if (total <= tuning.singleShotMax) return Math.max(total, 1);
2272
+ const maxTicks = Math.max(1, Math.floor(tuning.targetDurationMs / tuning.tickMs));
2273
+ const ideal = Math.ceil(total / maxTicks);
2274
+ return Math.min(tuning.maxChunkSize, Math.max(tuning.minChunkSize, ideal));
2275
+ }
2276
+ /**
2277
+ * One in-flight paced replay. Create per burst; not reusable after
2278
+ * completion or abort.
2279
+ */
2280
+ var PacedRtpReplay = class {
2281
+ send;
2282
+ onDone;
2283
+ tuning;
2284
+ schedule;
2285
+ now;
2286
+ queue = [];
2287
+ cancelTimer = null;
2288
+ started = false;
2289
+ finished = false;
2290
+ startedAt = 0;
2291
+ chunkSize = 0;
2292
+ initialPackets = 0;
2293
+ enqueuedLive = 0;
2294
+ sentCount = 0;
2295
+ chunkCount = 0;
2296
+ constructor(send, onDone, options) {
2297
+ this.send = send;
2298
+ this.onDone = onDone;
2299
+ this.tuning = {
2300
+ ...PACED_REPLAY_DEFAULTS,
2301
+ ...options?.tuning
2302
+ };
2303
+ this.schedule = options?.schedule ?? defaultSchedule;
2304
+ this.now = options?.now ?? Date.now;
2305
+ }
2306
+ /** True while packets remain to be drained (live packets must be enqueued,
2307
+ * not sent directly, to preserve order). */
2308
+ get active() {
2309
+ return this.started && !this.finished;
2310
+ }
2311
+ /**
2312
+ * Begin the replay: sends the first chunk synchronously, then paces the
2313
+ * rest. Calling `start` more than once is a no-op.
2314
+ */
2315
+ start(initial) {
2316
+ if (this.started) return;
2317
+ this.started = true;
2318
+ this.startedAt = this.now();
2319
+ this.initialPackets = initial.length;
2320
+ this.queue = [...initial];
2321
+ this.chunkSize = computeReplayChunkSize(initial.length, this.tuning);
2322
+ this.drainChunk();
2323
+ }
2324
+ /**
2325
+ * Append a live packet behind the still-queued replay tail. No-op (returns
2326
+ * false) when the replay is not active — the caller must then send the
2327
+ * packet directly.
2328
+ */
2329
+ enqueue(item) {
2330
+ if (!this.active) return false;
2331
+ this.queue.push(item);
2332
+ this.enqueuedLive++;
2333
+ return true;
2334
+ }
2335
+ /** Cancel the replay: pending packets are dropped, `onDone` fires with
2336
+ * `aborted: true`. Safe to call multiple times / before `start`. */
2337
+ abort() {
2338
+ if (this.finished) return;
2339
+ if (!this.started) {
2340
+ this.started = true;
2341
+ this.startedAt = this.now();
2342
+ }
2343
+ this.finish(true);
2344
+ }
2345
+ drainChunk() {
2346
+ if (this.finished) return;
2347
+ this.chunkCount++;
2348
+ const n = Math.min(this.chunkSize, this.queue.length);
2349
+ for (let i = 0; i < n; i++) if (this.send(this.queue[i])) this.sentCount++;
2350
+ this.queue = this.queue.slice(n);
2351
+ if (this.queue.length === 0) {
2352
+ this.finish(false);
2353
+ return;
2354
+ }
2355
+ this.cancelTimer = this.schedule(() => {
2356
+ this.cancelTimer = null;
2357
+ this.drainChunk();
2358
+ }, this.tuning.tickMs);
2359
+ }
2360
+ finish(aborted) {
2361
+ if (this.finished) return;
2362
+ this.finished = true;
2363
+ if (this.cancelTimer) {
2364
+ this.cancelTimer();
2365
+ this.cancelTimer = null;
2366
+ }
2367
+ this.queue = [];
2368
+ this.onDone({
2369
+ packets: this.initialPackets,
2370
+ enqueuedLive: this.enqueuedLive,
2371
+ sent: this.sentCount,
2372
+ chunks: this.chunkCount,
2373
+ chunkSize: this.chunkSize,
2374
+ durationMs: this.now() - this.startedAt,
2375
+ aborted
2376
+ });
2377
+ }
2378
+ };
2379
+ //#endregion
2237
2380
  //#region src/stream-broker/webrtc/webrtc-fallback-profile.ts
2238
2381
  /**
2239
2382
  * Hardcoded fallback encode profile for the WebRTC viewer path. Used only
@@ -2839,6 +2982,10 @@ var AdaptiveSession = class AdaptiveSession {
2839
2982
  /** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
2840
2983
  rtpBootstrap;
2841
2984
  rtpBootstrapDone = false;
2985
+ /** In-flight paced pre-buffer replay (instant-start bootstrap or PLI
2986
+ * recovery). While active, live source-RTP packets are enqueued behind
2987
+ * the replay tail to preserve packet order. Aborted on close/rebind. */
2988
+ activeRtpReplay = null;
2842
2989
  /**
2843
2990
  * Trickle ICE: the server's locally-gathered candidates, buffered as werift
2844
2991
  * surfaces them via `onIceCandidate`. The client polls `getIceCandidates`
@@ -2870,6 +3017,8 @@ var AdaptiveSession = class AdaptiveSession {
2870
3017
  /** Drop the repacketizer + bootstrap/sync state so a newly bound source
2871
3018
  * stream re-seeds its SPS/PPS and replays instant-start cleanly. */
2872
3019
  resetRepacketizer() {
3020
+ this.activeRtpReplay?.abort();
3021
+ this.activeRtpReplay = null;
2873
3022
  this.h264Repacketizer = null;
2874
3023
  this.h265Repacketizer = null;
2875
3024
  this.videoRtpSynced = false;
@@ -3570,6 +3719,10 @@ var AdaptiveSession = class AdaptiveSession {
3570
3719
  this.feedAbort.abort();
3571
3720
  this.feedAbort = null;
3572
3721
  }
3722
+ if (this.activeRtpReplay) {
3723
+ this.activeRtpReplay.abort();
3724
+ this.activeRtpReplay = null;
3725
+ }
3573
3726
  try {
3574
3727
  await this.source.return(void 0);
3575
3728
  } catch {}
@@ -3667,18 +3820,11 @@ var AdaptiveSession = class AdaptiveSession {
3667
3820
  this.rtpBootstrapDone = true;
3668
3821
  const bootstrap = this.rtpBootstrap?.() ?? [];
3669
3822
  if (bootstrap.length > 0) {
3670
- let sent = 0;
3671
- for (const b of bootstrap) if (this.sendSourceRtpPacket(b, codec)) sent++;
3672
- this.logger.info("replayed source-RTP pre-buffer (instant start)", { meta: {
3673
- phase: "session",
3674
- sessionId: this.sessionId,
3675
- codec,
3676
- packets: bootstrap.length,
3677
- sent
3678
- } });
3823
+ this.startPacedRtpReplay(bootstrap, codec, "replayed source-RTP pre-buffer (instant start)");
3679
3824
  return;
3680
3825
  }
3681
3826
  }
3827
+ if (this.activeRtpReplay?.active && this.activeRtpReplay.enqueue(rtpData)) return;
3682
3828
  this.sendSourceRtpPacket(rtpData, codec);
3683
3829
  }
3684
3830
  /**
@@ -3768,6 +3914,35 @@ var AdaptiveSession = class AdaptiveSession {
3768
3914
  }
3769
3915
  return sent;
3770
3916
  }
3917
+ /**
3918
+ * Start a paced replay of a source-RTP pre-buffer burst (instant-start
3919
+ * bootstrap or PLI recovery). The first chunk goes out synchronously; the
3920
+ * rest drain on a short timer cadence (see `PACED_REPLAY_DEFAULTS`:
3921
+ * 5 ms ticks, 32-128 packets/tick scaled to finish within ~300 ms).
3922
+ * An already-running replay is aborted first (goLive re-bootstrap). The
3923
+ * completion log preserves the historical message and adds pacing stats.
3924
+ */
3925
+ startPacedRtpReplay(bootstrap, codec, logMessage) {
3926
+ this.activeRtpReplay?.abort();
3927
+ const replay = new PacedRtpReplay((pkt) => this.sendSourceRtpPacket(pkt, codec), (stats) => {
3928
+ if (this.activeRtpReplay === replay) this.activeRtpReplay = null;
3929
+ const meta = {
3930
+ phase: "session",
3931
+ sessionId: this.sessionId,
3932
+ codec,
3933
+ packets: stats.packets,
3934
+ sent: stats.sent,
3935
+ chunks: stats.chunks,
3936
+ chunkSize: stats.chunkSize,
3937
+ durationMs: stats.durationMs,
3938
+ enqueuedLive: stats.enqueuedLive
3939
+ };
3940
+ if (stats.aborted) this.logger.debug(`${logMessage} — aborted mid-replay`, { meta });
3941
+ else this.logger.info(logMessage, { meta });
3942
+ });
3943
+ this.activeRtpReplay = replay;
3944
+ replay.start(bootstrap);
3945
+ }
3771
3946
  setupPliListener() {
3772
3947
  const sender = this.videoSender;
3773
3948
  if (!sender) return;
@@ -3806,20 +3981,19 @@ var AdaptiveSession = class AdaptiveSession {
3806
3981
  const now = Date.now();
3807
3982
  if (now - this.lastPliResendAt < AdaptiveSession.PLI_RESEND_COOLDOWN_MS) return;
3808
3983
  this.lastPliResendAt = now;
3984
+ if (this.activeRtpReplay?.active) {
3985
+ this.logger.debug("PLI received — paced pre-buffer replay already in flight", { meta: {
3986
+ phase: "session",
3987
+ sessionId: this.sessionId
3988
+ } });
3989
+ return;
3990
+ }
3809
3991
  if (this.rtpBootstrap) {
3810
3992
  const codec = this.negotiatedCodec;
3811
3993
  if (codec === "H264" || codec === "H265") {
3812
3994
  const bootstrap = this.rtpBootstrap();
3813
3995
  if (bootstrap.length > 0) {
3814
- let sent = 0;
3815
- for (const b of bootstrap) if (this.sendSourceRtpPacket(b, codec)) sent++;
3816
- this.logger.info("PLI received — replayed source-RTP pre-buffer", { meta: {
3817
- phase: "session",
3818
- sessionId: this.sessionId,
3819
- codec,
3820
- packets: bootstrap.length,
3821
- sent
3822
- } });
3996
+ this.startPacedRtpReplay(bootstrap, codec, "PLI received — replayed source-RTP pre-buffer");
3823
3997
  return;
3824
3998
  }
3825
3999
  }
@@ -14374,8 +14548,9 @@ var StreamBrokerManager = class StreamBrokerManager {
14374
14548
  const keep = new Set(descriptors.map((d) => d.camStreamId));
14375
14549
  await this.publishCatalog(deviceId, descriptors);
14376
14550
  const existing = this.cameraStreams.get(deviceId);
14377
- if (existing) {
14378
- for (const camStreamId of Array.from(existing.keys())) if (!keep.has(camStreamId)) await this.retractCameraStream({
14551
+ if (existing) for (const [camStreamId, cam] of Array.from(existing.entries())) {
14552
+ if (cam.kind === "derived") continue;
14553
+ if (!keep.has(camStreamId)) await this.retractCameraStream({
14379
14554
  deviceId,
14380
14555
  camStreamId
14381
14556
  });
@@ -16664,6 +16839,73 @@ var AdaptiveController = class {
16664
16839
  }
16665
16840
  };
16666
16841
  //#endregion
16842
+ //#region src/stream-broker/recorded/control-protocol.ts
16843
+ var ControlMessageSchema = discriminatedUnion("t", [
16844
+ object({
16845
+ t: literal("playRecorded"),
16846
+ deviceId: number(),
16847
+ epoch: number(),
16848
+ profile: string()
16849
+ }),
16850
+ object({
16851
+ t: literal("seek"),
16852
+ epoch: number()
16853
+ }),
16854
+ object({
16855
+ t: literal("scrubCommit"),
16856
+ epoch: number()
16857
+ }),
16858
+ object({
16859
+ t: literal("setRate"),
16860
+ rate: number().min(0).max(4)
16861
+ }),
16862
+ object({
16863
+ t: literal("stepFrame"),
16864
+ dir: union([literal(1), literal(-1)])
16865
+ }),
16866
+ object({ t: literal("goLive") }),
16867
+ object({
16868
+ t: literal("setProfile"),
16869
+ profile: string()
16870
+ })
16871
+ ]);
16872
+ /**
16873
+ * Clamp a requested playback rate onto the supported domain: {0} ∪ [0.25, 4].
16874
+ * 0 (and any non-finite / non-positive input) pauses; anything else is a
16875
+ * continuous rate clamped into [0.25, 4]. Single server-side authority — the
16876
+ * feeder applies it to every inbound `setRate`.
16877
+ */
16878
+ function clampPlaybackRate(rate) {
16879
+ if (!Number.isFinite(rate) || rate <= 0) return 0;
16880
+ return Math.min(4, Math.max(.25, rate));
16881
+ }
16882
+ var ServerMessageSchema = discriminatedUnion("t", [object({
16883
+ t: literal("state"),
16884
+ state: _enum([
16885
+ "live",
16886
+ "loading",
16887
+ "playing",
16888
+ "paused",
16889
+ "gap",
16890
+ "ended",
16891
+ "error"
16892
+ ])
16893
+ }), object({
16894
+ t: literal("position"),
16895
+ epochMs: number()
16896
+ })]);
16897
+ function parseControlMessage(raw) {
16898
+ try {
16899
+ const parsed = ControlMessageSchema.safeParse(JSON.parse(raw));
16900
+ return parsed.success ? parsed.data : null;
16901
+ } catch {
16902
+ return null;
16903
+ }
16904
+ }
16905
+ function serializeServerMessage(msg) {
16906
+ return JSON.stringify(ServerMessageSchema.parse(msg));
16907
+ }
16908
+ //#endregion
16667
16909
  //#region src/stream-broker/recorded/feed-clock.ts
16668
16910
  /**
16669
16911
  * Maps arbitrary, possibly-resetting source PTS (ms) onto a strictly increasing
@@ -16671,26 +16913,70 @@ var AdaptiveController = class {
16671
16913
  * seek the output keeps advancing while the source anchor re-bases, so
16672
16914
  * per-segment PTS resets never rewind the track. Video and audio use SEPARATE
16673
16915
  * instances that `markSeek()` together to stay A/V aligned across boundaries.
16916
+ *
16917
+ * Playback rate: `setRate(r)` divides SOURCE elapsed time by `r` on the output
16918
+ * timeline (2× → half the output spacing → the receiver plays frames twice as
16919
+ * fast). A mid-stream rate change re-anchors at the last stamped point so the
16920
+ * output stays continuous and monotonic across the change (like `markSeek`,
16921
+ * but without discarding source continuity).
16674
16922
  */
16675
16923
  var MonotonicClock = class {
16676
16924
  rateHz;
16677
16925
  srcAnchorMs = null;
16926
+ lastSrcMs = null;
16678
16927
  outBaseMs = 0;
16679
16928
  lastOutMs = 0;
16929
+ rate = 1;
16680
16930
  constructor(rateHz = 9e4) {
16681
16931
  this.rateHz = rateHz;
16682
16932
  }
16683
16933
  stamp(srcPtsMs) {
16684
16934
  if (this.srcAnchorMs === null) this.srcAnchorMs = srcPtsMs;
16685
- const elapsed = Math.max(0, srcPtsMs - this.srcAnchorMs);
16935
+ this.lastSrcMs = srcPtsMs;
16936
+ const elapsed = Math.max(0, (srcPtsMs - this.srcAnchorMs) / this.rate);
16686
16937
  const outMs = this.outBaseMs + elapsed;
16687
16938
  this.lastOutMs = Math.max(this.lastOutMs, outMs);
16688
16939
  return Math.round(this.lastOutMs * this.rateHz / 1e3);
16689
16940
  }
16690
- /** Call before feeding a new segment after a seek. */
16941
+ /**
16942
+ * Call before feeding a new CONTIGUOUS segment (the per-segment PTS reset at
16943
+ * a boundary during forward playback, or a paused frame-step re-feed).
16944
+ * Advances the output ~1 frame past the last emitted ts. The nudge lives in
16945
+ * the OUTPUT domain, so it scales with the rate factor (at 2× a source frame
16946
+ * occupies ~16.5 output ms) to preserve the perceived frame cadence across
16947
+ * the boundary.
16948
+ */
16691
16949
  markSeek() {
16692
- this.outBaseMs = this.lastOutMs + 33;
16950
+ this.markDiscontinuity(33 / this.rate);
16951
+ }
16952
+ /**
16953
+ * Call before feeding a NON-contiguous position (a seek / scrub commit).
16954
+ * Like `markSeek` but with an explicit, caller-chosen output gap: a seek must
16955
+ * be unmistakable to the receiver — with only a ~1-frame nudge, stale
16956
+ * in-flight access units from the aborted feed and the new GOP look like one
16957
+ * contiguous stream, and a lost IDR then smears the new deltas onto pre-seek
16958
+ * references. The gap is an intentional discontinuity, so it is NOT
16959
+ * rate-scaled.
16960
+ */
16961
+ markDiscontinuity(gapMs) {
16962
+ this.outBaseMs = this.lastOutMs + gapMs;
16693
16963
  this.srcAnchorMs = null;
16964
+ this.lastSrcMs = null;
16965
+ }
16966
+ /**
16967
+ * Change the playback-rate factor (r > 0; 0/negative/redundant are no-ops —
16968
+ * "paused" is the absence of stamping, not a clock rate). Re-anchors both the
16969
+ * source and output baselines at the last stamped point so already-emitted
16970
+ * timestamps stay fixed and the next stamp continues monotonically at the new
16971
+ * rate.
16972
+ */
16973
+ setRate(rate) {
16974
+ if (!(rate > 0) || rate === this.rate) return;
16975
+ if (this.srcAnchorMs !== null && this.lastSrcMs !== null) {
16976
+ this.outBaseMs = this.lastOutMs;
16977
+ this.srcAnchorMs = this.lastSrcMs;
16978
+ }
16979
+ this.rate = rate;
16694
16980
  }
16695
16981
  };
16696
16982
  //#endregion
@@ -16704,23 +16990,54 @@ var POSITION_INTERVAL_MS = 1e3;
16704
16990
  * keeping seeks responsive without per-frame drift.
16705
16991
  */
16706
16992
  var WAIT_TICK_MS = 250;
16993
+ /**
16994
+ * Output-clock gap stamped at every seek/scrub commit (markDiscontinuity on
16995
+ * both A/V clocks). Root cause of the "smeared frame on scrub": the aborted
16996
+ * pace loop's last access units are already in the RTP send path when the new
16997
+ * feed starts; with only the ~1-frame (+33 ms) continuation nudge the receiver
16998
+ * sees ONE contiguous timestamp stream, so if any packet of the new feed's IDR
16999
+ * is lost in the seek burst, the decoder decodes the following deltas against
17000
+ * PRE-seek reference frames — the classic wrong-reference smear. A 250 ms jump
17001
+ * makes the seek an unambiguous stream discontinuity: stale lower-ts units
17002
+ * can't look contiguous with the new GOP, and the receiver resets timing
17003
+ * instead of merging the two feeds.
17004
+ */
17005
+ var SEEK_DISCONTINUITY_MS = 250;
16707
17006
  var RecordedFeeder = class {
16708
17007
  deps;
16709
17008
  clock = new MonotonicClock();
16710
17009
  /** Separate 8 kHz clock for PCMU audio; re-anchored together with video. */
16711
17010
  audioClock = new MonotonicClock(8e3);
16712
17011
  clockHasAdvanced = false;
17012
+ /** Playback rate: 0 (paused) or a continuous factor in [0.25, 4]. */
16713
17013
  rate = 1;
16714
17014
  disposed = false;
16715
17015
  seekToken = 0;
16716
17016
  advanceToken = 0;
16717
17017
  cursorMs = 0;
16718
17018
  positionTimer = null;
17019
+ /**
17020
+ * The demuxed segment "under the cursor" — the frame-step working set. Kept
17021
+ * in lockstep with every feed path (seek burst, paused partial feed, pacing)
17022
+ * so a pause at any moment can step from the exact last-fed frame.
17023
+ */
17024
+ currentSegment = null;
17025
+ /** Index (into `currentSegment.aus`) of the last video AU fed; -1 = none. */
17026
+ lastFedVideoIdx = -1;
17027
+ /** Serializes frame steps: a step arriving while one is in flight is dropped. */
17028
+ stepInFlight = false;
17029
+ /**
17030
+ * Target epoch of the seek currently in flight (null when none). A rate
17031
+ * change re-enters via seek(); while a seek is still resolving, `cursorMs`
17032
+ * is stale — re-seeking the in-flight target instead avoids snapping back to
17033
+ * the pre-seek position when the client changes rate right after a seek.
17034
+ */
17035
+ inFlightSeekEpochMs = null;
16719
17036
  constructor(deps) {
16720
17037
  this.deps = deps;
16721
17038
  }
16722
17039
  /**
16723
- * Seek to an epoch and (if rate is 1) begin forward playback. Resolves after
17040
+ * Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
16724
17041
  * the FIRST located segment is fed; the forward `advance` loop is started
16725
17042
  * fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
16726
17043
  * seek bumps the token and silently abandons this one.
@@ -16728,11 +17045,12 @@ var RecordedFeeder = class {
16728
17045
  async seek(epochMs) {
16729
17046
  const token = ++this.seekToken;
16730
17047
  this.advanceToken++;
17048
+ this.inFlightSeekEpochMs = epochMs;
16731
17049
  try {
16732
17050
  this.deps.onState("loading");
16733
17051
  if (this.clockHasAdvanced) {
16734
- this.clock.markSeek();
16735
- this.audioClock.markSeek();
17052
+ this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
17053
+ this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
16736
17054
  }
16737
17055
  const loc = await this.withTimeout(this.deps.locate({
16738
17056
  deviceId: this.deps.deviceId,
@@ -16741,93 +17059,274 @@ var RecordedFeeder = class {
16741
17059
  }));
16742
17060
  if (this.disposed || token !== this.seekToken) return;
16743
17061
  if (loc.kind === "gap") {
17062
+ this.currentSegment = null;
17063
+ this.lastFedVideoIdx = -1;
16744
17064
  this.stopPositionReports();
16745
17065
  this.deps.onState("gap");
16746
17066
  return;
16747
17067
  }
16748
17068
  this.cursorMs = loc.startMs;
16749
- await this.feedSegment(loc.startMs, token);
16750
- if (this.disposed || token !== this.seekToken) return;
16751
- this.cursorMs = loc.startMs + loc.durMs;
16752
- this.deps.onState(this.rate === 0 ? "paused" : "playing");
17069
+ const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
17070
+ if (this.disposed || token !== this.seekToken || loaded === null) return;
17071
+ this.feedPrefix(loc, loaded.video, epochMs);
17072
+ if (this.rate === 0) {
17073
+ this.deps.onState("paused");
17074
+ this.deps.onPosition(this.cursorMs);
17075
+ this.startPositionReports();
17076
+ return;
17077
+ }
17078
+ this.deps.onState("playing");
17079
+ this.deps.onPosition(this.cursorMs);
16753
17080
  this.startPositionReports();
16754
- if (this.rate === 1) this.startAdvance();
17081
+ const lastFedAu = this.lastFedVideoIdx >= 0 ? loaded.video[this.lastFedVideoIdx] : void 0;
17082
+ this.startAdvance({
17083
+ prepared: {
17084
+ startMs: loc.startMs,
17085
+ durMs: loc.durMs,
17086
+ aus: loaded.video,
17087
+ audio: loaded.audio
17088
+ },
17089
+ resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
17090
+ });
16755
17091
  } catch {
16756
17092
  if (!this.disposed && token === this.seekToken) {
16757
17093
  this.stopPositionReports();
16758
17094
  this.deps.onState("error");
16759
17095
  }
17096
+ } finally {
17097
+ if (token === this.seekToken) this.inFlightSeekEpochMs = null;
16760
17098
  }
16761
17099
  }
16762
- /** Set playback rate. 0 pauses; 1 (re)starts the forward advance loop. */
17100
+ /**
17101
+ * Set playback rate. 0 pauses in place (the pacing loop aborts on its next
17102
+ * guard check); any other value is clamped to [0.25, 4].
17103
+ *
17104
+ * Rate-change strategy (documented choice): every transition TO a non-zero
17105
+ * rate — resume from pause AND a playing→playing rate change — re-enters via
17106
+ * `seek(cursorMs)`. seek() bumps `advanceToken` up-front, which aborts any
17107
+ * mid-segment pacing loop, then re-feeds the covering segment from its IDR
17108
+ * (a burst — the decoder needs the GOP head) and restarts the advance loop
17109
+ * pacing at the new rate. No in-place re-pacing of a half-delivered segment.
17110
+ */
16763
17111
  setRate(rate) {
16764
17112
  if (this.disposed) return;
16765
- if (rate === this.rate) return;
16766
- this.rate = rate;
16767
- if (rate === 0) this.stopPositionReports();
16768
- this.deps.onState(rate === 0 ? "paused" : "playing");
16769
- if (rate === 1) this.startAdvance();
17113
+ const next = clampPlaybackRate(rate);
17114
+ if (next === this.rate) return;
17115
+ this.rate = next;
17116
+ if (next === 0) {
17117
+ this.stopPositionReports();
17118
+ this.deps.onState("paused");
17119
+ return;
17120
+ }
17121
+ this.clock.setRate(next);
17122
+ this.seek(this.inFlightSeekEpochMs ?? this.cursorMs);
17123
+ }
17124
+ /**
17125
+ * Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
17126
+ * paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
17127
+ * at a segment end crosses into the next segment (gaps are skipped via the
17128
+ * same lookahead the advance loop uses); backward at a segment start loads
17129
+ * the PREVIOUS segment and lands on its last frame. Backward re-feeds from
17130
+ * the nearest keyframe at-or-before the target so the decoder has a valid
17131
+ * GOP head. Edge no-ops: forward past the end of the recording, backward
17132
+ * into a gap/start of the recording, and a step while another cross-segment
17133
+ * step is still in flight.
17134
+ */
17135
+ stepFrame(dir) {
17136
+ if (this.disposed || this.rate !== 0 || this.stepInFlight) return;
17137
+ this.stepInFlight = true;
17138
+ this.doStep(dir).catch(() => {}).finally(() => {
17139
+ this.stepInFlight = false;
17140
+ });
16770
17141
  }
16771
- async feedSegment(startMs, token) {
16772
- const bytes = await this.withTimeout(this.deps.readBytes({
17142
+ async doStep(dir) {
17143
+ const seg = this.currentSegment;
17144
+ if (!seg || seg.aus.length === 0) return;
17145
+ const token = this.seekToken;
17146
+ const gen = this.advanceToken;
17147
+ if (dir === 1) {
17148
+ const nextIdx = this.lastFedVideoIdx + 1;
17149
+ if (nextIdx < seg.aus.length) {
17150
+ this.feedOneAu(seg, nextIdx);
17151
+ return;
17152
+ }
17153
+ const prepared = await this.prepareNext(seg.startMs + seg.durMs, gen);
17154
+ if (prepared === null || prepared === "ended") return;
17155
+ if (this.disposed || token !== this.seekToken || this.rate !== 0) return;
17156
+ if (prepared.aus.length === 0) return;
17157
+ if (this.clockHasAdvanced) {
17158
+ this.clock.markSeek();
17159
+ this.audioClock.markSeek();
17160
+ }
17161
+ this.currentSegment = {
17162
+ startMs: prepared.startMs,
17163
+ durMs: prepared.durMs,
17164
+ aus: prepared.aus
17165
+ };
17166
+ this.feedOneAu(this.currentSegment, 0);
17167
+ return;
17168
+ }
17169
+ if (this.lastFedVideoIdx > 0) {
17170
+ this.refeedUpTo(seg, this.lastFedVideoIdx - 1);
17171
+ return;
17172
+ }
17173
+ let loc = await this.withTimeout(this.deps.locate({
16773
17174
  deviceId: this.deps.deviceId,
16774
17175
  profile: this.deps.profile,
16775
- startMs
17176
+ epochMs: seg.startMs - 1
16776
17177
  }));
16777
- if (this.disposed || token !== this.seekToken) return;
16778
- const aus = await this.withTimeout(this.deps.demux(bytes));
16779
- if (this.disposed || token !== this.seekToken) return;
16780
- for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
16781
- if (aus.length > 0) this.clockHasAdvanced = true;
16782
- await this.feedAudio(bytes, token);
16783
- }
16784
- /**
16785
- * Demux+transcode the segment's audio to PCMU and push it, re-stamped on the
16786
- * 8 kHz audio clock. Best-effort: audio NEVER blocks video a demux/transcode
16787
- * failure or a video-only sink simply yields silence.
16788
- */
16789
- async feedAudio(bytes, token) {
16790
- const demuxAudio = this.deps.demuxAudio;
16791
- const pushAudio = this.deps.sink.pushAudioPcmu;
16792
- if (!demuxAudio || !pushAudio) return;
16793
- let audio;
16794
- try {
16795
- audio = await this.withTimeout(demuxAudio(bytes));
16796
- } catch {
17178
+ if (this.disposed || token !== this.seekToken || this.rate !== 0) return;
17179
+ if (loc.kind === "gap" && loc.prevEndMs != null && loc.prevEndMs <= seg.startMs) {
17180
+ loc = await this.withTimeout(this.deps.locate({
17181
+ deviceId: this.deps.deviceId,
17182
+ profile: this.deps.profile,
17183
+ epochMs: loc.prevEndMs - 1
17184
+ }));
17185
+ if (this.disposed || token !== this.seekToken || this.rate !== 0) return;
17186
+ }
17187
+ if (loc.kind !== "segment" || loc.startMs >= seg.startMs) return;
17188
+ const loaded = await this.loadAus(loc.startMs, false);
17189
+ if (loaded === null || loaded.video.length === 0) return;
17190
+ if (this.disposed || this.rate !== 0) return;
17191
+ this.currentSegment = {
17192
+ startMs: loc.startMs,
17193
+ durMs: loc.durMs,
17194
+ aus: loaded.video
17195
+ };
17196
+ this.refeedUpTo(this.currentSegment, this.currentSegment.aus.length - 1);
17197
+ }
17198
+ /**
17199
+ * Clamp a frame-derived cursor into the segment's covered window
17200
+ * [startMs, startMs + durMs). Real frames can outlive the muxed `durMs`
17201
+ * (last pts − first pts ≥ durMs), and `startMs + durMs` itself is already
17202
+ * inside the inter-segment crack — a cursor parked there makes the next
17203
+ * `seek(cursorMs)` (resume/rate-change) locate a GAP and freeze playback.
17204
+ */
17205
+ clampIntoSegment(seg, epochMs) {
17206
+ return Math.min(Math.max(epochMs, seg.startMs), seg.startMs + seg.durMs - 1);
17207
+ }
17208
+ /** Push exactly one AU of `seg`, move the step cursor onto it, report position. */
17209
+ feedOneAu(seg, idx) {
17210
+ const au = seg.aus[idx];
17211
+ const first = seg.aus[0];
17212
+ if (!au || !first) return;
17213
+ this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
17214
+ this.clockHasAdvanced = true;
17215
+ this.lastFedVideoIdx = idx;
17216
+ this.cursorMs = this.clampIntoSegment(seg, seg.startMs + (au.ptsMs - first.ptsMs));
17217
+ this.deps.onPosition(this.cursorMs);
17218
+ }
17219
+ /**
17220
+ * Backward-step render: re-feed from the nearest keyframe at-or-before
17221
+ * `targetIdx` up TO `targetIdx` (decode needs the GOP head; the burst renders
17222
+ * fast and the display lands exactly one frame earlier). Re-anchors the
17223
+ * clocks first — the re-fed PTS run BEHIND the monotonic clock's anchor and
17224
+ * would otherwise clamp into duplicate output timestamps.
17225
+ */
17226
+ refeedUpTo(seg, targetIdx) {
17227
+ const first = seg.aus[0];
17228
+ const target = seg.aus[targetIdx];
17229
+ if (!first || !target) return;
17230
+ let fromIdx = 0;
17231
+ for (let i = targetIdx; i >= 0; i--) if (seg.aus[i]?.keyframe) {
17232
+ fromIdx = i;
17233
+ break;
17234
+ }
17235
+ if (this.clockHasAdvanced) {
17236
+ this.clock.markSeek();
17237
+ this.audioClock.markSeek();
17238
+ }
17239
+ for (let i = fromIdx; i <= targetIdx; i++) {
17240
+ const au = seg.aus[i];
17241
+ if (au) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
17242
+ }
17243
+ this.clockHasAdvanced = true;
17244
+ this.lastFedVideoIdx = targetIdx;
17245
+ this.cursorMs = this.clampIntoSegment(seg, seg.startMs + (target.ptsMs - first.ptsMs));
17246
+ this.deps.onPosition(this.cursorMs);
17247
+ }
17248
+ /**
17249
+ * Seek prefix feed (decode priming): push only the AUs with
17250
+ * `ptsMs − firstPtsMs ≤ requestedEpochMs − loc.startMs` — always at least the
17251
+ * first (IDR-led) AU so the feed is never empty — then park the cursor at the
17252
+ * requested epoch (clamped into the segment window). While paused this IS the
17253
+ * feed (exact-frame render); at rate > 0 it primes the decoder up to the
17254
+ * cursor and the advance loop paces the remainder. The demuxer contract
17255
+ * guarantees `aus[0]` is the segment's params-fused IDR, so the prefix always
17256
+ * starts at a keyframe.
17257
+ */
17258
+ feedPrefix(loc, aus, requestedEpochMs) {
17259
+ const clampedEpoch = Math.min(Math.max(requestedEpochMs, loc.startMs), loc.startMs + loc.durMs - 1);
17260
+ this.currentSegment = {
17261
+ startMs: loc.startMs,
17262
+ durMs: loc.durMs,
17263
+ aus
17264
+ };
17265
+ const first = aus[0];
17266
+ if (!first) {
17267
+ this.lastFedVideoIdx = -1;
17268
+ this.cursorMs = clampedEpoch;
16797
17269
  return;
16798
17270
  }
16799
- if (this.disposed || token !== this.seekToken) return;
16800
- for (const au of audio) pushAudio.call(this.deps.sink, au.data, this.audioClock.stamp(au.ptsMs));
17271
+ const offsetMs = requestedEpochMs - loc.startMs;
17272
+ let lastIdx = 0;
17273
+ for (let i = 1; i < aus.length; i++) {
17274
+ const au = aus[i];
17275
+ if (au && au.ptsMs - first.ptsMs <= offsetMs) lastIdx = i;
17276
+ else break;
17277
+ }
17278
+ for (let i = 0; i <= lastIdx; i++) {
17279
+ const au = aus[i];
17280
+ if (au) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
17281
+ }
17282
+ this.clockHasAdvanced = true;
17283
+ this.lastFedVideoIdx = lastIdx;
17284
+ this.cursorMs = clampedEpoch;
16801
17285
  }
16802
17286
  /**
16803
17287
  * Start (or restart) the single forward advance loop. Bumping `advanceToken`
16804
17288
  * invalidates any loop already running, guaranteeing at most one survives.
17289
+ * `initial` hands the loop the seek's already-loaded covering segment plus
17290
+ * the prefix boundary, so pacing resumes FROM THE CURSOR instead of the loop
17291
+ * re-locating (and thereby skipping) the segment under it.
16805
17292
  */
16806
- startAdvance() {
17293
+ startAdvance(initial) {
16807
17294
  const gen = ++this.advanceToken;
16808
- this.advanceLoop(gen).catch(() => {
17295
+ this.advanceLoop(gen, initial).catch(() => {
16809
17296
  if (!this.disposed && gen === this.advanceToken) {
16810
17297
  this.stopPositionReports();
16811
17298
  this.deps.onState("error");
16812
17299
  }
16813
17300
  });
16814
17301
  }
16815
- async advanceLoop(gen) {
16816
- let prepared = await this.prepareNext(this.cursorMs, gen);
16817
- while (!this.disposed && gen === this.advanceToken && this.rate === 1) {
17302
+ async advanceLoop(gen, initial) {
17303
+ let prepared;
17304
+ let resumeAfterPtsMs = null;
17305
+ let isResumedSegment = false;
17306
+ if (initial) {
17307
+ prepared = initial.prepared;
17308
+ resumeAfterPtsMs = initial.resumeAfterPtsMs;
17309
+ isResumedSegment = true;
17310
+ } else prepared = await this.prepareNext(this.cursorMs, gen);
17311
+ while (!this.disposed && gen === this.advanceToken && this.rate > 0) {
16818
17312
  if (prepared === null) return;
16819
17313
  if (prepared === "ended") {
16820
17314
  this.stopPositionReports();
16821
17315
  this.deps.onState("ended");
16822
17316
  return;
16823
17317
  }
16824
- this.cursorMs = prepared.startMs;
16825
- if (this.clockHasAdvanced) {
16826
- this.clock.markSeek();
16827
- this.audioClock.markSeek();
17318
+ if (isResumedSegment) {} else {
17319
+ this.cursorMs = prepared.startMs;
17320
+ if (this.clockHasAdvanced) {
17321
+ this.clock.markSeek();
17322
+ this.audioClock.markSeek();
17323
+ }
16828
17324
  }
16829
17325
  const prefetch = this.prepareNext(prepared.startMs + prepared.durMs, gen);
16830
- if (await this.paceSegment(prepared, gen)) {
17326
+ const aborted = await this.paceSegment(prepared, gen, resumeAfterPtsMs);
17327
+ resumeAfterPtsMs = null;
17328
+ isResumedSegment = false;
17329
+ if (aborted) {
16831
17330
  prefetch.catch(() => {});
16832
17331
  return;
16833
17332
  }
@@ -16838,45 +17337,67 @@ var RecordedFeeder = class {
16838
17337
  /**
16839
17338
  * Drip one prepared segment to the sink at its real frame cadence: build a
16840
17339
  * single PTS-ordered schedule of the segment's video + audio access units and
16841
- * push each at `segmentStart + (pts - firstPts)` wall-clock time, sleeping
16842
- * between items with a drift-free absolute-time wait. RTP timestamps come from
16843
- * the SAME `clock.stamp` / `audioClock.stamp` calls as before — only delivery
16844
- * timing changes. Returns `true` if a seek/dispose/rate-change aborted pacing
16845
- * mid-segment (the caller must stop without advancing the cursor).
16846
- */
16847
- async paceSegment(prepared, gen) {
16848
- const hasAudioSink = typeof this.deps.sink.pushAudioPcmu === "function";
17340
+ * push each at `segmentStart + (pts - firstPts) / rate` wall-clock time,
17341
+ * sleeping between items with a drift-free absolute-time wait. RTP timestamps
17342
+ * come from the SAME `clock.stamp` / `audioClock.stamp` calls as before (the
17343
+ * clock itself carries the rate factor) only delivery timing changes.
17344
+ * `resumeAfterPtsMs` (a resumed covering segment) paces only the items
17345
+ * strictly after the seek prefix, over the segment's REMAINING window.
17346
+ * Returns `true` if a seek/dispose/pause aborted pacing mid-segment (the
17347
+ * caller must stop without advancing the cursor).
17348
+ */
17349
+ async paceSegment(prepared, gen, resumeAfterPtsMs = null) {
17350
+ const rate = this.rate;
17351
+ const hasAudioSink = typeof this.deps.sink.pushAudioPcmu === "function" && rate === 1;
16849
17352
  const schedule = [];
16850
- for (const au of prepared.aus) schedule.push({
17353
+ for (const au of prepared.aus) if (resumeAfterPtsMs === null || au.ptsMs > resumeAfterPtsMs) schedule.push({
16851
17354
  ptsMs: au.ptsMs,
16852
17355
  video: au
16853
17356
  });
16854
- if (hasAudioSink) for (const au of prepared.audio) schedule.push({
16855
- ptsMs: au.ptsMs,
16856
- audio: au
16857
- });
17357
+ if (hasAudioSink) {
17358
+ for (const au of prepared.audio) if (resumeAfterPtsMs === null || au.ptsMs > resumeAfterPtsMs) schedule.push({
17359
+ ptsMs: au.ptsMs,
17360
+ audio: au
17361
+ });
17362
+ }
16858
17363
  schedule.sort((a, b) => {
16859
17364
  if (a.ptsMs !== b.ptsMs) return a.ptsMs - b.ptsMs;
16860
17365
  return (a.video ? 0 : 1) - (b.video ? 0 : 1);
16861
17366
  });
16862
- if (schedule.length === 0) return await this.sleepUntil(performance.now() + prepared.durMs, gen);
16863
- const firstPtsMs = schedule[0].ptsMs;
17367
+ this.currentSegment = {
17368
+ startMs: prepared.startMs,
17369
+ durMs: prepared.durMs,
17370
+ aus: prepared.aus
17371
+ };
17372
+ let videoIdx = 0;
17373
+ if (resumeAfterPtsMs === null) this.lastFedVideoIdx = -1;
17374
+ else {
17375
+ while (videoIdx < prepared.aus.length && prepared.aus[videoIdx].ptsMs <= resumeAfterPtsMs) videoIdx++;
17376
+ this.lastFedVideoIdx = videoIdx - 1;
17377
+ }
17378
+ const firstVideoPtsMs = prepared.aus[0]?.ptsMs ?? 0;
17379
+ const consumedMs = resumeAfterPtsMs === null ? 0 : Math.max(0, resumeAfterPtsMs - firstVideoPtsMs);
17380
+ const remainingWindowMs = Math.max(0, prepared.durMs - consumedMs);
17381
+ if (schedule.length === 0) return await this.sleepUntil(performance.now() + remainingWindowMs / rate, gen);
17382
+ const baselinePtsMs = resumeAfterPtsMs ?? schedule[0].ptsMs;
16864
17383
  const segmentStartWall = performance.now();
16865
17384
  let pushedVideo = false;
16866
17385
  for (const item of schedule) {
16867
- const targetWall = segmentStartWall + (item.ptsMs - firstPtsMs);
17386
+ const targetWall = segmentStartWall + (item.ptsMs - baselinePtsMs) / rate;
16868
17387
  if (await this.sleepUntil(targetWall, gen)) return true;
16869
- if (this.disposed || gen !== this.advanceToken || this.rate !== 1) return true;
17388
+ if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
16870
17389
  if (item.video) {
16871
17390
  this.deps.sink.pushAccessUnit(item.video.data, this.clock.stamp(item.video.ptsMs), item.video.codec);
16872
17391
  pushedVideo = true;
17392
+ this.lastFedVideoIdx = videoIdx++;
17393
+ this.cursorMs = prepared.startMs + (item.video.ptsMs - firstVideoPtsMs);
16873
17394
  } else if (item.audio) {
16874
17395
  const pushAudio = this.deps.sink.pushAudioPcmu;
16875
17396
  if (pushAudio) pushAudio.call(this.deps.sink, item.audio.data, this.audioClock.stamp(item.audio.ptsMs));
16876
17397
  }
16877
17398
  }
16878
17399
  if (pushedVideo) this.clockHasAdvanced = true;
16879
- const segmentEndWall = segmentStartWall + prepared.durMs;
17400
+ const segmentEndWall = segmentStartWall + remainingWindowMs / rate;
16880
17401
  return await this.sleepUntil(segmentEndWall, gen);
16881
17402
  }
16882
17403
  /**
@@ -16884,11 +17405,13 @@ var RecordedFeeder = class {
16884
17405
  * value). Re-derives the remaining interval each tick and caps each individual
16885
17406
  * timeout so a long inter-frame gap can't delay seek/dispose responsiveness —
16886
17407
  * the loop re-checks `gen === advanceToken` between ticks. Returns `true` if a
16887
- * dispose/seek/rate-change aborted the wait, so the caller stops pushing.
17408
+ * dispose/seek/pause aborted the wait, so the caller stops pushing. (A rate
17409
+ * CHANGE aborts via the gen guard — setRate re-enters through seek(), which
17410
+ * bumps advanceToken; rate 0 is the direct pause abort.)
16888
17411
  */
16889
17412
  async sleepUntil(targetWall, gen) {
16890
17413
  for (;;) {
16891
- if (this.disposed || gen !== this.advanceToken || this.rate !== 1) return true;
17414
+ if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
16892
17415
  const remaining = targetWall - performance.now();
16893
17416
  if (remaining <= 0) return false;
16894
17417
  const tick = Math.min(remaining, WAIT_TICK_MS);
@@ -16936,8 +17459,9 @@ var RecordedFeeder = class {
16936
17459
  /** Read + demux one segment's bytes into video (+ best-effort PCMU audio)
16937
17460
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
16938
17461
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
16939
- * never blocks video). */
16940
- async loadAus(startMs) {
17462
+ * never blocks video). `wantAudio: false` skips the audio transcode entirely
17463
+ * (paused scrubs would throw it away). */
17464
+ async loadAus(startMs, wantAudio = true) {
16941
17465
  const token = this.seekToken;
16942
17466
  const bytes = await this.withTimeout(this.deps.readBytes({
16943
17467
  deviceId: this.deps.deviceId,
@@ -16948,7 +17472,7 @@ var RecordedFeeder = class {
16948
17472
  const video = await this.withTimeout(this.deps.demux(bytes));
16949
17473
  if (this.disposed || token !== this.seekToken) return null;
16950
17474
  let audio = [];
16951
- if (this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
17475
+ if (wantAudio && this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
16952
17476
  try {
16953
17477
  audio = await this.withTimeout(this.deps.demuxAudio(bytes));
16954
17478
  } catch {
@@ -16998,59 +17522,6 @@ var RecordedFeeder = class {
16998
17522
  }
16999
17523
  };
17000
17524
  //#endregion
17001
- //#region src/stream-broker/recorded/control-protocol.ts
17002
- var ControlMessageSchema = discriminatedUnion("t", [
17003
- object({
17004
- t: literal("playRecorded"),
17005
- deviceId: number(),
17006
- epoch: number(),
17007
- profile: string()
17008
- }),
17009
- object({
17010
- t: literal("seek"),
17011
- epoch: number()
17012
- }),
17013
- object({
17014
- t: literal("scrubCommit"),
17015
- epoch: number()
17016
- }),
17017
- object({
17018
- t: literal("setRate"),
17019
- rate: union([literal(0), literal(1)])
17020
- }),
17021
- object({ t: literal("goLive") }),
17022
- object({
17023
- t: literal("setProfile"),
17024
- profile: string()
17025
- })
17026
- ]);
17027
- var ServerMessageSchema = discriminatedUnion("t", [object({
17028
- t: literal("state"),
17029
- state: _enum([
17030
- "live",
17031
- "loading",
17032
- "playing",
17033
- "paused",
17034
- "gap",
17035
- "ended",
17036
- "error"
17037
- ])
17038
- }), object({
17039
- t: literal("position"),
17040
- epochMs: number()
17041
- })]);
17042
- function parseControlMessage(raw) {
17043
- try {
17044
- const parsed = ControlMessageSchema.safeParse(JSON.parse(raw));
17045
- return parsed.success ? parsed.data : null;
17046
- } catch {
17047
- return null;
17048
- }
17049
- }
17050
- function serializeServerMessage(msg) {
17051
- return JSON.stringify(ServerMessageSchema.parse(msg));
17052
- }
17053
- //#endregion
17054
17525
  //#region src/stream-broker/recorded/timeline-session.ts
17055
17526
  /**
17056
17527
  * Per-viewer coordinator over one broker WebRTC session. Owns the playback mode
@@ -17083,6 +17554,9 @@ var TimelineSession = class {
17083
17554
  case "setRate":
17084
17555
  this.feeder?.setRate(msg.rate);
17085
17556
  break;
17557
+ case "stepFrame":
17558
+ if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
17559
+ break;
17086
17560
  case "setProfile":
17087
17561
  this.profile = msg.profile;
17088
17562
  if (this.mode === "recorded") this.enterRecorded();
@@ -19118,7 +19592,7 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
19118
19592
  const streamingDebug = this.resolveSessionDebug(input.deviceId);
19119
19593
  const transcodeProfile = this.resolveTranscodeProfile(input.deviceId, camStreamId);
19120
19594
  try {
19121
- return await this.webrtcServer.handleOffer(streamId, input.sdpOffer, void 0, {
19595
+ return await this.webrtcServer.handleOffer(streamId, input.sdpOffer, input.hints, {
19122
19596
  streamingDebug,
19123
19597
  sessionId: input.sessionId,
19124
19598
  relayOnly: input.relayOnly === true,