@camstack/addon-pipeline 1.1.58 → 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-Bn6Uq4vq.js → dist-B1u-QN05.js} +176 -12
  6. package/dist/{dist-CnwwDzX3.mjs → dist-DKeNH_5z.mjs} +176 -12
  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 +123 -4
  12. package/dist/pipeline-runner/index.mjs +122 -3
  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 +14 -1
  16. package/dist/session-decode/decode-worker-child.mjs +14 -1
  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-D7NOrnzz.js → step-definitions-CPch37vh.js} +1 -1
  20. package/dist/{step-definitions-DhEe57Wn.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-B8FQ1e0a.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-D1Vdyu-4.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
  26. package/dist/stream-broker/index.js +622 -149
  27. package/dist/stream-broker/index.mjs +622 -149
  28. package/dist/stream-broker/remoteEntry.js +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CG5EE0W4.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BFeLUNOl.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-pQQunq1F.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-BxqQ931j.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-Bn6Uq4vq.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
  }
@@ -16670,6 +16844,73 @@ var AdaptiveController = class {
16670
16844
  }
16671
16845
  };
16672
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
16673
16914
  //#region src/stream-broker/recorded/feed-clock.ts
16674
16915
  /**
16675
16916
  * Maps arbitrary, possibly-resetting source PTS (ms) onto a strictly increasing
@@ -16677,26 +16918,70 @@ var AdaptiveController = class {
16677
16918
  * seek the output keeps advancing while the source anchor re-bases, so
16678
16919
  * per-segment PTS resets never rewind the track. Video and audio use SEPARATE
16679
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).
16680
16927
  */
16681
16928
  var MonotonicClock = class {
16682
16929
  rateHz;
16683
16930
  srcAnchorMs = null;
16931
+ lastSrcMs = null;
16684
16932
  outBaseMs = 0;
16685
16933
  lastOutMs = 0;
16934
+ rate = 1;
16686
16935
  constructor(rateHz = 9e4) {
16687
16936
  this.rateHz = rateHz;
16688
16937
  }
16689
16938
  stamp(srcPtsMs) {
16690
16939
  if (this.srcAnchorMs === null) this.srcAnchorMs = srcPtsMs;
16691
- const elapsed = Math.max(0, srcPtsMs - this.srcAnchorMs);
16940
+ this.lastSrcMs = srcPtsMs;
16941
+ const elapsed = Math.max(0, (srcPtsMs - this.srcAnchorMs) / this.rate);
16692
16942
  const outMs = this.outBaseMs + elapsed;
16693
16943
  this.lastOutMs = Math.max(this.lastOutMs, outMs);
16694
16944
  return Math.round(this.lastOutMs * this.rateHz / 1e3);
16695
16945
  }
16696
- /** 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
+ */
16697
16954
  markSeek() {
16698
- 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;
16699
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;
16700
16985
  }
16701
16986
  };
16702
16987
  //#endregion
@@ -16710,23 +16995,54 @@ var POSITION_INTERVAL_MS = 1e3;
16710
16995
  * keeping seeks responsive without per-frame drift.
16711
16996
  */
