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