16712
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;
16713
17011
  var RecordedFeeder = class {
16714
17012
  deps;
16715
17013
  clock = new MonotonicClock();
16716
17014
  /** Separate 8 kHz clock for PCMU audio; re-anchored together with video. */
16717
17015
  audioClock = new MonotonicClock(8e3);
16718
17016
  clockHasAdvanced = false;
17017
+ /** Playback rate: 0 (paused) or a continuous factor in [0.25, 4]. */
16719
17018
  rate = 1;
16720
17019
  disposed = false;
16721
17020
  seekToken = 0;
16722
17021
  advanceToken = 0;
16723
17022
  cursorMs = 0;
16724
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;
16725
17041
  constructor(deps) {
16726
17042
  this.deps = deps;
16727
17043
  }
16728
17044
  /**
16729
- * 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
16730
17046
  * the FIRST located segment is fed; the forward `advance` loop is started
16731
17047
  * fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
16732
17048
  * seek bumps the token and silently abandons this one.
@@ -16734,11 +17050,12 @@ var RecordedFeeder = class {
16734
17050
  async seek(epochMs) {
16735
17051
  const token = ++this.seekToken;
16736
17052
  this.advanceToken++;
17053
+ this.inFlightSeekEpochMs = epochMs;
16737
17054
  try {
16738
17055
  this.deps.onState("loading");
16739
17056
  if (this.clockHasAdvanced) {
16740
- this.clock.markSeek();
16741
- this.audioClock.markSeek();
17057
+ this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
17058
+ this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
16742
17059
  }
16743
17060
  const loc = await this.withTimeout(this.deps.locate({
16744
17061
  deviceId: this.deps.deviceId,
@@ -16747,93 +17064,274 @@ var RecordedFeeder = class {
16747
17064
  }));
16748
17065
  if (this.disposed || token !== this.seekToken) return;
16749
17066
  if (loc.kind === "gap") {
17067
+ this.currentSegment = null;
17068
+ this.lastFedVideoIdx = -1;
16750
17069
  this.stopPositionReports();
16751
17070
  this.deps.onState("gap");
16752
17071
  return;
16753
17072
  }
16754
17073
  this.cursorMs = loc.startMs;
16755
- await this.feedSegment(loc.startMs, token);
16756
- if (this.disposed || token !== this.seekToken) return;
16757
- this.cursorMs = loc.startMs + loc.durMs;
16758
- 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);
16759
17085
  this.startPositionReports();
16760
- 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
+ });
16761
17096
  } catch {
16762
17097
  if (!this.disposed && token === this.seekToken) {
16763
17098
  this.stopPositionReports();
16764
17099
  this.deps.onState("error");
16765
17100
  }
17101
+ } finally {
17102
+ if (token === this.seekToken) this.inFlightSeekEpochMs = null;
16766
17103
  }
16767
17104
  }
16768
- /** 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
+ */
16769
17116
  setRate(rate) {
16770
17117
  if (this.disposed) return;
16771
- if (rate === this.rate) return;
16772
- this.rate = rate;
16773
- if (rate === 0) this.stopPositionReports();
16774
- this.deps.onState(rate === 0 ? "paused" : "playing");
16775
- 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
+ });
16776
17146
  }
16777
- async feedSegment(startMs, token) {
16778
- 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({
16779
17179
  deviceId: this.deps.deviceId,
16780
17180
  profile: this.deps.profile,
16781
- startMs
17181
+ epochMs: seg.startMs - 1
16782
17182
  }));
16783
- if (this.disposed || token !== this.seekToken) return;
16784
- const aus = await this.withTimeout(this.deps.demux(bytes));
16785
- if (this.disposed || token !== this.seekToken) return;
16786
- for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
16787
- if (aus.length > 0) this.clockHasAdvanced = true;
16788
- await this.feedAudio(bytes, token);
16789
- }
16790
- /**
16791
- * Demux+transcode the segment's audio to PCMU and push it, re-stamped on the
16792
- * 8 kHz audio clock. Best-effort: audio NEVER blocks video a demux/transcode
16793
- * failure or a video-only sink simply yields silence.
16794
- */
16795
- async feedAudio(bytes, token) {
16796
- const demuxAudio = this.deps.demuxAudio;
16797
- const pushAudio = this.deps.sink.pushAudioPcmu;
16798
- if (!demuxAudio || !pushAudio) return;
16799
- let audio;
16800
- try {
16801
- audio = await this.withTimeout(demuxAudio(bytes));
16802
- } 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;
16803
17274
  return;
16804
17275
  }
16805
- if (this.disposed || token !== this.seekToken) return;
16806
- 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;
16807
17290
  }
16808
17291
  /**
16809
17292
  * Start (or restart) the single forward advance loop. Bumping `advanceToken`
16810
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.
16811
17297
  */
16812
- startAdvance() {
17298
+ startAdvance(initial) {
16813
17299
  const gen = ++this.advanceToken;
16814
- this.advanceLoop(gen).catch(() => {
17300
+ this.advanceLoop(gen, initial).catch(() => {
16815
17301
  if (!this.disposed && gen === this.advanceToken) {
16816
17302
  this.stopPositionReports();
16817
17303
  this.deps.onState("error");
16818
17304
  }
16819
17305
  });
16820
17306
  }
16821
- async advanceLoop(gen) {
16822
- let prepared = await this.prepareNext(this.cursorMs, gen);
16823
- 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) {
16824
17317
  if (prepared === null) return;
16825
17318
  if (prepared === "ended") {
16826
17319
  this.stopPositionReports();
16827
17320
  this.deps.onState("ended");
16828
17321
  return;
16829
17322
  }
16830
- this.cursorMs = prepared.startMs;
16831
- if (this.clockHasAdvanced) {
16832
- this.clock.markSeek();
16833
- 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
+ }
16834
17329
  }
16835
17330
  const prefetch = this.prepareNext(prepared.startMs + prepared.durMs, gen);
16836
- if (await this.paceSegment(prepared, gen)) {
17331
+ const aborted = await this.paceSegment(prepared, gen, resumeAfterPtsMs);
17332
+ resumeAfterPtsMs = null;
17333
+ isResumedSegment = false;
17334
+ if (aborted) {
16837
17335
  prefetch.catch(() => {});
16838
17336
  return;
16839
17337
  }
@@ -16844,45 +17342,67 @@ var RecordedFeeder = class {
16844
17342
  /**
16845
17343
  * Drip one prepared segment to the sink at its real frame cadence: build a
16846
17344
  * single PTS-ordered schedule of the segment's video + audio access units and
16847
- * push each at `segmentStart + (pts - firstPts)` wall-clock time, sleeping
16848
- * between items with a drift-free absolute-time wait. RTP timestamps come from
16849
- * the SAME `clock.stamp` / `audioClock.stamp` calls as before — only delivery
16850
- * timing changes. Returns `true` if a seek/dispose/rate-change aborted pacing
16851
- * mid-segment (the caller must stop without advancing the cursor).
16852
- */
16853
- async paceSegment(prepared, gen) {
16854
- 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;
16855
17357
  const schedule = [];
16856
- for (const au of prepared.aus) schedule.push({
17358
+ for (const au of prepared.aus) if (resumeAfterPtsMs === null || au.ptsMs > resumeAfterPtsMs) schedule.push({
16857
17359
  ptsMs: au.ptsMs,
16858
17360
  video: au
16859
17361
  });
16860
- if (hasAudioSink) for (const au of prepared.audio) schedule.push({
16861
- ptsMs: au.ptsMs,
16862
- audio: au
16863
- });
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
+ }
16864
17368
  schedule.sort((a, b) => {
16865
17369
  if (a.ptsMs !== b.ptsMs) return a.ptsMs - b.ptsMs;
16866
17370
  return (a.video ? 0 : 1) - (b.video ? 0 : 1);
16867
17371
  });
16868
- if (schedule.length === 0) return await this.sleepUntil(performance.now() + prepared.durMs, gen);
16869
- 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;
16870
17388
  const segmentStartWall = performance.now();
16871
17389
  let pushedVideo = false;
16872
17390
  for (const item of schedule) {
16873
- const targetWall = segmentStartWall + (item.ptsMs - firstPtsMs);
17391
+ const targetWall = segmentStartWall + (item.ptsMs - baselinePtsMs) / rate;
16874
17392
  if (await this.sleepUntil(targetWall, gen)) return true;
16875
- if (this.disposed || gen !== this.advanceToken || this.rate !== 1) return true;
17393
+ if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
16876
17394
  if (item.video) {
16877
17395
  this.deps.sink.pushAccessUnit(item.video.data, this.clock.stamp(item.video.ptsMs), item.video.codec);
16878
17396
  pushedVideo = true;
17397
+ this.lastFedVideoIdx = videoIdx++;
17398
+ this.cursorMs = prepared.startMs + (item.video.ptsMs - firstVideoPtsMs);
16879
17399
  } else if (item.audio) {
16880
17400
  const pushAudio = this.deps.sink.pushAudioPcmu;
16881
17401
  if (pushAudio) pushAudio.call(this.deps.sink, item.audio.data, this.audioClock.stamp(item.audio.ptsMs));
16882
17402
  }
16883
17403
  }
16884
17404
  if (pushedVideo) this.clockHasAdvanced = true;
16885
- const segmentEndWall = segmentStartWall + prepared.durMs;
17405
+ const segmentEndWall = segmentStartWall + remainingWindowMs / rate;
16886
17406
  return await this.sleepUntil(segmentEndWall, gen);
16887
17407
  }
16888
17408
  /**
@@ -16890,11 +17410,13 @@ var RecordedFeeder = class {
16890
17410
  * value). Re-derives the remaining interval each tick and caps each individual
16891
17411
  * timeout so a long inter-frame gap can't delay seek/dispose responsiveness —
16892
17412
  * the loop re-checks `gen === advanceToken` between ticks. Returns `true` if a
16893
- * 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.)
16894
17416
  */
16895
17417
  async sleepUntil(targetWall, gen) {
16896
17418
  for (;;) {
16897
- if (this.disposed || gen !== this.advanceToken || this.rate !== 1) return true;
17419
+ if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
16898
17420
  const remaining = targetWall - performance.now();
16899
17421
  if (remaining <= 0) return false;
16900
17422
  const tick = Math.min(remaining, WAIT_TICK_MS);
@@ -16942,8 +17464,9 @@ var RecordedFeeder = class {
16942
17464
  /** Read + demux one segment's bytes into video (+ best-effort PCMU audio)
16943
17465
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
16944
17466
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
16945
- * never blocks video). */
16946
- 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) {
16947
17470
  const token = this.seekToken;
16948
17471
  const bytes = await this.withTimeout(this.deps.readBytes({
16949
17472
  deviceId: this.deps.deviceId,
@@ -16954,7 +17477,7 @@ var RecordedFeeder = class {
16954
17477
  const video = await this.withTimeout(this.deps.demux(bytes));
16955
17478
  if (this.disposed || token !== this.seekToken) return null;
16956
17479
  let audio = [];
16957
- if (this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
17480
+ if (wantAudio && this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
16958
17481
  try {
16959
17482
  audio = await this.withTimeout(this.deps.demuxAudio(bytes));
16960
17483
  } catch {
@@ -17004,59 +17527,6 @@ var RecordedFeeder = class {
17004
17527
  }
17005
17528
  };
17006
17529
  //#endregion
17007
- //#region src/stream-broker/recorded/control-protocol.ts
17008
- var ControlMessageSchema = require_dist.discriminatedUnion("t", [
17009
- require_dist.object({
17010
- t: require_dist.literal("playRecorded"),
17011
- deviceId: require_dist.number(),
17012
- epoch: require_dist.number(),
17013
- profile: require_dist.string()
17014
- }),
17015
- require_dist.object({
17016
- t: require_dist.literal("seek"),
17017
- epoch: require_dist.number()
17018
- }),
17019
- require_dist.object({
17020
- t: require_dist.literal("scrubCommit"),
17021
- epoch: require_dist.number()
17022
- }),
17023
- require_dist.object({
17024
- t: require_dist.literal("setRate"),
17025
- rate: require_dist.union([require_dist.literal(0), require_dist.literal(1)])
17026
- }),
17027
- require_dist.object({ t: require_dist.literal("goLive") }),
17028
- require_dist.object({
17029
- t: require_dist.literal("setProfile"),
17030
- profile: require_dist.string()
17031
- })
17032
- ]);
17033
- var ServerMessageSchema = require_dist.discriminatedUnion("t", [require_dist.object({
17034
- t: require_dist.literal("state"),
17035
- state: require_dist._enum([
17036
- "live",
17037
- "loading",
17038
- "playing",
17039
- "paused",
17040
- "gap",
17041
- "ended",
17042
- "error"
17043
- ])
17044
- }), require_dist.object({
17045
- t: require_dist.literal("position"),
17046
- epochMs: require_dist.number()
17047
- })]);
17048
- function parseControlMessage(raw) {
17049
- try {
17050
- const parsed = ControlMessageSchema.safeParse(JSON.parse(raw));
17051
- return parsed.success ? parsed.data : null;
17052
- } catch {
17053
- return null;
17054
- }
17055
- }
17056
- function serializeServerMessage(msg) {
17057
- return JSON.stringify(ServerMessageSchema.parse(msg));
17058
- }
17059
- //#endregion
17060
17530
  //#region src/stream-broker/recorded/timeline-session.ts
17061
17531
  /**
17062
17532
  * Per-viewer coordinator over one broker WebRTC session. Owns the playback mode
@@ -17089,6 +17559,9 @@ var TimelineSession = class {
17089
17559
  case "setRate":
17090
17560
  this.feeder?.setRate(msg.rate);
17091
17561
  break;
17562
+ case "stepFrame":
17563
+ if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
17564
+ break;
17092
17565
  case "setProfile":
17093
17566
  this.profile = msg.profile;
17094
17567
  if (this.mode === "recorded") this.enterRecorded();