@camstack/addon-pipeline 1.2.284 → 1.2.286

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 (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-XmUJCdXn.js → default-detection-model-B9sgmjNE.js} +1 -1
  4. package/dist/{default-detection-model-DuK-Xu10.mjs → default-detection-model-BbaeqJWd.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-CWJ_t5jq.mjs → dist-L6fspiMs.mjs} +1479 -3
  8. package/dist/{dist-BS_GvN2R.js → dist-bAVvh6zw.js} +1496 -2
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-CqOIo6CL.js → node-BAHXRXps.js} +1 -1
  12. package/dist/{node-Cz6ByejK.mjs → node-BKSzm60G.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-BFycx9BF.js → process-memory-B5Enn1QL.js} +1 -1
  16. package/dist/{process-memory-DAwCuIeI.mjs → process-memory-gIGkgoj7.mjs} +1 -1
  17. package/dist/recorder/index.js +2 -2
  18. package/dist/recorder/index.mjs +2 -2
  19. package/dist/{segment-demux-js-r4t4zgdr.js → segment-demux-js-BCxiqxMJ.js} +166 -148
  20. package/dist/{segment-demux-js-gLuKs5Mg.mjs → segment-demux-js-DIMPKsLl.mjs} +149 -149
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CROj7Fwq.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BQppCOBh.mjs} +2 -2
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BxqJbWC8.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-DWcIgV47.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-DvNcV7kj.mjs → hostInit-BMoj1Wh2.mjs} +2 -2
  28. package/dist/stream-broker/index.js +2193 -51
  29. package/dist/stream-broker/index.mjs +2193 -51
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DByJbFCy.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BnLVuoDg.mjs +0 -26
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $t as CAM_PROFILE_ORDER, B as cameraStreamsCapability, Bt as streamBrokerCapability, Cn as number, D as RATE_CONTROL_RELAXED, Dn as string, Dt as rankStreams, En as record, F as addonWidgetsSourceCapability, G as declareLogChannel, Gt as errMsg, Jt as buildFfmpegArgs$1, Kt as AUDIO_PRESETS, M as RingBuffer, Nt as resolveEgressDecodeHwAccel, O as RATE_CONTROL_TIGHT, On as union, Q as egressTransportFromRequest, Qt as BaseAddon, Sn as literal, U as createHwAccelCache, W as createLogChannelsProvider, Wt as webrtcSessionCapability, Xt as isSoftwareDecode, Yt as invocationFromEncodeProfile, Z as egressTranscodeSharingKey, Zt as BOOT_RECOVERY_BACKOFF_MS, _ as EncodeProfileSchema, _n as _enum, b as G711_SCALE_CORRECTION_DB, bn as discriminatedUnion, dn as makeSourceBrokerId, dt as logChannelsCapability, en as CamProfileSchema, fn as nodePin, i as BatteryStatusSchema, in as createEvent, kn as EventCategory, ln as isEvent, mn as parseProfileBrokerId, nn as DeviceType, pt as maskUrlCredentials, q as deriveBatteryPresence, sn as expandAudioChunkToF32le, tn as DeviceFeature, un as makeProfileBrokerId, ut as loadContributionCapability, vn as array, wn as object, yn as boolean, z as batteryCapability } from "../dist-CWJ_t5jq.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Cz6ByejK.mjs";
2
+ import { $t as isSoftwareDecode, A as RATE_CONTROL_RELAXED, An as string, At as rankStreams, C as G711_SCALE_CORRECTION_DB, Cn as discriminatedUnion, Dn as object, En as number, F as RingBuffer, G as clipExportFailureOf, H as batteryCapability, It as resolveEgressDecodeHwAccel, J as declareLogChannel, Jt as errMsg, K as createHwAccelCache, Mn as EventCategory, Qt as invocationFromEncodeProfile, R as addonWidgetsSourceCapability, Sn as boolean, Tn as literal, U as cameraStreamsCapability, Ut as streamBrokerCapability, X as deriveBatteryPresence, Yt as AUDIO_PRESETS, Zt as buildFfmpegArgs$1, _n as parseProfileBrokerId, an as DeviceType, b as EncodeProfileSchema, bn as _enum, en as BOOT_RECOVERY_BACKOFF_MS, et as egressTranscodeSharingKey, fn as isEvent, gt as maskUrlCredentials, hn as nodePin, i as BatteryStatusSchema, in as DeviceFeature, j as RATE_CONTROL_TIGHT, jn as union, kn as record, mn as makeSourceBrokerId, mt as logChannelsCapability, nn as CAM_PROFILE_ORDER, pn as makeProfileBrokerId, pt as loadContributionCapability, q as createLogChannelsProvider, qt as webrtcSessionCapability, rn as CamProfileSchema, sn as createEvent, tn as BaseAddon, tt as egressTransportFromRequest, un as expandAudioChunkToF32le, xn as array } from "../dist-L6fspiMs.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BKSzm60G.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-gLuKs5Mg.mjs";
7
+ import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-DIMPKsLl.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -24,6 +24,7 @@ import { createSocket } from "node:dgram";
24
24
  import { once } from "events";
25
25
  import { Socket } from "net";
26
26
  import { lookup } from "node:dns/promises";
27
+ import { request } from "node:http";
27
28
  import { Worker } from "node:worker_threads";
28
29
  import { EventEmitter } from "node:events";
29
30
  //#region src/stream-broker/client-log-ingest.ts
@@ -26686,6 +26687,29 @@ var ServerMessageSchema = discriminatedUnion("t", [
26686
26687
  t: literal("clipDuration"),
26687
26688
  clipId: string().min(1),
26688
26689
  durationMs: number().positive()
26690
+ }),
26691
+ object({
26692
+ t: literal("clipTransport"),
26693
+ clipId: string().min(1),
26694
+ /** `forward` = only ahead of the playhead. `free` = anywhere. */
26695
+ seek: _enum(["forward", "free"]),
26696
+ /** Frame-step BACKWARD is meaningful. Forward always is. */
26697
+ stepBack: boolean()
26698
+ }),
26699
+ object({
26700
+ t: literal("clipAudio"),
26701
+ clipId: string().min(1),
26702
+ carried: boolean(),
26703
+ codec: _enum(["opus", "pcmu"]).optional(),
26704
+ /** Why there is no sound. Present iff `carried` is false. */
26705
+ reason: _enum([
26706
+ "not-negotiated",
26707
+ "not-offered",
26708
+ "no-audio-track",
26709
+ "unsupported-codec",
26710
+ "codec-mismatch",
26711
+ "stream-failed"
26712
+ ]).optional()
26689
26713
  })
26690
26714
  ]);
26691
26715
  function parseControlMessage(raw) {
@@ -26702,11 +26726,49 @@ function serializeServerMessage(msg) {
26702
26726
  //#endregion
26703
26727
  //#region src/stream-broker/recorded/feed-clock.ts
26704
26728
  /**
26729
+ * The ORIGIN two tracks are measured from — the base and the source anchor,
26730
+ * shared, so the offset between video and audio survives a discontinuity.
26731
+ *
26732
+ * ## Why this exists
26733
+ *
26734
+ * `markDiscontinuity` re-bases on the last output. With one instance per
26735
+ * track, that last output is the TRACK'S own, and the two differ by however
26736
+ * far apart the interleave left them — so every seek replaced the real A/V
26737
+ * offset with that gap, in the same direction each time. Measured on the
26738
+ * clock alone it grows by a fixed step each time and never comes back; how
26739
+ * big that step is, is the interleave's business — 10–23 ms a seek on a
26740
+ * synthetic fixture, **900 ms over three seeks on a real clip**.
26741
+ *
26742
+ * Calling `markSeek()` on both instances "together" did not help and could
26743
+ * not: it is not the CALL that has to be shared, it is the ORIGIN. With one,
26744
+ * `out_video − out_audio` is `(vPts − aPts) / rate` — exactly the source
26745
+ * offset, which is the entire content of lip sync — and there is nothing left
26746
+ * to keep in step.
26747
+ *
26748
+ * `lastOutMs` stays per-track: monotonicity is a promise to ONE receiver
26749
+ * track, and clamping video against audio's last stamp would move video.
26750
+ */
26751
+ var FeedOrigin = class {
26752
+ /** Output ms the current run is measured from. */
26753
+ outBaseMs = 0;
26754
+ /** Source ms the current run is measured from; `null` until the next stamp. */
26755
+ srcAnchorMs = null;
26756
+ /**
26757
+ * The furthest EITHER track has been stamped. What a discontinuity clears,
26758
+ * so the gap is opened past both — and so two clocks marking the same
26759
+ * discontinuity in a row produce one gap, not two.
26760
+ */
26761
+ highWaterMs = 0;
26762
+ };
26763
+ /**
26705
26764
  * Maps arbitrary, possibly-resetting source PTS (ms) onto a strictly increasing
26706
26765
  * output clock at `rateHz` (default 90 kHz video; pass 8000 for PCMU audio). On
26707
26766
  * seek the output keeps advancing while the source anchor re-bases, so
26708
- * per-segment PTS resets never rewind the track. Video and audio use SEPARATE
26709
- * instances that `markSeek()` together to stay A/V aligned across boundaries.
26767
+ * per-segment PTS resets never rewind the track.
26768
+ *
26769
+ * Video and audio are separate instances SHARING one {@link FeedOrigin} — see
26770
+ * there for what happens when they do not. An instance constructed without one
26771
+ * gets its own, so a lone clock behaves exactly as it always did.
26710
26772
  *
26711
26773
  * Playback rate: `setRate(r)` divides SOURCE elapsed time by `r` on the output
26712
26774
  * timeline (2× → half the output spacing → the receiver plays frames twice as
@@ -26716,20 +26778,21 @@ function serializeServerMessage(msg) {
26716
26778
  */
26717
26779
  var MonotonicClock = class {
26718
26780
  rateHz;
26719
- srcAnchorMs = null;
26720
26781
  lastSrcMs = null;
26721
- outBaseMs = 0;
26722
26782
  lastOutMs = 0;
26723
26783
  rate = 1;
26724
- constructor(rateHz = 9e4) {
26784
+ origin;
26785
+ constructor(rateHz = 9e4, origin) {
26725
26786
  this.rateHz = rateHz;
26787
+ this.origin = origin ?? new FeedOrigin();
26726
26788
  }
26727
26789
  stamp(srcPtsMs) {
26728
- if (this.srcAnchorMs === null) this.srcAnchorMs = srcPtsMs;
26790
+ if (this.origin.srcAnchorMs === null) this.origin.srcAnchorMs = srcPtsMs;
26729
26791
  this.lastSrcMs = srcPtsMs;
26730
- const elapsed = Math.max(0, (srcPtsMs - this.srcAnchorMs) / this.rate);
26731
- const outMs = this.outBaseMs + elapsed;
26792
+ const elapsed = Math.max(0, (srcPtsMs - this.origin.srcAnchorMs) / this.rate);
26793
+ const outMs = this.origin.outBaseMs + elapsed;
26732
26794
  this.lastOutMs = Math.max(this.lastOutMs, outMs);
26795
+ this.origin.highWaterMs = Math.max(this.origin.highWaterMs, this.lastOutMs);
26733
26796
  return Math.round(this.lastOutMs * this.rateHz / 1e3);
26734
26797
  }
26735
26798
  /**
@@ -26753,8 +26816,8 @@ var MonotonicClock = class {
26753
26816
  * rate-scaled.
26754
26817
  */
26755
26818
  markDiscontinuity(gapMs) {
26756
- this.outBaseMs = this.lastOutMs + gapMs;
26757
- this.srcAnchorMs = null;
26819
+ this.origin.outBaseMs = this.origin.highWaterMs + gapMs;
26820
+ this.origin.srcAnchorMs = null;
26758
26821
  this.lastSrcMs = null;
26759
26822
  }
26760
26823
  /**
@@ -26766,9 +26829,9 @@ var MonotonicClock = class {
26766
26829
  */
26767
26830
  setRate(rate) {
26768
26831
  if (!(rate > 0) || rate === this.rate) return;
26769
- if (this.srcAnchorMs !== null && this.lastSrcMs !== null) {
26770
- this.outBaseMs = this.lastOutMs;
26771
- this.srcAnchorMs = this.lastSrcMs;
26832
+ if (this.origin.srcAnchorMs !== null && this.lastSrcMs !== null) {
26833
+ this.origin.outBaseMs = this.lastOutMs;
26834
+ this.origin.srcAnchorMs = this.lastSrcMs;
26772
26835
  }
26773
26836
  this.rate = rate;
26774
26837
  }
@@ -27263,7 +27326,7 @@ var SEGMENT_TIMEOUT_MS = 8e3;
27263
27326
  * 2 ms, p95 202 ms, max 285 ms), so a segment that CAN yield audio still does.
27264
27327
  */
27265
27328
  var AUDIO_DEMUX_TIMEOUT_MS = 1500;
27266
- var POSITION_INTERVAL_MS = 1e3;
27329
+ var POSITION_INTERVAL_MS$1 = 1e3;
27267
27330
  /** How often `recorded:play-census` fires while the advance loop is alive. */
27268
27331
  var PLAY_CENSUS_INTERVAL_TICKS = 5;
27269
27332
  /**
@@ -27285,7 +27348,7 @@ var PTS_REPAIR_LOG_MEMORY = 64;
27285
27348
  * PTS gap (or an empty/tail wait) still re-checks the abort guards promptly,
27286
27349
  * keeping seeks responsive without per-frame drift.
27287
27350
  */
27288
- var WAIT_TICK_MS = 250;
27351
+ var WAIT_TICK_MS$1 = 250;
27289
27352
  /**
27290
27353
  * Output-clock gap stamped at every seek/scrub commit (markDiscontinuity on
27291
27354
  * both A/V clocks). Root cause of the "smeared frame on scrub": the aborted
@@ -27298,7 +27361,7 @@ var WAIT_TICK_MS = 250;
27298
27361
  * can't look contiguous with the new GOP, and the receiver resets timing
27299
27362
  * instead of merging the two feeds.
27300
27363
  */
27301
- var SEEK_DISCONTINUITY_MS = 250;
27364
+ var SEEK_DISCONTINUITY_MS$1 = 250;
27302
27365
  /**
27303
27366
  * Coalescing window for feeder-scrub `scrubTo` (experimental). Pointer-move
27304
27367
  * targets arrive far faster than a segment demux completes; the feeder collapses
@@ -27607,7 +27670,14 @@ function emptyPlayCensus() {
27607
27670
  }
27608
27671
  var RecordedFeeder = class {
27609
27672
  deps;
27610
- clock = new MonotonicClock();
27673
+ /**
27674
+ * ONE origin for both tracks. Two independent ones replaced the real A/V
27675
+ * offset with the interleave gap at every seek, in the same direction each
27676
+ * time — measured at 23 ms per seek on the clock alone and 900 ms over
27677
+ * three seeks on a real clip. See `FeedOrigin`.
27678
+ */
27679
+ feedOrigin = new FeedOrigin();
27680
+ clock = new MonotonicClock(9e4, this.feedOrigin);
27611
27681
  /** Audio clock (PCMU 8 kHz / Opus 48 kHz); re-anchored together with video. */
27612
27682
  audioClock;
27613
27683
  clockHasAdvanced = false;
@@ -27790,7 +27860,7 @@ var RecordedFeeder = class {
27790
27860
  lastAnnouncedState = null;
27791
27861
  constructor(deps) {
27792
27862
  this.deps = deps;
27793
- this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3);
27863
+ this.audioClock = new MonotonicClock(deps.audioClockRateHz ?? 8e3, this.feedOrigin);
27794
27864
  this.scrubCache = deps.fragmentCache ?? new ScrubFragmentCache();
27795
27865
  }
27796
27866
  /**
@@ -27884,8 +27954,8 @@ var RecordedFeeder = class {
27884
27954
  try {
27885
27955
  this.emitState("loading", "seek-start", token, dragId);
27886
27956
  if (this.clockHasAdvanced) {
27887
- this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
27888
- this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
27957
+ this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
27958
+ this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
27889
27959
  }
27890
27960
  const locateStartedAt = performance.now();
27891
27961
  let loc = await this.withTimeout(this.deps.locate({
@@ -28760,12 +28830,12 @@ var RecordedFeeder = class {
28760
28830
  return;
28761
28831
  }
28762
28832
  for (const s of jumpTravel) {
28763
- if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
28833
+ if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
28764
28834
  this.deps.sink.pushAccessUnit(s.au.data, this.clock.stamp(s.au.ptsMs), s.au.codec);
28765
28835
  this.clockHasAdvanced = true;
28766
28836
  this.deps.onPosition(this.clampIntoSegment(s.seg, s.tMs));
28767
28837
  }
28768
- if ((mode === "jump" || rewind) && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
28838
+ if ((mode === "jump" || rewind) && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
28769
28839
  else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
28770
28840
  for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
28771
28841
  const pushedAtMs = performance.now();
@@ -29189,7 +29259,7 @@ var RecordedFeeder = class {
29189
29259
  if (still === "deadline") break;
29190
29260
  if (still !== null && still.seg.startMs !== lastFragStartMs && !fragmentCovers(still.seg, toMs) && !fragmentCovers(still.seg, fromMs)) {
29191
29261
  lastFragStartMs = still.seg.startMs;
29192
- if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
29262
+ if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
29193
29263
  this.deps.sink.pushAccessUnit(still.au.data, this.clock.stamp(still.au.ptsMs), still.au.codec);
29194
29264
  this.clockHasAdvanced = true;
29195
29265
  this.deps.onPosition(this.clampIntoSegment(still.seg, tMs));
@@ -29636,7 +29706,7 @@ var RecordedFeeder = class {
29636
29706
  if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
29637
29707
  const remaining = targetWall - performance.now();
29638
29708
  if (remaining <= 0) return false;
29639
- const tick = Math.min(remaining, WAIT_TICK_MS);
29709
+ const tick = Math.min(remaining, WAIT_TICK_MS$1);
29640
29710
  await new Promise((resolve) => setTimeout(resolve, tick));
29641
29711
  }
29642
29712
  }
@@ -29761,7 +29831,7 @@ var RecordedFeeder = class {
29761
29831
  if (this.rate <= 0) return;
29762
29832
  this.positionTicks += 1;
29763
29833
  if (this.positionTicks % PLAY_CENSUS_INTERVAL_TICKS === 0) this.emitPlayCensus("tick");
29764
- }, POSITION_INTERVAL_MS);
29834
+ }, POSITION_INTERVAL_MS$1);
29765
29835
  }
29766
29836
  /** Tear down the ~1 Hz position interval so it stops reporting a stale cursor. */
29767
29837
  stopPositionReports() {
@@ -30057,7 +30127,97 @@ var TimelineSession = class {
30057
30127
  this.lastPositionMs = null;
30058
30128
  const feeder = this.enterRecorded();
30059
30129
  await feeder.seek(offsetMs);
30060
- await this.reportClipDuration(clipId, feeder);
30130
+ this.reportClipTransport(clipId, feeder);
30131
+ await Promise.all([this.reportClipDuration(clipId, feeder), this.reportClipAudio(clipId, feeder)]);
30132
+ }
30133
+ /**
30134
+ * Tell the viewer what this clip can be driven to do.
30135
+ *
30136
+ * A clip served from the provider's stream is forward-only; one fetched as
30137
+ * a file is not. Without the answer the viewer draws the same progress bar
30138
+ * for both, and on the stream a backward drag is refused and the position
30139
+ * re-sent — a control that visibly undoes the operator's gesture, which
30140
+ * reads as broken rather than as unavailable (D62).
30141
+ *
30142
+ * A feeder that does not say sends nothing: silence is "the freedom you
30143
+ * always had", and claiming `free` for a feeder that never answered would
30144
+ * be this surface inventing a capability.
30145
+ */
30146
+ reportClipTransport(clipId, feeder) {
30147
+ const report = feeder.transport;
30148
+ if (report === void 0) {
30149
+ this.deps.onLog?.("clip playback: transport not reported — this feeder cannot say", {
30150
+ deviceId: this.deps.deviceId,
30151
+ clipId
30152
+ });
30153
+ return;
30154
+ }
30155
+ if (this.clip?.clipId !== clipId) return;
30156
+ this.deps.onLog?.("clip playback: transport reported", {
30157
+ deviceId: this.deps.deviceId,
30158
+ clipId,
30159
+ seek: report.seek,
30160
+ stepBack: report.stepBack
30161
+ });
30162
+ this.deps.session.sendControl(serializeServerMessage({
30163
+ t: "clipTransport",
30164
+ clipId,
30165
+ seek: report.seek,
30166
+ stepBack: report.stepBack
30167
+ }));
30168
+ }
30169
+ /**
30170
+ * Tell the viewer whether this clip has sound.
30171
+ *
30172
+ * Silence has three causes the operator cannot tell apart by listening —
30173
+ * a camera with no microphone, a transport that carried no audio, and a
30174
+ * session that negotiated none — so the viewer is told which. Every branch
30175
+ * that sends nothing says so (D391): a feeder that cannot answer, an answer
30176
+ * that rejected, and an answer that landed after the pointer had left the
30177
+ * clip, which must not be pinned on whatever came next.
30178
+ */
30179
+ async reportClipAudio(clipId, feeder) {
30180
+ if (feeder.audio === void 0) {
30181
+ this.deps.onLog?.("clip playback: audio not reported — this feeder cannot say", {
30182
+ deviceId: this.deps.deviceId,
30183
+ clipId
30184
+ });
30185
+ return;
30186
+ }
30187
+ let report;
30188
+ try {
30189
+ report = await feeder.audio();
30190
+ } catch (err) {
30191
+ this.deps.onLog?.("clip playback: audio not reported — the answer failed", {
30192
+ deviceId: this.deps.deviceId,
30193
+ clipId,
30194
+ error: err instanceof Error ? err.message : String(err)
30195
+ });
30196
+ return;
30197
+ }
30198
+ if (this.clip?.clipId !== clipId) {
30199
+ this.deps.onLog?.("clip playback: audio dropped — the pointer has left this clip", {
30200
+ deviceId: this.deps.deviceId,
30201
+ clipId,
30202
+ carried: report.carried
30203
+ });
30204
+ return;
30205
+ }
30206
+ this.deps.onLog?.("clip playback: audio reported", {
30207
+ deviceId: this.deps.deviceId,
30208
+ clipId,
30209
+ carried: report.carried,
30210
+ codec: report.codec,
30211
+ reason: report.reason,
30212
+ detail: report.detail
30213
+ });
30214
+ this.deps.session.sendControl(serializeServerMessage({
30215
+ t: "clipAudio",
30216
+ clipId,
30217
+ carried: report.carried,
30218
+ ...report.codec !== null ? { codec: report.codec } : {},
30219
+ ...report.reason !== null ? { reason: report.reason } : {}
30220
+ }));
30061
30221
  }
30062
30222
  /**
30063
30223
  * Tell the viewer how long the file on the pointer REALLY is.
@@ -30100,6 +30260,13 @@ var TimelineSession = class {
30100
30260
  });
30101
30261
  return;
30102
30262
  }
30263
+ if (durationMs === null) {
30264
+ this.deps.onLog?.("clip playback: duration unknown — the source ended without a length", {
30265
+ deviceId: this.deps.deviceId,
30266
+ clipId
30267
+ });
30268
+ return;
30269
+ }
30103
30270
  if (!(durationMs > 0)) {
30104
30271
  this.deps.onLog?.("clip playback: duration not reported — the measure is not positive", {
30105
30272
  deviceId: this.deps.deviceId,
@@ -30298,7 +30465,7 @@ var LARGE_SIZE_HEADER_LENGTH = 16;
30298
30465
  * structurally impossible (a size that does not fit), which the caller turns
30299
30466
  * into `malformed` — never a throw, because these bytes come off a camera.
30300
30467
  */
30301
- function childBoxes(bytes, start, end) {
30468
+ function childBoxes$1(bytes, start, end) {
30302
30469
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30303
30470
  const out = [];
30304
30471
  let off = start;
@@ -30328,7 +30495,7 @@ function childBoxes(bytes, start, end) {
30328
30495
  }
30329
30496
  return out;
30330
30497
  }
30331
- function findBox(boxes, type) {
30498
+ function findBox$1(boxes, type) {
30332
30499
  for (const b of boxes) if (b.type === type) return b;
30333
30500
  return null;
30334
30501
  }
@@ -30358,7 +30525,7 @@ function readSyncSampleNumbers(bytes, box) {
30358
30525
  for (let i = 0; i < entries; i++) out.add(view.getUint32(base + 4 + i * 4));
30359
30526
  return out;
30360
30527
  }
30361
- function mediaTimescale(bytes, mdhd) {
30528
+ function mediaTimescale$1(bytes, mdhd) {
30362
30529
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30363
30530
  const version = bytes[mdhd.contentStart] ?? 0;
30364
30531
  const at = mdhd.contentStart + 4 + (version === 1 ? 16 : 8);
@@ -30366,7 +30533,7 @@ function mediaTimescale(bytes, mdhd) {
30366
30533
  const ts = view.getUint32(at);
30367
30534
  return ts > 0 ? ts : null;
30368
30535
  }
30369
- function handlerType(bytes, hdlr) {
30536
+ function handlerType$1(bytes, hdlr) {
30370
30537
  const at = hdlr.contentStart + 8;
30371
30538
  if (at + FOURCC_LENGTH > hdlr.end) return "";
30372
30539
  let out = "";
@@ -30375,16 +30542,16 @@ function handlerType(bytes, hdlr) {
30375
30542
  }
30376
30543
  /** The video `trak` of a `moov`, or `null` when it has none. */
30377
30544
  function videoTrack(bytes, moov) {
30378
- const children = childBoxes(bytes, moov.contentStart, moov.end);
30545
+ const children = childBoxes$1(bytes, moov.contentStart, moov.end);
30379
30546
  if (!children) return null;
30380
30547
  for (const trak of children) {
30381
30548
  if (trak.type !== "trak") continue;
30382
- const trakKids = childBoxes(bytes, trak.contentStart, trak.end);
30383
- const mdia = trakKids ? findBox(trakKids, "mdia") : null;
30549
+ const trakKids = childBoxes$1(bytes, trak.contentStart, trak.end);
30550
+ const mdia = trakKids ? findBox$1(trakKids, "mdia") : null;
30384
30551
  if (!mdia) continue;
30385
- const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
30386
- const hdlr = mdiaKids ? findBox(mdiaKids, "hdlr") : null;
30387
- if (hdlr && handlerType(bytes, hdlr) === "vide") return mdia;
30552
+ const mdiaKids = childBoxes$1(bytes, mdia.contentStart, mdia.end);
30553
+ const hdlr = mdiaKids ? findBox$1(mdiaKids, "hdlr") : null;
30554
+ if (hdlr && handlerType$1(bytes, hdlr) === "vide") return mdia;
30388
30555
  }
30389
30556
  return null;
30390
30557
  }
@@ -30393,12 +30560,12 @@ function videoTrack(bytes, moov) {
30393
30560
  * comes back as a named refusal so the caller can say WHY a clip will not play.
30394
30561
  */
30395
30562
  function indexClipMp4(bytes) {
30396
- const top = childBoxes(bytes, 0, bytes.length);
30563
+ const top = childBoxes$1(bytes, 0, bytes.length);
30397
30564
  if (!top) return {
30398
30565
  kind: "refused",
30399
30566
  reason: "malformed"
30400
30567
  };
30401
- const moov = findBox(top, "moov");
30568
+ const moov = findBox$1(top, "moov");
30402
30569
  if (!moov) return {
30403
30570
  kind: "refused",
30404
30571
  reason: "no-moov"
@@ -30408,34 +30575,34 @@ function indexClipMp4(bytes) {
30408
30575
  kind: "refused",
30409
30576
  reason: "no-video-track"
30410
30577
  };
30411
- const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
30578
+ const mdiaKids = childBoxes$1(bytes, mdia.contentStart, mdia.end);
30412
30579
  if (!mdiaKids) return {
30413
30580
  kind: "refused",
30414
30581
  reason: "malformed"
30415
30582
  };
30416
- const mdhd = findBox(mdiaKids, "mdhd");
30417
- const minf = findBox(mdiaKids, "minf");
30583
+ const mdhd = findBox$1(mdiaKids, "mdhd");
30584
+ const minf = findBox$1(mdiaKids, "minf");
30418
30585
  if (!mdhd || !minf) return {
30419
30586
  kind: "refused",
30420
30587
  reason: "malformed"
30421
30588
  };
30422
- const timescale = mediaTimescale(bytes, mdhd);
30589
+ const timescale = mediaTimescale$1(bytes, mdhd);
30423
30590
  if (timescale === null) return {
30424
30591
  kind: "refused",
30425
30592
  reason: "malformed"
30426
30593
  };
30427
- const minfKids = childBoxes(bytes, minf.contentStart, minf.end);
30428
- const stbl = minfKids ? findBox(minfKids, "stbl") : null;
30594
+ const minfKids = childBoxes$1(bytes, minf.contentStart, minf.end);
30595
+ const stbl = minfKids ? findBox$1(minfKids, "stbl") : null;
30429
30596
  if (!stbl) return {
30430
30597
  kind: "refused",
30431
30598
  reason: "no-samples"
30432
30599
  };
30433
- const stblKids = childBoxes(bytes, stbl.contentStart, stbl.end);
30600
+ const stblKids = childBoxes$1(bytes, stbl.contentStart, stbl.end);
30434
30601
  if (!stblKids) return {
30435
30602
  kind: "refused",
30436
30603
  reason: "malformed"
30437
30604
  };
30438
- const sttsBox = findBox(stblKids, "stts");
30605
+ const sttsBox = findBox$1(stblKids, "stts");
30439
30606
  if (!sttsBox) return {
30440
30607
  kind: "refused",
30441
30608
  reason: "no-samples"
@@ -30445,7 +30612,7 @@ function indexClipMp4(bytes) {
30445
30612
  kind: "refused",
30446
30613
  reason: "malformed"
30447
30614
  };
30448
- const stssBox = findBox(stblKids, "stss");
30615
+ const stssBox = findBox$1(stblKids, "stss");
30449
30616
  const syncNumbers = stssBox === null ? null : readSyncSampleNumbers(bytes, stssBox);
30450
30617
  if (stssBox !== null && syncNumbers === null) return {
30451
30618
  kind: "refused",
@@ -30493,6 +30660,13 @@ var ClipSourceError = class extends Error {
30493
30660
  this.name = "ClipSourceError";
30494
30661
  }
30495
30662
  };
30663
+ /**
30664
+ * 64 MB. Above `VIDEOCLIPS_MAX_READ_BYTES` (50 MB) on purpose: the cap's own
30665
+ * bound refuses first and by name, and this one exists so a provider that ever
30666
+ * answers larger cannot pin a broker runner's heap. A `high` twin measured
30667
+ * 20–35 MB (D570 § 9.6).
30668
+ */
30669
+ var CLIP_SOURCE_MAX_BYTES = 64 * 1024 * 1024;
30496
30670
  function createClipFileSource(deps) {
30497
30671
  const maxBytes = deps.maxBytes ?? 67108864;
30498
30672
  const { deviceId, clipId, provider, profile } = deps.handle;
@@ -30586,6 +30760,1827 @@ function createClipFileSource(deps) {
30586
30760
  };
30587
30761
  }
30588
30762
  //#endregion
30763
+ //#region src/stream-broker/recorded/clip-path-choice.ts
30764
+ function chooseClipPath(input) {
30765
+ if (input.streamDialWired) return {
30766
+ path: "stream",
30767
+ reason: "stream-serves-every-profile"
30768
+ };
30769
+ if (input.fileReadWired) return {
30770
+ path: "file",
30771
+ reason: "no-stream-dial-wired"
30772
+ };
30773
+ return {
30774
+ path: "none",
30775
+ reason: "nothing-wired"
30776
+ };
30777
+ }
30778
+ /**
30779
+ * The feeder a `none` choice gets: every verb is a named refusal and the
30780
+ * first seek reports `error`, so a clip nothing can serve is never a frozen
30781
+ * frame with no line behind it (D391).
30782
+ */
30783
+ function createRefusedClipFeeder(deps) {
30784
+ const data = {
30785
+ deviceId: deps.handle.deviceId,
30786
+ clipId: deps.handle.clipId,
30787
+ reason: deps.reason
30788
+ };
30789
+ let announced = false;
30790
+ const refuse = (verb) => {
30791
+ deps.onLog?.("clip playback: refused — no path can serve this clip", {
30792
+ ...data,
30793
+ verb
30794
+ });
30795
+ if (announced) return;
30796
+ announced = true;
30797
+ deps.onState({
30798
+ from: null,
30799
+ to: "error",
30800
+ reason: `refused:${deps.reason}`,
30801
+ seekToken: 0
30802
+ });
30803
+ };
30804
+ return {
30805
+ seek: async () => refuse("seek"),
30806
+ setRate: () => {},
30807
+ stepFrame: () => refuse("stepFrame"),
30808
+ beginScrub: () => refuse("beginScrub"),
30809
+ scrubTo: () => {},
30810
+ scrubAck: () => {},
30811
+ durationMs: () => Promise.resolve(null),
30812
+ dispose: () => {}
30813
+ };
30814
+ }
30815
+ var PCMU_RATE_HZ = 8e3;
30816
+ function createPassthroughFramer() {
30817
+ return {
30818
+ push: (unit) => unit.data.byteLength === 0 ? [] : [{
30819
+ data: unit.data,
30820
+ ptsMs: unit.ptsMs,
30821
+ durationMs: unit.durationMs
30822
+ }],
30823
+ carriedBytes: 0,
30824
+ reanchors: 0
30825
+ };
30826
+ }
30827
+ var PcmuFramer = class {
30828
+ carry = new Uint8Array(0);
30829
+ /** `ptsMs` of the first byte currently held in {@link carry}. */
30830
+ anchorMs = null;
30831
+ /** Bytes emitted plus carried since {@link anchorMs} — the exact clock. */
30832
+ bytesSinceAnchor = 0;
30833
+ reanchorCount = 0;
30834
+ get carriedBytes() {
30835
+ return this.carry.byteLength;
30836
+ }
30837
+ get reanchors() {
30838
+ return this.reanchorCount;
30839
+ }
30840
+ push(unit) {
30841
+ if (unit.data.byteLength === 0) return [];
30842
+ const expectedMs = this.anchorMs === null ? null : this.anchorMs + this.bytesSinceAnchor * 1e3 / PCMU_RATE_HZ;
30843
+ if (expectedMs === null || Math.abs(expectedMs - unit.ptsMs) > 20) {
30844
+ if (expectedMs !== null) this.reanchorCount += 1;
30845
+ this.carry = new Uint8Array(0);
30846
+ this.anchorMs = unit.ptsMs;
30847
+ this.bytesSinceAnchor = 0;
30848
+ }
30849
+ const anchorMs = this.anchorMs;
30850
+ if (anchorMs === null) return [];
30851
+ const joined = new Uint8Array(this.carry.byteLength + unit.data.byteLength);
30852
+ joined.set(this.carry, 0);
30853
+ joined.set(unit.data, this.carry.byteLength);
30854
+ const out = [];
30855
+ let at = 0;
30856
+ while (at + 160 <= joined.byteLength) {
30857
+ out.push({
30858
+ data: joined.slice(at, at + 160),
30859
+ ptsMs: anchorMs + this.bytesSinceAnchor * 1e3 / PCMU_RATE_HZ,
30860
+ durationMs: 20
30861
+ });
30862
+ this.bytesSinceAnchor += 160;
30863
+ at += 160;
30864
+ }
30865
+ this.carry = joined.slice(at);
30866
+ return out;
30867
+ }
30868
+ };
30869
+ /** The framer for a codec: a pass-through for Opus, a re-cut for µ-law. */
30870
+ function createClipAudioFramer(codec) {
30871
+ return codec === "opus" ? createPassthroughFramer() : new PcmuFramer();
30872
+ }
30873
+ //#endregion
30874
+ //#region src/stream-broker/recorded/fmp4-stream-demux.ts
30875
+ /**
30876
+ * fmp4-stream-demux — an INCREMENTAL demux of a fragmented MP4 as it arrives.
30877
+ *
30878
+ * The clip STREAM (`/clip-stream/…`, D597) is an fMP4 written by ffmpeg with
30879
+ * `frag_keyframe+empty_moov+default_base_moof` while it is still reading the
30880
+ * camera: a `moov` with an EMPTY sample table, then one `moof`+`mdat` per
30881
+ * GOP, and no trailer until the end. Nothing in this directory can read that
30882
+ * shape as it flows — `clip-mp4-index.ts` wants `stbl` and refuses it
30883
+ * `no-samples` by design, `segment-demux-js.ts` (mp4box) wants a WHOLE
30884
+ * fragment in hand — so this module walks the boxes itself, byte by byte, and
30885
+ * emits every video sample the moment its bytes are in.
30886
+ *
30887
+ * ## What it emits and when
30888
+ *
30889
+ * {@link AccessUnit}s, in DECODE order, `ptsMs` anchored at 0 on the first
30890
+ * sample's composition time — the same contract `demuxSegmentFullJs` keeps,
30891
+ * so a streamed clip and a stored one push the same shape. A sample is
30892
+ * emitted as soon as ITS bytes have arrived, not when its `mdat` closes: a
30893
+ * GOP is a second or two of video, and a feeder that waited for the box would
30894
+ * hand the viewer that second late for nothing.
30895
+ *
30896
+ * Sync samples are fused with the parameter sets (`avcc-annexb.ts`), delta
30897
+ * samples are converted plain; the codec and the `avcC`/`hvcC` come from the
30898
+ * `moov`. ONE video track is followed, and — since D600 — ONE audio track
30899
+ * beside it; every other track's bytes in an `mdat` are skipped, never
30900
+ * emitted.
30901
+ *
30902
+ * ## The audio track, and the one clock both tracks are on
30903
+ *
30904
+ * A clip stream carries sound already transcoded to the session's codec
30905
+ * (D600): Opus, whose mp4 sample IS the RTP payload, or µ-law in a QuickTime
30906
+ * `ulaw` entry. Audio samples are handed to {@link Fmp4StreamDemuxOptions.onAudio}
30907
+ * as they land, interleaved with the video in FILE-OFFSET order — which is
30908
+ * the order the bytes arrive, and the only order a forward-only window can
30909
+ * drain in.
30910
+ *
30911
+ * **Both tracks are anchored on ONE origin**, the first sample of either to
30912
+ * be emitted, and every `ptsMs` is measured from it in exact integer
30913
+ * arithmetic over the two timescales. Anchoring each track on its own first
30914
+ * sample would zero out the offset BETWEEN them — which is the whole content
30915
+ * of lip sync — and would do it invisibly, in a way that only shows up as
30916
+ * drift on a long clip.
30917
+ *
30918
+ * An audio track whose sample entry is neither Opus nor µ-law is REPORTED
30919
+ * ({@link Fmp4StreamDemuxer.audio}) and not followed. It is never a refusal:
30920
+ * a codec this process cannot forward is a reason to play the clip silent,
30921
+ * never a reason to lose the video.
30922
+ *
30923
+ * ## What it refuses, by name
30924
+ *
30925
+ * A `moof` before any `moov` (`no-moov`), a `moov` with no video track
30926
+ * (`no-video-track`), a codec that is neither AVC nor HEVC
30927
+ * (`unsupported-codec`), and a box or a sample that cannot fit where it says
30928
+ * it is (`malformed`). Each is thrown ONCE as {@link Fmp4DemuxError}; the
30929
+ * demuxer is dead after it and says so on every further push. The bytes come
30930
+ * off a camera through two processes — a refusal here is a fact about the
30931
+ * stream, never a crash.
30932
+ *
30933
+ * ## Duration
30934
+ *
30935
+ * Unknown until the stream ENDS. `end()` answers the sum of the sample
30936
+ * durations, in ms, or `null` when no sample was ever emitted — never `0`
30937
+ * (D393). The caller learns the length when the camera has finished, which
30938
+ * is the truth about a forward-only source.
30939
+ */
30940
+ var Fmp4DemuxError = class extends Error {
30941
+ reason;
30942
+ constructor(reason, message) {
30943
+ super(message);
30944
+ this.reason = reason;
30945
+ this.name = "Fmp4DemuxError";
30946
+ }
30947
+ };
30948
+ var BOX_HEADER = 8;
30949
+ var LARGE_BOX_HEADER = 16;
30950
+ var FULLBOX = 4;
30951
+ /** `sample_is_non_sync_sample`, ISO/IEC 14496-12 § 8.8.3.1. */
30952
+ var FLAG_NON_SYNC = 65536;
30953
+ var TFHD_BASE_DATA_OFFSET = 1;
30954
+ var TFHD_SAMPLE_DESC_INDEX = 2;
30955
+ var TFHD_DEFAULT_DURATION = 8;
30956
+ var TFHD_DEFAULT_SIZE = 16;
30957
+ var TFHD_DEFAULT_FLAGS = 32;
30958
+ var TFHD_DEFAULT_BASE_IS_MOOF = 131072;
30959
+ var TRUN_DATA_OFFSET = 1;
30960
+ var TRUN_FIRST_SAMPLE_FLAGS = 4;
30961
+ var TRUN_SAMPLE_DURATION = 256;
30962
+ var TRUN_SAMPLE_SIZE = 512;
30963
+ var TRUN_SAMPLE_FLAGS = 1024;
30964
+ var TRUN_SAMPLE_CTS = 2048;
30965
+ /** VisualSampleEntry fields between the sample-entry header and its children. */
30966
+ var VISUAL_SAMPLE_ENTRY_FIELDS = 78;
30967
+ /**
30968
+ * AudioSampleEntry fixed fields, ISO/IEC 14496-12 § 12.2.3: the 8-byte
30969
+ * `SampleEntry` (6 reserved + `data_reference_index`), then version, revision,
30970
+ * vendor, `channelcount`, `samplesize`, `compression_id`, `packet_size` and a
30971
+ * 16.16 `samplerate`.
30972
+ */
30973
+ var AUDIO_SAMPLE_ENTRY_FIELDS = 28;
30974
+ var AUDIO_ENTRY_CHANNELS_AT = 16;
30975
+ var AUDIO_ENTRY_SAMPLE_RATE_AT = 24;
30976
+ /** `cts` in `timescale` ticks, as milliseconds from `origin`. Exact. */
30977
+ function ptsMsFrom(origin, cts, timescale) {
30978
+ if (timescale === origin.timescale) return Math.round((cts - origin.ticks) * 1e3 / timescale);
30979
+ const numerator = (cts * origin.timescale - origin.ticks * timescale) * 1e3;
30980
+ return Math.round(numerator / (timescale * origin.timescale));
30981
+ }
30982
+ function fourcc(bytes, at) {
30983
+ let out = "";
30984
+ for (let i = 0; i < 4; i++) out += String.fromCharCode(bytes[at + i] ?? 0);
30985
+ return out;
30986
+ }
30987
+ function u32(view, at) {
30988
+ return view.getUint32(at);
30989
+ }
30990
+ function u64(view, at) {
30991
+ return view.getUint32(at) * 4294967296 + view.getUint32(at + 4);
30992
+ }
30993
+ /** The direct children of `[start, end)`, or `null` when a size does not fit. */
30994
+ function childBoxes(bytes, start, end) {
30995
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30996
+ const out = [];
30997
+ let off = start;
30998
+ while (off + BOX_HEADER <= end) {
30999
+ const declared = u32(view, off);
31000
+ const type = fourcc(bytes, off + 4);
31001
+ let size = declared;
31002
+ let contentStart = off + BOX_HEADER;
31003
+ if (declared === 1) {
31004
+ if (off + LARGE_BOX_HEADER > end) return null;
31005
+ size = u64(view, off + 8);
31006
+ contentStart = off + LARGE_BOX_HEADER;
31007
+ } else if (declared === 0) size = end - off;
31008
+ if (size < contentStart - off) return null;
31009
+ const boxEnd = off + size;
31010
+ if (boxEnd > end) return null;
31011
+ out.push({
31012
+ type,
31013
+ start: off,
31014
+ end: boxEnd,
31015
+ contentStart
31016
+ });
31017
+ off = boxEnd;
31018
+ }
31019
+ return out;
31020
+ }
31021
+ function findBox(boxes, type) {
31022
+ if (boxes === null) return null;
31023
+ for (const b of boxes) if (b.type === type) return b;
31024
+ return null;
31025
+ }
31026
+ function handlerType(bytes, hdlr) {
31027
+ const at = hdlr.contentStart + FULLBOX + 4;
31028
+ return at + 4 <= hdlr.end ? fourcc(bytes, at) : "";
31029
+ }
31030
+ function trackId(bytes, tkhd) {
31031
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31032
+ const version = bytes[tkhd.contentStart] ?? 0;
31033
+ const at = tkhd.contentStart + FULLBOX + (version === 1 ? 16 : 8);
31034
+ return at + 4 <= tkhd.end ? u32(view, at) : null;
31035
+ }
31036
+ function mediaTimescale(bytes, mdhd) {
31037
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31038
+ const version = bytes[mdhd.contentStart] ?? 0;
31039
+ const at = mdhd.contentStart + FULLBOX + (version === 1 ? 16 : 8);
31040
+ if (at + 4 > mdhd.end) return null;
31041
+ const ts = u32(view, at);
31042
+ return ts > 0 ? ts : null;
31043
+ }
31044
+ /** The decoder config out of `stsd`, header stripped, with the codec it names. */
31045
+ function sampleDescription(bytes, stsd) {
31046
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31047
+ const countAt = stsd.contentStart + FULLBOX;
31048
+ if (countAt + 4 > stsd.end) return null;
31049
+ const count = u32(view, countAt);
31050
+ const entries = childBoxes(bytes, countAt + 4, stsd.end);
31051
+ if (entries === null || count === 0 || entries.length === 0) return null;
31052
+ for (const entry of entries) {
31053
+ const isAvc = entry.type === "avc1" || entry.type === "avc3";
31054
+ const isHevc = entry.type === "hvc1" || entry.type === "hev1";
31055
+ if (!isAvc && !isHevc) continue;
31056
+ const config = findBox(childBoxes(bytes, entry.contentStart + VISUAL_SAMPLE_ENTRY_FIELDS, entry.end), isAvc ? "avcC" : "hvcC");
31057
+ if (config === null) return null;
31058
+ return {
31059
+ codec: isAvc ? "H264" : "H265",
31060
+ description: bytes.subarray(config.contentStart, config.end)
31061
+ };
31062
+ }
31063
+ return "unsupported";
31064
+ }
31065
+ function audioSampleDescription(bytes, stsd) {
31066
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31067
+ const countAt = stsd.contentStart + FULLBOX;
31068
+ if (countAt + 4 > stsd.end) return null;
31069
+ const entry = childBoxes(bytes, countAt + 4, stsd.end)?.[0];
31070
+ if (entry === void 0) return null;
31071
+ if (entry.contentStart + AUDIO_SAMPLE_ENTRY_FIELDS > entry.end) return null;
31072
+ const codec = entry.type === "Opus" ? "opus" : entry.type === "ulaw" ? "pcmu" : null;
31073
+ if (codec === null) return { unsupported: entry.type };
31074
+ return {
31075
+ codec,
31076
+ channels: view.getUint16(entry.contentStart + AUDIO_ENTRY_CHANNELS_AT),
31077
+ sampleRateHz: view.getUint16(entry.contentStart + AUDIO_ENTRY_SAMPLE_RATE_AT)
31078
+ };
31079
+ }
31080
+ function readTrex(bytes, moov, id) {
31081
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31082
+ const mvex = findBox(childBoxes(bytes, moov.contentStart, moov.end), "mvex");
31083
+ const none = {
31084
+ duration: null,
31085
+ size: null,
31086
+ flags: null
31087
+ };
31088
+ if (mvex === null) return none;
31089
+ for (const trex of childBoxes(bytes, mvex.contentStart, mvex.end) ?? []) {
31090
+ if (trex.type !== "trex" || trex.contentStart + FULLBOX + 20 > trex.end) continue;
31091
+ const at = trex.contentStart + FULLBOX;
31092
+ if (u32(view, at) !== id) continue;
31093
+ return {
31094
+ duration: u32(view, at + 8),
31095
+ size: u32(view, at + 12),
31096
+ flags: u32(view, at + 16)
31097
+ };
31098
+ }
31099
+ return none;
31100
+ }
31101
+ /** The `trak`s of a `moov` whose handler is `want`, already unpacked. */
31102
+ function traksOfHandler(bytes, moov, want) {
31103
+ const kids = childBoxes(bytes, moov.contentStart, moov.end);
31104
+ if (kids === null) return "malformed";
31105
+ const out = [];
31106
+ for (const trak of kids) {
31107
+ if (trak.type !== "trak") continue;
31108
+ const trakKids = childBoxes(bytes, trak.contentStart, trak.end);
31109
+ const mdia = findBox(trakKids, "mdia");
31110
+ const tkhd = findBox(trakKids, "tkhd");
31111
+ if (mdia === null || tkhd === null) continue;
31112
+ const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
31113
+ const hdlr = findBox(mdiaKids, "hdlr");
31114
+ if (hdlr === null || handlerType(bytes, hdlr) !== want) continue;
31115
+ const id = trackId(bytes, tkhd);
31116
+ const mdhd = findBox(mdiaKids, "mdhd");
31117
+ const minf = findBox(mdiaKids, "minf");
31118
+ if (id === null || mdhd === null || minf === null) return "malformed";
31119
+ const timescale = mediaTimescale(bytes, mdhd);
31120
+ const stbl = findBox(childBoxes(bytes, minf.contentStart, minf.end), "stbl");
31121
+ const stsd = findBox(stbl ? childBoxes(bytes, stbl.contentStart, stbl.end) : null, "stsd");
31122
+ if (timescale === null || stsd === null) return "malformed";
31123
+ out.push({
31124
+ id,
31125
+ timescale,
31126
+ stsd
31127
+ });
31128
+ }
31129
+ return out;
31130
+ }
31131
+ /** The one video track of a `moov`, or the refusal that stands in for it. */
31132
+ function readVideoTrack(bytes, moov) {
31133
+ const traks = traksOfHandler(bytes, moov, "vide");
31134
+ if (traks === "malformed") return "malformed";
31135
+ for (const trak of traks) {
31136
+ const desc = sampleDescription(bytes, trak.stsd);
31137
+ if (desc === null) return "malformed";
31138
+ if (desc === "unsupported") return "unsupported-codec";
31139
+ let nalLengthSize;
31140
+ try {
31141
+ nalLengthSize = parseNalLengthSize(desc.description, desc.codec === "H265");
31142
+ } catch {
31143
+ return "malformed";
31144
+ }
31145
+ return {
31146
+ id: trak.id,
31147
+ timescale: trak.timescale,
31148
+ codec: desc.codec,
31149
+ description: desc.description,
31150
+ nalLengthSize,
31151
+ trex: readTrex(bytes, moov, trak.id)
31152
+ };
31153
+ }
31154
+ return "no-video-track";
31155
+ }
31156
+ /**
31157
+ * The one audio track of a `moov`, or what to REPORT instead of one.
31158
+ *
31159
+ * Never a refusal: sound this process cannot forward is a silent clip, and a
31160
+ * silent clip is a clip. The only thing that must not happen is silence
31161
+ * nobody can account for, which is what {@link Fmp4AudioState} is for.
31162
+ */
31163
+ function readAudioTrack$1(bytes, moov) {
31164
+ const traks = traksOfHandler(bytes, moov, "soun");
31165
+ if (traks === "malformed") return { kind: "absent" };
31166
+ for (const trak of traks) {
31167
+ const desc = audioSampleDescription(bytes, trak.stsd);
31168
+ if (desc === null) return {
31169
+ kind: "unsupported",
31170
+ sampleEntry: ""
31171
+ };
31172
+ if ("unsupported" in desc) return {
31173
+ kind: "unsupported",
31174
+ sampleEntry: desc.unsupported
31175
+ };
31176
+ return {
31177
+ id: trak.id,
31178
+ timescale: trak.timescale,
31179
+ codec: desc.codec,
31180
+ sampleRateHz: desc.sampleRateHz,
31181
+ channels: desc.channels,
31182
+ trex: readTrex(bytes, moov, trak.id)
31183
+ };
31184
+ }
31185
+ return { kind: "absent" };
31186
+ }
31187
+ function readTfhd(view, tfhd) {
31188
+ const flags = u32(view, tfhd.contentStart) & 16777215;
31189
+ let at = tfhd.contentStart + FULLBOX;
31190
+ if (at + 4 > tfhd.end) return null;
31191
+ const id = u32(view, at);
31192
+ at += 4;
31193
+ let baseDataOffset = null;
31194
+ if (flags & TFHD_BASE_DATA_OFFSET) {
31195
+ if (at + 8 > tfhd.end) return null;
31196
+ baseDataOffset = u64(view, at);
31197
+ at += 8;
31198
+ }
31199
+ if (flags & TFHD_SAMPLE_DESC_INDEX) at += 4;
31200
+ let duration = null;
31201
+ let size = null;
31202
+ let sampleFlags = null;
31203
+ if (flags & TFHD_DEFAULT_DURATION) {
31204
+ if (at + 4 > tfhd.end) return null;
31205
+ duration = u32(view, at);
31206
+ at += 4;
31207
+ }
31208
+ if (flags & TFHD_DEFAULT_SIZE) {
31209
+ if (at + 4 > tfhd.end) return null;
31210
+ size = u32(view, at);
31211
+ at += 4;
31212
+ }
31213
+ if (flags & TFHD_DEFAULT_FLAGS) {
31214
+ if (at + 4 > tfhd.end) return null;
31215
+ sampleFlags = u32(view, at);
31216
+ }
31217
+ return {
31218
+ trackId: id,
31219
+ baseDataOffset,
31220
+ defaultBaseIsMoof: (flags & TFHD_DEFAULT_BASE_IS_MOOF) !== 0,
31221
+ defaults: {
31222
+ duration,
31223
+ size,
31224
+ flags: sampleFlags
31225
+ }
31226
+ };
31227
+ }
31228
+ function readTfdt(view, bytes, tfdt) {
31229
+ const version = bytes[tfdt.contentStart] ?? 0;
31230
+ const at = tfdt.contentStart + FULLBOX;
31231
+ if (version === 1) return at + 8 <= tfdt.end ? u64(view, at) : null;
31232
+ return at + 4 <= tfdt.end ? u32(view, at) : null;
31233
+ }
31234
+ /** The samples one `trun` announces, or `null` when it does not fit. */
31235
+ function readTrun(args) {
31236
+ const { view, trun, base, trex, traf } = args;
31237
+ const version = view.getUint8(trun.contentStart);
31238
+ const flags = u32(view, trun.contentStart) & 16777215;
31239
+ let at = trun.contentStart + FULLBOX;
31240
+ if (at + 4 > trun.end) return null;
31241
+ const count = u32(view, at);
31242
+ at += 4;
31243
+ let dataOffset = 0;
31244
+ if (flags & TRUN_DATA_OFFSET) {
31245
+ if (at + 4 > trun.end) return null;
31246
+ dataOffset = view.getInt32(at);
31247
+ at += 4;
31248
+ }
31249
+ let firstSampleFlags = null;
31250
+ if (flags & TRUN_FIRST_SAMPLE_FLAGS) {
31251
+ if (at + 4 > trun.end) return null;
31252
+ firstSampleFlags = u32(view, at);
31253
+ at += 4;
31254
+ }
31255
+ const out = [];
31256
+ let offset = base + dataOffset;
31257
+ let dts = args.startDts;
31258
+ for (let i = 0; i < count; i++) {
31259
+ let duration = traf.defaults.duration ?? trex.duration;
31260
+ let size = traf.defaults.size ?? trex.size;
31261
+ let sampleFlags = traf.defaults.flags ?? trex.flags;
31262
+ let ctsOffset = 0;
31263
+ if (flags & TRUN_SAMPLE_DURATION) {
31264
+ if (at + 4 > trun.end) return null;
31265
+ duration = u32(view, at);
31266
+ at += 4;
31267
+ }
31268
+ if (flags & TRUN_SAMPLE_SIZE) {
31269
+ if (at + 4 > trun.end) return null;
31270
+ size = u32(view, at);
31271
+ at += 4;
31272
+ }
31273
+ if (flags & TRUN_SAMPLE_FLAGS) {
31274
+ if (at + 4 > trun.end) return null;
31275
+ sampleFlags = u32(view, at);
31276
+ at += 4;
31277
+ } else if (i === 0 && firstSampleFlags !== null) sampleFlags = firstSampleFlags;
31278
+ if (flags & TRUN_SAMPLE_CTS) {
31279
+ if (at + 4 > trun.end) return null;
31280
+ ctsOffset = version === 0 ? u32(view, at) : view.getInt32(at);
31281
+ at += 4;
31282
+ }
31283
+ if (duration === null || size === null) return null;
31284
+ const sync = sampleFlags === null ? i === 0 : (sampleFlags & FLAG_NON_SYNC) === 0;
31285
+ out.push({
31286
+ offset,
31287
+ size,
31288
+ dts,
31289
+ cts: dts + ctsOffset,
31290
+ duration,
31291
+ sync
31292
+ });
31293
+ offset += size;
31294
+ dts += duration;
31295
+ }
31296
+ return out;
31297
+ }
31298
+ /**
31299
+ * Every sample a `moof` announces for the followed tracks, in FILE-OFFSET
31300
+ * order, or `null`.
31301
+ *
31302
+ * Offset order, not decode order across tracks: the window is forward-only
31303
+ * and drains what has arrived, so two tracks whose runs are laid out one
31304
+ * after another inside one `mdat` — which is how ffmpeg writes them — must
31305
+ * be drained in the order their bytes land. Within one track the offsets are
31306
+ * increasing anyway, so each track's own decode order is preserved.
31307
+ */
31308
+ function readMoof(bytes, moof, tracks) {
31309
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31310
+ const kids = childBoxes(bytes, moof.contentStart, moof.end);
31311
+ if (kids === null) return null;
31312
+ const out = [];
31313
+ for (const traf of kids) {
31314
+ if (traf.type !== "traf") continue;
31315
+ const trafKids = childBoxes(bytes, traf.contentStart, traf.end);
31316
+ const tfhdBox = findBox(trafKids, "tfhd");
31317
+ if (tfhdBox === null) return null;
31318
+ const tfhd = readTfhd(view, tfhdBox);
31319
+ if (tfhd === null) return null;
31320
+ const kind = tfhd.trackId === tracks.video.id ? "video" : tracks.audio !== null && tfhd.trackId === tracks.audio.id ? "audio" : null;
31321
+ if (kind === null) continue;
31322
+ const trex = kind === "video" ? tracks.video.trex : tracks.audio?.trex ?? null;
31323
+ if (trex === null) continue;
31324
+ const tfdtBox = findBox(trafKids, "tfdt");
31325
+ const startDts = tfdtBox === null ? 0 : readTfdt(view, bytes, tfdtBox);
31326
+ if (startDts === null) return null;
31327
+ const base = tfhd.baseDataOffset ?? moof.start;
31328
+ let dts = startDts;
31329
+ for (const trun of trafKids ?? []) {
31330
+ if (trun.type !== "trun") continue;
31331
+ const samples = readTrun({
31332
+ view,
31333
+ trun,
31334
+ base,
31335
+ startDts: dts,
31336
+ trex,
31337
+ traf: tfhd
31338
+ });
31339
+ if (samples === null) return null;
31340
+ for (const sample of samples) out.push({
31341
+ kind,
31342
+ sample
31343
+ });
31344
+ const last = samples[samples.length - 1];
31345
+ if (last !== void 0) dts = last.dts + last.duration;
31346
+ }
31347
+ }
31348
+ return out.toSorted((a, b) => a.sample.offset - b.sample.offset);
31349
+ }
31350
+ /** Bytes are ABSOLUTE file offsets here, so the demuxer can reason about a
31351
+ * sample announced by a `moof` whose `mdat` has not fully arrived. */
31352
+ var ByteWindow = class {
31353
+ held = new Uint8Array(0);
31354
+ /** Absolute offset of `held[0]`. */
31355
+ base = 0;
31356
+ /** Bytes still to DISCARD from the front of what arrives next — a skip
31357
+ * past the end of what is held (the tail of an mdat nobody follows). */
31358
+ skipAhead = 0;
31359
+ get start() {
31360
+ return this.base;
31361
+ }
31362
+ get end() {
31363
+ return this.base + this.held.length;
31364
+ }
31365
+ get length() {
31366
+ return this.held.length;
31367
+ }
31368
+ append(chunk) {
31369
+ let incoming = chunk;
31370
+ if (this.skipAhead > 0) {
31371
+ const drop = Math.min(this.skipAhead, incoming.length);
31372
+ this.skipAhead -= drop;
31373
+ this.base += drop;
31374
+ incoming = incoming.subarray(drop);
31375
+ }
31376
+ if (incoming.length === 0) return;
31377
+ if (this.held.length === 0) {
31378
+ this.held = incoming;
31379
+ return;
31380
+ }
31381
+ const next = new Uint8Array(this.held.length + incoming.length);
31382
+ next.set(this.held, 0);
31383
+ next.set(incoming, this.held.length);
31384
+ this.held = next;
31385
+ }
31386
+ /** Drop everything before absolute `offset`, including bytes not yet arrived. */
31387
+ consumeTo(offset) {
31388
+ if (offset <= this.base) return;
31389
+ const drop = Math.min(offset - this.base, this.held.length);
31390
+ this.held = this.held.subarray(drop);
31391
+ this.base += drop;
31392
+ if (offset > this.base) {
31393
+ this.skipAhead += offset - this.base;
31394
+ this.base = offset;
31395
+ this.held = new Uint8Array(0);
31396
+ }
31397
+ }
31398
+ slice(absStart, absEnd) {
31399
+ return this.held.subarray(absStart - this.base, absEnd - this.base);
31400
+ }
31401
+ view() {
31402
+ return this.held;
31403
+ }
31404
+ };
31405
+ function createFmp4StreamDemuxer(options = {}) {
31406
+ const onAudio = options.onAudio;
31407
+ const window = new ByteWindow();
31408
+ let track = null;
31409
+ let audioTrack = null;
31410
+ let audioState = { kind: "unknown" };
31411
+ let dead = null;
31412
+ /** Samples announced by the current `moof`, not yet emitted, offset order. */
31413
+ let announced = [];
31414
+ /** Absolute end of the `mdat` the announced samples live in, once known. */
31415
+ let mdatEnd = null;
31416
+ /** The ONE origin both tracks are measured from — the first sample emitted. */
31417
+ let origin = null;
31418
+ let samples = 0;
31419
+ let audioSamples = 0;
31420
+ let durationTicks = 0;
31421
+ const refuse = (reason, message) => {
31422
+ dead = new Fmp4DemuxError(reason, message);
31423
+ throw dead;
31424
+ };
31425
+ /** Anchor the shared origin on the first sample of EITHER track. */
31426
+ const anchor = (cts, timescale) => {
31427
+ origin ??= {
31428
+ ticks: cts,
31429
+ timescale
31430
+ };
31431
+ return origin;
31432
+ };
31433
+ const toAccessUnit = (sample, data) => {
31434
+ if (track === null) throw new Error("unreachable: sample before moov");
31435
+ const at = anchor(sample.cts, track.timescale);
31436
+ const isHevc = track.codec === "H265";
31437
+ let annexB;
31438
+ try {
31439
+ annexB = sample.sync ? buildKeyframeAnnexB(data, track.description, isHevc) : avccSampleToAnnexB(data, track.nalLengthSize);
31440
+ } catch (err) {
31441
+ return refuse("malformed", `sample ${samples} is not a valid AVCC/HVCC sample: ${String(err)}`);
31442
+ }
31443
+ samples += 1;
31444
+ durationTicks += sample.duration;
31445
+ return {
31446
+ data: annexB,
31447
+ ptsMs: ptsMsFrom(at, sample.cts, track.timescale),
31448
+ keyframe: sample.sync,
31449
+ codec: track.codec
31450
+ };
31451
+ };
31452
+ const emitAudio = (sample, data) => {
31453
+ if (audioTrack === null || onAudio === void 0) return;
31454
+ const at = anchor(sample.cts, audioTrack.timescale);
31455
+ audioSamples += 1;
31456
+ onAudio({
31457
+ data: data.slice(),
31458
+ ptsMs: ptsMsFrom(at, sample.cts, audioTrack.timescale),
31459
+ durationMs: Math.round(sample.duration * 1e3 / audioTrack.timescale),
31460
+ codec: audioTrack.codec,
31461
+ sampleRateHz: audioTrack.sampleRateHz,
31462
+ channels: audioTrack.channels
31463
+ });
31464
+ };
31465
+ /** Emit every announced sample whose bytes are in, in order; stop at the first that is not. */
31466
+ const drainAnnounced = (out) => {
31467
+ while (announced.length > 0) {
31468
+ const next = announced[0];
31469
+ if (next === void 0) return;
31470
+ const sampleEnd = next.sample.offset + next.sample.size;
31471
+ if (mdatEnd !== null && sampleEnd > mdatEnd) refuse("malformed", `a sample runs past its mdat (${sampleEnd} > ${mdatEnd})`);
31472
+ if (next.sample.offset < window.start) refuse("malformed", `a sample points behind the bytes still held (${next.sample.offset} < ${window.start})`);
31473
+ if (sampleEnd > window.end) return;
31474
+ const bytes = window.slice(next.sample.offset, sampleEnd);
31475
+ if (next.kind === "video") out.push(toAccessUnit(next.sample, bytes));
31476
+ else emitAudio(next.sample, bytes);
31477
+ announced = announced.slice(1);
31478
+ window.consumeTo(sampleEnd);
31479
+ }
31480
+ if (mdatEnd !== null) {
31481
+ window.consumeTo(mdatEnd);
31482
+ mdatEnd = null;
31483
+ }
31484
+ };
31485
+ const push = (chunk) => {
31486
+ if (dead !== null) throw dead;
31487
+ window.append(chunk);
31488
+ const out = [];
31489
+ for (;;) {
31490
+ if (announced.length > 0 && mdatEnd !== null) {
31491
+ drainAnnounced(out);
31492
+ if (announced.length > 0) return out;
31493
+ }
31494
+ const held = window.view();
31495
+ if (held.length < BOX_HEADER) return out;
31496
+ const view = new DataView(held.buffer, held.byteOffset, held.byteLength);
31497
+ const declared = u32(view, 0);
31498
+ const type = fourcc(held, 4);
31499
+ let size = declared;
31500
+ let contentStart = BOX_HEADER;
31501
+ if (declared === 1) {
31502
+ if (held.length < LARGE_BOX_HEADER) return out;
31503
+ size = u64(view, 8);
31504
+ contentStart = LARGE_BOX_HEADER;
31505
+ } else if (declared === 0) refuse("malformed", `a "${type}" box extends to end-of-file, which a stream never reaches`);
31506
+ if (size < contentStart) refuse("malformed", `a "${type}" box declares ${size} bytes`);
31507
+ const boxStart = window.start;
31508
+ const boxEnd = boxStart + size;
31509
+ if (type === "mdat") {
31510
+ if (track === null) refuse("no-moov", "media arrived before any moov");
31511
+ if (announced.length === 0) {
31512
+ window.consumeTo(boxEnd);
31513
+ continue;
31514
+ }
31515
+ mdatEnd = boxEnd;
31516
+ window.consumeTo(boxStart + contentStart);
31517
+ continue;
31518
+ }
31519
+ if (held.length < size) return out;
31520
+ if (type === "moov") {
31521
+ const moov = {
31522
+ type,
31523
+ start: 0,
31524
+ end: size,
31525
+ contentStart
31526
+ };
31527
+ const read = readVideoTrack(held, moov);
31528
+ if (typeof read === "string") refuse(read, `the moov cannot be followed: ${read}`);
31529
+ else track = read;
31530
+ const audio = readAudioTrack$1(held, moov);
31531
+ if ("kind" in audio) audioState = audio;
31532
+ else {
31533
+ audioTrack = audio;
31534
+ audioState = {
31535
+ kind: "ready",
31536
+ codec: audio.codec,
31537
+ sampleRateHz: audio.sampleRateHz,
31538
+ channels: audio.channels
31539
+ };
31540
+ }
31541
+ } else if (type === "moof") {
31542
+ if (track === null) refuse("no-moov", "a moof arrived before any moov");
31543
+ const announcedHere = readMoof(held, {
31544
+ type,
31545
+ start: 0,
31546
+ end: size,
31547
+ contentStart
31548
+ }, {
31549
+ video: track,
31550
+ audio: onAudio === void 0 ? null : audioTrack
31551
+ });
31552
+ if (announcedHere === null) refuse("malformed", "a moof cannot be read");
31553
+ announced = announcedHere.map((p) => ({
31554
+ kind: p.kind,
31555
+ sample: {
31556
+ ...p.sample,
31557
+ offset: p.sample.offset + boxStart
31558
+ }
31559
+ }));
31560
+ }
31561
+ window.consumeTo(boxEnd);
31562
+ }
31563
+ };
31564
+ return {
31565
+ push,
31566
+ end: () => ({
31567
+ durationMs: track !== null && samples > 0 && durationTicks > 0 ? durationTicks * 1e3 / track.timescale : null,
31568
+ samples,
31569
+ audioSamples,
31570
+ trailingBytes: window.length,
31571
+ pendingSamples: announced.length
31572
+ }),
31573
+ get ready() {
31574
+ return track !== null;
31575
+ },
31576
+ get codec() {
31577
+ return track?.codec ?? null;
31578
+ },
31579
+ get audio() {
31580
+ return audioState;
31581
+ },
31582
+ get pendingBytes() {
31583
+ return window.length;
31584
+ }
31585
+ };
31586
+ }
31587
+ //#endregion
31588
+ //#region src/stream-broker/recorded/clip-stream-feeder.ts
31589
+ /**
31590
+ * clip-stream-feeder — a clip plays FROM A STREAM, forward-only (D597).
31591
+ *
31592
+ * The second clip feeder, beside `clip-file-source.ts` + `RecordedFeeder`
31593
+ * and not in their place. The file path fetches the whole clip by handle,
31594
+ * indexes `stbl`, and lets the recorded feeder PULL access units by time.
31595
+ * This path is the other shape: the provider's clip STREAM
31596
+ * (`/clip-stream/…`, stage 1) is a fragmented MP4 written from the first
31597
+ * muxed byte, with no index and no length, and the bytes reach this process
31598
+ * as the camera delivers them. Nothing here pulls — the stream PUSHES, the
31599
+ * demuxer emits access units as their bytes land, and this feeder paces them
31600
+ * onto the session's track at the playback rate.
31601
+ *
31602
+ * ## Forward-only, and what that means for every verb
31603
+ *
31604
+ * The camera offers no seek inside a recording (operator's decision from the
31605
+ * packet capture, 2026-09-22): a replay names a FILE, and the bytes behind
31606
+ * the head were never kept. So:
31607
+ *
31608
+ * - `seek(t)` with `t` behind the HEAD (the last frame pushed) is REFUSED BY
31609
+ * NAME — logged, the position re-sent so the viewer's playhead snaps back
31610
+ * to the truth, and nothing else changes. "Back to the start" is as
31611
+ * impossible as any other backward move; `forward` is measured against the
31612
+ * head, never against zero (the viewer's `clipSeekAllowed` says the same).
31613
+ * - `seek(t)` ahead of the head is honoured if the bytes have arrived: what
31614
+ * was buffered before the last keyframe ≤ `t` is dropped, the run from that
31615
+ * keyframe to `t` is BURST (decode priming, as the recorded feeder's seek
31616
+ * prefix does), and pacing resumes from `t`. If the bytes have NOT arrived,
31617
+ * the seek waits for them and lands when they do — a stream cannot answer
31618
+ * faster than the camera sends.
31619
+ * - `stepFrame(-1)` and the scrub verbs are refused by name; `stepFrame(+1)`
31620
+ * pushes the next buffered unit.
31621
+ * - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 4],
31622
+ * the recorded domain). The camera's own `playSpeed` (8, 16 — real, the app
31623
+ * reopens the replay at 8) is a different mechanism and is NOT wired: it
31624
+ * would go on the dial (`videoclips.dialClipStream` → the cmd-5 replay's
31625
+ * `<playSpeed>`), costing a new stream, and is named in D597 as open.
31626
+ *
31627
+ * ## Duration is unknown until it is known
31628
+ *
31629
+ * A stream has no length up front. `durationMs()` resolves when the stream
31630
+ * ENDS, with the demuxer's own sum, or with `null` when it was cut or left
31631
+ * before it ended — never `0` (D393). The session reports it then, named by
31632
+ * clip, exactly as it reports the file path's measure.
31633
+ *
31634
+ * ## D9/D18, stated
31635
+ *
31636
+ * Media crosses the process boundary here COMPRESSED, in one TCP stream, at
31637
+ * the camera's delivery rate — the same shape the live path ingests from an
31638
+ * RTSP camera, and the same shape the file path's one by-handle fetch has,
31639
+ * spread over time instead of held whole. What D9 forbids is RAW frames at
31640
+ * frame-rate across processes, and nothing raw exists until the session's
31641
+ * decoder, which is downstream of this process. The backlog is bounded
31642
+ * ({@link CLIP_STREAM_BUFFER_MAX_BYTES}, the file path's own ceiling): above
31643
+ * it the socket is PAUSED (D447 on the consuming hop — read no chunk you have
31644
+ * not taken), and the producer's drain gate is what decides a consumer that
31645
+ * never drains. Frames behind the head are released as soon as they are
31646
+ * pushed, which is the same fact as "no backward seek".
31647
+ *
31648
+ * ## Sound, and the one clock it shares with the picture (D600)
31649
+ *
31650
+ * The stream carries audio ALREADY in the session's negotiated codec — the
31651
+ * provider transcodes before it muxes, because it is the process that holds
31652
+ * the camera's AAC and it already runs an ffmpeg. So this feeder decodes
31653
+ * nothing: it frames (`clip-stream-audio.ts`) and paces, on ONE schedule with
31654
+ * the video, on the SAME `ptsMs` axis the demuxer anchors both tracks on, and
31655
+ * off ONE {@link MonotonicClock} whose output is converted to each track's
31656
+ * RTP rate ({@link ClipStreamFeeder.stampAudio}). One clock rather than the
31657
+ * recorded path's pair: two clocks re-base on their own last output at every
31658
+ * discontinuity, so each seek leaves them a frame further apart.
31659
+ *
31660
+ * Two rules keep it honest, and both exist because a wrong one is inaudible
31661
+ * in a unit test:
31662
+ *
31663
+ * - **Audio is never pushed late.** ffmpeg's muxer writes a track's run when
31664
+ * its own fragment closes, so the first seconds of sound can land after the
31665
+ * picture they belong to (measured: 43 ms behind at the head on a 60x
31666
+ * delivery, and nothing after). A unit whose moment has passed by more than
31667
+ * {@link CLIP_STREAM_AUDIO_LATE_MS} is DROPPED and counted — never pushed
31668
+ * in a burst the receiver would have to discard anyway, and never silently.
31669
+ * - **Audio plays at 1x only**, like the recorded feeder's: a rate change
31670
+ * would need a resample this path has no decoder for, and pitch-shifted
31671
+ * sound is worse than none. Units dropped for the rate are counted apart.
31672
+ *
31673
+ * What the clip turned out to carry — sound, or silence and WHY — settles on
31674
+ * {@link ClipStreamFeeder.audio} the moment the `moov` is read, and is what
31675
+ * the session reports to the viewer. A dial that promises a codec promises
31676
+ * the codec, never the content: whether the recording has a microphone track
31677
+ * is only ever knowable from the stream.
31678
+ *
31679
+ * ## Every exit is logged
31680
+ *
31681
+ * Opened, first frame (with the time since the seek and since the dial),
31682
+ * ended, cut (by reason), refused (by the provider's code), disposed, every
31683
+ * refused verb, and every backpressure transition — each with `deviceId` in
31684
+ * `data`, which the broker's `onLog` puts in `tags`.
31685
+ */
31686
+ /**
31687
+ * The same ceiling as the file path (`CLIP_SOURCE_MAX_BYTES`): a clip the
31688
+ * file path would refuse `too-large` is one this path stops taking. Frames
31689
+ * behind the head are released as they are pushed, so the backlog is the
31690
+ * distance between arrival and playback, not the clip.
31691
+ */
31692
+ var CLIP_STREAM_BUFFER_MAX_BYTES = CLIP_SOURCE_MAX_BYTES;
31693
+ /** The RTP clock each codec's re-stamped timestamps are counted on. */
31694
+ var AUDIO_CLOCK_HZ = {
31695
+ opus: 48e3,
31696
+ pcmu: 8e3
31697
+ };
31698
+ /** A forward seek is a discontinuity for the receiver, like the recorded feeder's. */
31699
+ var SEEK_DISCONTINUITY_MS = 250;
31700
+ var WAIT_TICK_MS = 250;
31701
+ var POSITION_INTERVAL_MS = 1e3;
31702
+ function createClipStreamFeeder(deps) {
31703
+ return new ClipStreamFeeder(deps);
31704
+ }
31705
+ var ClipStreamFeeder = class {
31706
+ deps;
31707
+ /**
31708
+ * Forward-only, and no backward frame-step: the bytes behind the head were
31709
+ * never kept, and the camera offers no replay-from-offset. Stated here so
31710
+ * the viewer can DISABLE those verbs rather than offer them and have every
31711
+ * use refused — the whole content of this report.
31712
+ */
31713
+ transport = {
31714
+ seek: "forward",
31715
+ stepBack: false
31716
+ };
31717
+ phase = "idle";
31718
+ connection = null;
31719
+ demuxer;
31720
+ clock = new MonotonicClock();
31721
+ /** The RTP rate the audio sender negotiated — a CONVERSION, not a clock. */
31722
+ audioClockHz;
31723
+ audioAsk;
31724
+ audioPush;
31725
+ audioFramer = null;
31726
+ audioQueue = [];
31727
+ audioQueuedBytes = 0;
31728
+ audioPushed = 0;
31729
+ audioDroppedLate = 0;
31730
+ audioDroppedRate = 0;
31731
+ audioDroppedSeek = 0;
31732
+ firstAudioLogged = false;
31733
+ audioReport = null;
31734
+ audioWaiters = [];
31735
+ clockHasAdvanced = false;
31736
+ rate = 1;
31737
+ queue = [];
31738
+ queuedBytes = 0;
31739
+ headMs = null;
31740
+ arrivedMs = null;
31741
+ pendingSeekMs = null;
31742
+ pendingSteps = 0;
31743
+ seekToken = 0;
31744
+ anchorWall = 0;
31745
+ anchorPts = 0;
31746
+ paceTimer = null;
31747
+ positionTimer = null;
31748
+ stallTimer = null;
31749
+ socketPaused = false;
31750
+ streamEnded = false;
31751
+ streamEnd = null;
31752
+ duration = null;
31753
+ durationWaiters = [];
31754
+ lastAnnounced = null;
31755
+ scrubRefusedFor = void 0;
31756
+ pushed = 0;
31757
+ dropped = 0;
31758
+ bytesIn = 0;
31759
+ dialedAt = null;
31760
+ seekStartedAt = null;
31761
+ firstFrameLogged = false;
31762
+ maxBufferedBytes;
31763
+ stallTimeoutMs;
31764
+ now;
31765
+ tags;
31766
+ constructor(deps) {
31767
+ this.deps = deps;
31768
+ this.maxBufferedBytes = deps.maxBufferedBytes ?? CLIP_STREAM_BUFFER_MAX_BYTES;
31769
+ this.stallTimeoutMs = deps.stallTimeoutMs ?? 15e3;
31770
+ this.now = deps.now ?? (() => Date.now());
31771
+ this.tags = {
31772
+ deviceId: deps.handle.deviceId,
31773
+ clipId: deps.handle.clipId
31774
+ };
31775
+ const push = this.resolveAudioPush(deps);
31776
+ this.audioAsk = push === null ? "none" : deps.audio ?? "none";
31777
+ this.audioPush = this.audioAsk === "none" ? null : push;
31778
+ this.audioClockHz = this.audioAsk === "none" ? AUDIO_CLOCK_HZ.pcmu : AUDIO_CLOCK_HZ[this.audioAsk];
31779
+ this.demuxer = createFmp4StreamDemuxer(this.audioPush === null ? {} : { onAudio: (unit) => this.onAudioUnit(unit) });
31780
+ if (this.audioPush === null) this.settleAudio({
31781
+ carried: false,
31782
+ codec: null,
31783
+ reason: "not-negotiated"
31784
+ });
31785
+ }
31786
+ /**
31787
+ * The sink function for the negotiated codec, or `null`.
31788
+ *
31789
+ * Mirrors `RecordedFeeder.resolveAudioPush`: the sink is what decides, and
31790
+ * a session that offered a codec on the dial but wired no push for it would
31791
+ * otherwise make the provider transcode for nobody.
31792
+ */
31793
+ resolveAudioPush(deps) {
31794
+ const { sink, audio } = deps;
31795
+ if (audio === "opus") {
31796
+ const opus = sink.pushAudioOpus;
31797
+ return opus === void 0 ? null : (data, ts) => opus.call(sink, data, ts);
31798
+ }
31799
+ if (audio === "pcmu") {
31800
+ const pcmu = sink.pushAudioPcmu;
31801
+ return pcmu === void 0 ? null : (data, ts) => pcmu.call(sink, data, ts);
31802
+ }
31803
+ return null;
31804
+ }
31805
+ async seek(epochMs) {
31806
+ const token = ++this.seekToken;
31807
+ if (this.phase === "disposed" || this.phase === "cut" || this.phase === "refused") {
31808
+ this.log("clip-stream:seek-after-exit", {
31809
+ targetMs: epochMs,
31810
+ phase: this.phase
31811
+ });
31812
+ return;
31813
+ }
31814
+ if (this.phase === "ended") {
31815
+ this.log("clip-stream:seek-at-end", {
31816
+ targetMs: epochMs,
31817
+ headMs: this.headMs
31818
+ });
31819
+ if (this.headMs !== null) this.deps.onPosition(this.headMs);
31820
+ return;
31821
+ }
31822
+ if (this.headMs !== null && epochMs < this.headMs) {
31823
+ this.log("clip-stream:seek-backward-refused", {
31824
+ targetMs: epochMs,
31825
+ headMs: this.headMs,
31826
+ behindMs: this.headMs - epochMs
31827
+ });
31828
+ this.deps.onPosition(this.headMs);
31829
+ return;
31830
+ }
31831
+ this.seekStartedAt = this.now();
31832
+ this.stopPacing();
31833
+ this.pendingSeekMs = epochMs;
31834
+ if (this.phase === "idle") {
31835
+ await this.open(token);
31836
+ if (token !== this.seekToken) return;
31837
+ if (!this.isStreaming()) return;
31838
+ } else if (this.headMs !== null && epochMs > this.headMs && this.clockHasAdvanced) {
31839
+ this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
31840
+ this.emitState("loading", "seek-forward", token);
31841
+ this.log("clip-stream:seek-forward", {
31842
+ targetMs: epochMs,
31843
+ headMs: this.headMs,
31844
+ arrivedMs: this.arrivedMs,
31845
+ queued: this.queue.length
31846
+ });
31847
+ }
31848
+ this.resolvePendingSeek(token);
31849
+ }
31850
+ setRate(rate) {
31851
+ const next = clampPlaybackRate(rate);
31852
+ if (next === 0) {
31853
+ this.rate = 0;
31854
+ this.stopPacing();
31855
+ this.stopPositionReports();
31856
+ if (this.phase === "streaming" && this.pendingSeekMs === null) this.emitState("paused", "set-rate-zero", this.seekToken);
31857
+ return;
31858
+ }
31859
+ this.clock.setRate(next);
31860
+ const wasPaused = this.rate === 0;
31861
+ this.rate = next;
31862
+ if (this.phase !== "streaming" || this.pendingSeekMs !== null) return;
31863
+ if (wasPaused) this.emitState("playing", "set-rate-resume", this.seekToken);
31864
+ this.startPacing();
31865
+ }
31866
+ stepFrame(dir) {
31867
+ if (this.rate !== 0 || this.phase !== "streaming") return;
31868
+ if (dir === -1) {
31869
+ this.log("clip-stream:step-backward-refused", { headMs: this.headMs });
31870
+ return;
31871
+ }
31872
+ if (!this.pushNext()) {
31873
+ if (this.streamEnded) {
31874
+ this.log("clip-stream:step-at-end", { headMs: this.headMs });
31875
+ return;
31876
+ }
31877
+ this.pendingSteps += 1;
31878
+ }
31879
+ }
31880
+ beginScrub(dragId) {
31881
+ if (this.scrubRefusedFor === dragId) return;
31882
+ this.scrubRefusedFor = dragId;
31883
+ this.log("clip-stream:scrub-refused", {
31884
+ dragId,
31885
+ reason: "forward-only",
31886
+ headMs: this.headMs
31887
+ });
31888
+ }
31889
+ scrubTo() {}
31890
+ scrubAck() {}
31891
+ durationMs() {
31892
+ if (this.duration !== null) return Promise.resolve(this.duration.value);
31893
+ return new Promise((resolve) => this.durationWaiters.push(resolve));
31894
+ }
31895
+ /**
31896
+ * What this clip turned out to carry as sound — the answer the viewer needs
31897
+ * to say "this clip has no audio" rather than leave the operator wondering.
31898
+ *
31899
+ * Settles as soon as the stream's `moov` has been read, which is the first
31900
+ * fragment, so it lands beside `durationMs()`'s much later answer rather
31901
+ * than waiting for the stream to end.
31902
+ */
31903
+ audio() {
31904
+ const settled = this.audioReport;
31905
+ if (settled !== null) return Promise.resolve(settled);
31906
+ return new Promise((resolve) => this.audioWaiters.push(resolve));
31907
+ }
31908
+ dispose() {
31909
+ if (this.phase === "disposed") return;
31910
+ const before = this.phase;
31911
+ this.phase = "disposed";
31912
+ this.teardown();
31913
+ this.settleDuration(null);
31914
+ this.settleAudio({
31915
+ carried: false,
31916
+ codec: null,
31917
+ reason: "stream-failed"
31918
+ });
31919
+ this.log("clip-stream:disposed", {
31920
+ phaseBefore: before,
31921
+ pushed: this.pushed,
31922
+ dropped: this.dropped,
31923
+ queued: this.queue.length,
31924
+ bytesIn: this.bytesIn,
31925
+ headMs: this.headMs
31926
+ });
31927
+ this.queue = [];
31928
+ this.queuedBytes = 0;
31929
+ this.audioQueue = [];
31930
+ this.audioQueuedBytes = 0;
31931
+ }
31932
+ /** A method, not a field read: `open()` moves the phase behind an await
31933
+ * that control-flow analysis does not see through. */
31934
+ isStreaming() {
31935
+ return this.phase === "streaming";
31936
+ }
31937
+ async open(token) {
31938
+ this.phase = "opening";
31939
+ this.emitState("loading", "dial", token);
31940
+ this.dialedAt = this.now();
31941
+ let answer;
31942
+ try {
31943
+ answer = await this.deps.dial(this.deps.handle, this.audioAsk);
31944
+ } catch (err) {
31945
+ answer = {
31946
+ kind: "refused",
31947
+ code: "fetch-failed",
31948
+ detail: describe(err)
31949
+ };
31950
+ }
31951
+ if (this.phase !== "opening") return;
31952
+ if (answer.kind !== "dialed") {
31953
+ this.settleAudio({
31954
+ carried: false,
31955
+ codec: null,
31956
+ reason: "stream-failed"
31957
+ });
31958
+ this.refuse(answer.kind === "unavailable" ? "stream-unavailable" : answer.code, answer.detail, token, answer.kind === "unavailable" ? "the provider predates videoclips.dialClipStream — deploy provider-reolink, or the file path" : void 0);
31959
+ return;
31960
+ }
31961
+ if (this.deps.handle.profile === "high" && answer.served !== void 0 && answer.served !== "high") this.deps.onLog?.("clip stream: no main twin — streaming the sub copy for a `high` ask", {
31962
+ deviceId: this.deps.handle.deviceId,
31963
+ asked: "high",
31964
+ served: answer.served
31965
+ });
31966
+ if (this.audioAsk !== "none" && answer.servedAudio !== this.audioAsk) {
31967
+ this.settleAudio({
31968
+ carried: false,
31969
+ codec: null,
31970
+ reason: "not-offered",
31971
+ detail: answer.audioReason ?? answer.servedAudio ?? "the provider did not say"
31972
+ });
31973
+ this.log("clip-stream:audio-not-offered", {
31974
+ asked: this.audioAsk,
31975
+ servedAudio: answer.servedAudio ?? null,
31976
+ audioReason: answer.audioReason ?? null
31977
+ });
31978
+ }
31979
+ let opened;
31980
+ try {
31981
+ opened = await this.deps.open(answer.url, {
31982
+ onData: (chunk) => this.onData(chunk),
31983
+ onEnd: () => this.onEnd(),
31984
+ onError: (err) => this.cut("stream-failed", err.message)
31985
+ });
31986
+ } catch (err) {
31987
+ opened = {
31988
+ kind: "refused",
31989
+ status: 0,
31990
+ code: "fetch-failed",
31991
+ detail: describe(err)
31992
+ };
31993
+ }
31994
+ if (this.phase !== "opening") {
31995
+ if (opened.kind === "open") opened.connection.destroy();
31996
+ return;
31997
+ }
31998
+ if (opened.kind === "refused") {
31999
+ this.refuse(opened.code, opened.detail, token, void 0, opened.status);
32000
+ return;
32001
+ }
32002
+ this.connection = opened.connection;
32003
+ this.phase = "streaming";
32004
+ this.armStall();
32005
+ this.applyBackpressure();
32006
+ this.log("clip-stream:opened", {
32007
+ sinceDialMs: this.now() - (this.dialedAt ?? this.now()),
32008
+ expiresInMs: answer.expiresAtMs - Date.now()
32009
+ });
32010
+ }
32011
+ refuse(code, detail, token, hint, status) {
32012
+ this.phase = "refused";
32013
+ this.teardown();
32014
+ this.settleDuration(null);
32015
+ this.settleAudio({
32016
+ carried: false,
32017
+ codec: null,
32018
+ reason: "stream-failed"
32019
+ });
32020
+ this.log("clip-stream:refused", {
32021
+ code,
32022
+ detail,
32023
+ ...hint !== void 0 ? { hint } : {},
32024
+ ...status !== void 0 ? { status } : {}
32025
+ });
32026
+ this.emitState("error", `refused:${code}`, token);
32027
+ }
32028
+ /**
32029
+ * Bytes are taken while OPENING too. Node flushes body bytes it already
32030
+ * holds on `process.nextTick` the moment the `data` listener attaches,
32031
+ * and that tick runs BEFORE the promise continuation that flips this
32032
+ * feeder to `streaming` — so the head of the stream (ftyp, moov) arrives
32033
+ * while the phase still says `opening`. Dropping it there turned every
32034
+ * live stream into `no-moov` while a fake opener, which delivered only
32035
+ * after resolving, kept the suite green (2026-09-22).
32036
+ */
32037
+ takesBytes() {
32038
+ return this.phase === "streaming" || this.phase === "opening";
32039
+ }
32040
+ onData(chunk) {
32041
+ if (!this.takesBytes()) return;
32042
+ this.bytesIn += chunk.byteLength;
32043
+ this.armStall();
32044
+ let units;
32045
+ try {
32046
+ units = this.demuxer.push(chunk);
32047
+ } catch (err) {
32048
+ const reason = err instanceof Fmp4DemuxError ? err.reason : "malformed";
32049
+ this.cut("demux-refused", `${reason}: ${describe(err)}`);
32050
+ return;
32051
+ }
32052
+ for (const unit of units) {
32053
+ this.queue.push(unit);
32054
+ this.queuedBytes += unit.data.byteLength;
32055
+ this.arrivedMs = unit.ptsMs;
32056
+ }
32057
+ this.settleAudioFromMoov();
32058
+ if (units.length > 0 || this.audioQueue.length > 0) this.onUnitsArrived();
32059
+ this.applyBackpressure();
32060
+ }
32061
+ /**
32062
+ * What the stream really carries, decided from the `moov` and said ONCE.
32063
+ *
32064
+ * This is the answer a dial cannot give: the provider promises a codec
32065
+ * before the camera has delivered a single audio frame, and whether the
32066
+ * recording HAS sound is written in the container it produced. A track in a
32067
+ * codec this process cannot forward, or in one the session did not
32068
+ * negotiate, is named here and never re-encoded — a second transcode on
32069
+ * this hop is exactly the backlog this path is built to avoid.
32070
+ */
32071
+ settleAudioFromMoov() {
32072
+ if (this.audioReport !== null || this.audioPush === null) return;
32073
+ const state = this.demuxer.audio;
32074
+ if (state.kind === "unknown") return;
32075
+ if (state.kind === "absent") {
32076
+ this.settleAudio({
32077
+ carried: false,
32078
+ codec: null,
32079
+ reason: "no-audio-track"
32080
+ });
32081
+ this.log("clip-stream:audio-absent", { asked: this.audioAsk });
32082
+ return;
32083
+ }
32084
+ if (state.kind === "unsupported") {
32085
+ this.settleAudio({
32086
+ carried: false,
32087
+ codec: null,
32088
+ reason: "unsupported-codec"
32089
+ });
32090
+ this.log("clip-stream:audio-unsupported", {
32091
+ asked: this.audioAsk,
32092
+ sampleEntry: state.sampleEntry
32093
+ });
32094
+ return;
32095
+ }
32096
+ if (state.codec !== this.audioAsk) {
32097
+ this.settleAudio({
32098
+ carried: false,
32099
+ codec: null,
32100
+ reason: "codec-mismatch"
32101
+ });
32102
+ this.log("clip-stream:audio-codec-mismatch", {
32103
+ asked: this.audioAsk,
32104
+ got: state.codec
32105
+ });
32106
+ return;
32107
+ }
32108
+ this.audioFramer = createClipAudioFramer(state.codec);
32109
+ this.settleAudio({
32110
+ carried: true,
32111
+ codec: state.codec,
32112
+ reason: null
32113
+ });
32114
+ this.log("clip-stream:audio-ready", {
32115
+ codec: state.codec,
32116
+ sampleRateHz: state.sampleRateHz,
32117
+ channels: state.channels
32118
+ });
32119
+ }
32120
+ /** One demuxed audio sample, framed and queued on the same axis as video. */
32121
+ onAudioUnit(unit) {
32122
+ this.settleAudioFromMoov();
32123
+ const framer = this.audioFramer;
32124
+ if (framer === null) return;
32125
+ for (const frame of framer.push(unit)) {
32126
+ this.audioQueue.push(frame);
32127
+ this.audioQueuedBytes += frame.data.byteLength;
32128
+ }
32129
+ }
32130
+ onUnitsArrived() {
32131
+ if (this.pendingSeekMs !== null) {
32132
+ this.resolvePendingSeek(this.seekToken);
32133
+ return;
32134
+ }
32135
+ while (this.rate === 0 && this.pendingSteps > 0 && this.pushNext()) this.pendingSteps -= 1;
32136
+ if (this.rate > 0 && this.paceTimer === null) this.tick();
32137
+ }
32138
+ onEnd() {
32139
+ if (!this.takesBytes()) return;
32140
+ this.streamEnded = true;
32141
+ this.clearStall();
32142
+ this.streamEnd = this.demuxer.end();
32143
+ this.log("clip-stream:ended", {
32144
+ samples: this.streamEnd.samples,
32145
+ durationMs: this.streamEnd.durationMs,
32146
+ bytesIn: this.bytesIn,
32147
+ trailingBytes: this.streamEnd.trailingBytes,
32148
+ pendingSamples: this.streamEnd.pendingSamples,
32149
+ queued: this.queue.length
32150
+ });
32151
+ this.settleDuration(this.streamEnd.durationMs);
32152
+ if (this.pendingSeekMs !== null) {
32153
+ this.resolvePendingSeek(this.seekToken);
32154
+ return;
32155
+ }
32156
+ if (this.rate > 0 && this.paceTimer === null) this.tick();
32157
+ }
32158
+ cut(reason, detail) {
32159
+ if (this.phase !== "streaming" && this.phase !== "opening") return;
32160
+ this.phase = "cut";
32161
+ this.teardown();
32162
+ this.settleDuration(null);
32163
+ this.settleAudio({
32164
+ carried: false,
32165
+ codec: null,
32166
+ reason: "stream-failed"
32167
+ });
32168
+ this.log("clip-stream:cut", {
32169
+ reason,
32170
+ detail,
32171
+ pushed: this.pushed,
32172
+ queued: this.queue.length,
32173
+ bytesIn: this.bytesIn,
32174
+ headMs: this.headMs,
32175
+ arrivedMs: this.arrivedMs,
32176
+ ...this.audioTally()
32177
+ });
32178
+ this.emitState("error", `cut:${reason}`, this.seekToken);
32179
+ }
32180
+ armStall() {
32181
+ this.clearStall();
32182
+ this.stallTimer = setTimeout(() => this.cut("stream-stalled", `no bytes for ${this.stallTimeoutMs} ms`), this.stallTimeoutMs);
32183
+ }
32184
+ clearStall() {
32185
+ if (this.stallTimer !== null) clearTimeout(this.stallTimer);
32186
+ this.stallTimer = null;
32187
+ }
32188
+ /** D447 on the consuming hop: pause the socket above the bound, resume below half. */
32189
+ applyBackpressure() {
32190
+ if (this.connection === null) return;
32191
+ const held = this.queuedBytes + this.audioQueuedBytes + this.demuxer.pendingBytes;
32192
+ if (!this.socketPaused && held > this.maxBufferedBytes) {
32193
+ this.socketPaused = true;
32194
+ this.connection?.pause();
32195
+ this.log("clip-stream:backpressure-paused", {
32196
+ heldBytes: held,
32197
+ maxBytes: this.maxBufferedBytes
32198
+ });
32199
+ } else if (this.socketPaused && held < this.maxBufferedBytes / 2) {
32200
+ this.socketPaused = false;
32201
+ this.connection?.resume();
32202
+ this.log("clip-stream:backpressure-resumed", { heldBytes: held });
32203
+ }
32204
+ }
32205
+ /**
32206
+ * Land a pending seek if its bytes are here. Called on the seek itself,
32207
+ * on every arrival while one is pending, and on end-of-stream (the last
32208
+ * chance: a target past the end lands on the last frame and the clip ends).
32209
+ */
32210
+ resolvePendingSeek(token) {
32211
+ const target = this.pendingSeekMs;
32212
+ if (target === null || this.phase !== "streaming") return;
32213
+ let keep = 0;
32214
+ for (let i = 0; i < this.queue.length; i++) {
32215
+ const unit = this.queue[i];
32216
+ if (unit === void 0 || unit.ptsMs > target) break;
32217
+ if (unit.keyframe) keep = i;
32218
+ }
32219
+ if (keep > 0) {
32220
+ for (let i = 0; i < keep; i++) this.queuedBytes -= this.queue[i]?.data.byteLength ?? 0;
32221
+ this.dropped += keep;
32222
+ this.queue = this.queue.slice(keep);
32223
+ }
32224
+ if (!(this.arrivedMs !== null && this.arrivedMs >= target) && !this.streamEnded) return;
32225
+ if (this.queue.length === 0) {
32226
+ if (this.streamEnded) {
32227
+ this.pendingSeekMs = null;
32228
+ this.log("clip-stream:seek-past-end", {
32229
+ targetMs: target,
32230
+ headMs: this.headMs
32231
+ });
32232
+ this.finish();
32233
+ }
32234
+ return;
32235
+ }
32236
+ let burst = 0;
32237
+ while (this.queue.length > 0) {
32238
+ const next = this.queue[0];
32239
+ if (next === void 0 || burst > 0 && next.ptsMs > target) break;
32240
+ this.pushNext();
32241
+ burst += 1;
32242
+ }
32243
+ this.pendingSeekMs = null;
32244
+ this.dropAudioBefore(this.headMs);
32245
+ this.log("clip-stream:seek-landed", {
32246
+ targetMs: target,
32247
+ landedMs: this.headMs,
32248
+ droppedUnits: keep,
32249
+ burstUnits: burst,
32250
+ queued: this.queue.length,
32251
+ streamEnded: this.streamEnded
32252
+ });
32253
+ if (this.headMs !== null) this.deps.onPosition(this.headMs);
32254
+ if (this.rate === 0) {
32255
+ this.emitState("paused", "seek-landed-paused", token);
32256
+ return;
32257
+ }
32258
+ this.emitState("playing", "seek-landed", token);
32259
+ this.startPacing();
32260
+ }
32261
+ /**
32262
+ * The audio RTP timestamp for a source instant — off the SAME clock as the
32263
+ * video, converted to the negotiated rate.
32264
+ *
32265
+ * ONE mapping from source time to output time, not two. The recorded path
32266
+ * runs a second `MonotonicClock` for audio and keeps the pair aligned by
32267
+ * calling `markSeek()` on both, and that alignment is only as good as the
32268
+ * discipline: each clock re-bases on its OWN last output, so every
32269
+ * discontinuity leaves the two a frame further apart than it found them —
32270
+ * a skew that is invisible on a short clip, grows with each seek, and is
32271
+ * exactly the drift this path is not going to pay for twice. Deriving the
32272
+ * audio stamp from the video clock's own output makes the alignment
32273
+ * structural: there is nothing to keep in step.
32274
+ *
32275
+ * The conversion is exact to within a rounding tick (the video clock counts
32276
+ * 90 kHz, so an output instant is known to 1/90 ms) and is computed from
32277
+ * the source pts every time, never accumulated.
32278
+ */
32279
+ stampAudio(ptsMs) {
32280
+ return Math.round(this.clock.stamp(ptsMs) * this.audioClockHz / 9e4);
32281
+ }
32282
+ /** Forget sound the playhead has already passed — a forward seek's share. */
32283
+ dropAudioBefore(headMs) {
32284
+ if (headMs === null || this.audioQueue.length === 0) return;
32285
+ let drop = 0;
32286
+ while (drop < this.audioQueue.length) {
32287
+ const frame = this.audioQueue[drop];
32288
+ if (frame === void 0 || frame.ptsMs + frame.durationMs >= headMs) break;
32289
+ this.audioQueuedBytes -= frame.data.byteLength;
32290
+ drop += 1;
32291
+ }
32292
+ if (drop === 0) return;
32293
+ this.audioQueue = this.audioQueue.slice(drop);
32294
+ this.audioDroppedSeek += drop;
32295
+ }
32296
+ startPacing() {
32297
+ this.stopPacing();
32298
+ this.anchorWall = this.now();
32299
+ this.anchorPts = this.headMs ?? this.queue[0]?.ptsMs ?? 0;
32300
+ this.startPositionReports();
32301
+ this.tick();
32302
+ }
32303
+ stopPacing() {
32304
+ if (this.paceTimer !== null) clearTimeout(this.paceTimer);
32305
+ this.paceTimer = null;
32306
+ }
32307
+ /**
32308
+ * Push every unit that is due; sleep until the next; wait for data, or end.
32309
+ *
32310
+ * ONE schedule for both tracks, ordered by `ptsMs` with VIDEO first on a
32311
+ * tie — the same ordering `RecordedFeeder.paceSegment` builds, so a clip
32312
+ * and the recorder's own footage interleave identically. The two clocks
32313
+ * stay separate (each codec's RTP rate) but they stamp the SAME axis.
32314
+ */
32315
+ tick() {
32316
+ this.paceTimer = null;
32317
+ if (this.phase !== "streaming" || this.rate === 0 || this.pendingSeekMs !== null) return;
32318
+ for (;;) {
32319
+ const video = this.queue[0];
32320
+ const audio = this.audioQueue[0];
32321
+ if (video === void 0 && audio === void 0) {
32322
+ if (this.streamEnded) this.finish();
32323
+ return;
32324
+ }
32325
+ const takeVideo = video !== void 0 && (audio === void 0 || video.ptsMs <= audio.ptsMs);
32326
+ const ptsMs = takeVideo ? video?.ptsMs ?? 0 : audio?.ptsMs ?? 0;
32327
+ const wait = this.anchorWall + (ptsMs - this.anchorPts) / this.rate - this.now();
32328
+ if (takeVideo) {
32329
+ if (wait > 0) {
32330
+ this.paceTimer = setTimeout(() => this.tick(), Math.min(wait, WAIT_TICK_MS));
32331
+ return;
32332
+ }
32333
+ this.pushNext();
32334
+ continue;
32335
+ }
32336
+ if (wait > 0) {
32337
+ this.paceTimer = setTimeout(() => this.tick(), Math.min(wait, WAIT_TICK_MS));
32338
+ return;
32339
+ }
32340
+ this.pushNextAudio(-wait);
32341
+ }
32342
+ }
32343
+ /**
32344
+ * Push or DROP the head of the audio queue. False only when it was empty.
32345
+ *
32346
+ * `lateMs` is how far the frame's moment has already passed. Past the
32347
+ * bound it is dropped and counted: a receiver would discard it anyway, and
32348
+ * pushing a run of them is the burst that makes a clip sound broken rather
32349
+ * than silent. At any rate but 1x it is dropped for a different reason,
32350
+ * counted apart, because resampling would need a decoder this path has not
32351
+ * got and pitch-shifted sound is worse than none.
32352
+ */
32353
+ pushNextAudio(lateMs) {
32354
+ const frame = this.audioQueue[0];
32355
+ if (frame === void 0) return false;
32356
+ this.audioQueue = this.audioQueue.slice(1);
32357
+ this.audioQueuedBytes -= frame.data.byteLength;
32358
+ if (this.rate !== 1) {
32359
+ this.audioDroppedRate += 1;
32360
+ return true;
32361
+ }
32362
+ if (lateMs > 120) {
32363
+ this.audioDroppedLate += 1;
32364
+ return true;
32365
+ }
32366
+ this.audioPush?.(frame.data, this.stampAudio(frame.ptsMs));
32367
+ this.audioPushed += 1;
32368
+ if (!this.firstAudioLogged) {
32369
+ this.firstAudioLogged = true;
32370
+ this.log("clip-stream:first-audio", {
32371
+ ptsMs: frame.ptsMs,
32372
+ lateMs: Math.round(lateMs),
32373
+ headMs: this.headMs,
32374
+ droppedLate: this.audioDroppedLate
32375
+ });
32376
+ }
32377
+ this.applyBackpressure();
32378
+ return true;
32379
+ }
32380
+ /** Push the head of the queue onto the track. False when the queue is empty. */
32381
+ pushNext() {
32382
+ const unit = this.queue.shift();
32383
+ if (unit === void 0) return false;
32384
+ this.queuedBytes -= unit.data.byteLength;
32385
+ this.deps.sink.pushAccessUnit(unit.data, this.clock.stamp(unit.ptsMs), unit.codec);
32386
+ this.clockHasAdvanced = true;
32387
+ this.headMs = this.headMs === null ? unit.ptsMs : Math.max(this.headMs, unit.ptsMs);
32388
+ this.pushed += 1;
32389
+ if (!this.firstFrameLogged) {
32390
+ this.firstFrameLogged = true;
32391
+ const at = this.now();
32392
+ this.log("clip-stream:first-frame", {
32393
+ sinceSeekMs: this.seekStartedAt === null ? null : at - this.seekStartedAt,
32394
+ sinceDialMs: this.dialedAt === null ? null : at - this.dialedAt,
32395
+ bytesIn: this.bytesIn,
32396
+ ptsMs: unit.ptsMs,
32397
+ codec: unit.codec
32398
+ });
32399
+ }
32400
+ this.applyBackpressure();
32401
+ return true;
32402
+ }
32403
+ finish() {
32404
+ if (this.phase !== "streaming") return;
32405
+ this.phase = "ended";
32406
+ this.settleAudio({
32407
+ carried: false,
32408
+ codec: null,
32409
+ reason: this.demuxer.ready ? "no-audio-track" : "stream-failed"
32410
+ });
32411
+ this.stopPacing();
32412
+ this.stopPositionReports();
32413
+ if (this.headMs !== null) this.deps.onPosition(this.headMs);
32414
+ this.log("clip-stream:played-out", {
32415
+ pushed: this.pushed,
32416
+ dropped: this.dropped,
32417
+ headMs: this.headMs,
32418
+ durationMs: this.streamEnd?.durationMs ?? null,
32419
+ ...this.audioTally()
32420
+ });
32421
+ this.emitState("ended", "stream-played-out", this.seekToken);
32422
+ }
32423
+ startPositionReports() {
32424
+ if (this.positionTimer !== null) return;
32425
+ this.positionTimer = setInterval(() => {
32426
+ if (this.headMs !== null && this.phase === "streaming") this.deps.onPosition(this.headMs);
32427
+ }, POSITION_INTERVAL_MS);
32428
+ }
32429
+ stopPositionReports() {
32430
+ if (this.positionTimer !== null) clearInterval(this.positionTimer);
32431
+ this.positionTimer = null;
32432
+ }
32433
+ emitState(to, reason, seekToken) {
32434
+ const from = this.lastAnnounced;
32435
+ this.lastAnnounced = to;
32436
+ this.deps.onState({
32437
+ from,
32438
+ to,
32439
+ reason,
32440
+ seekToken
32441
+ });
32442
+ }
32443
+ /** The audio counters every exit line carries. Silence is never unexplained. */
32444
+ audioTally() {
32445
+ return {
32446
+ audioAsked: this.audioAsk,
32447
+ audioCarried: this.audioReport?.carried ?? false,
32448
+ audioSilentReason: this.audioReport?.reason ?? null,
32449
+ audioPushed: this.audioPushed,
32450
+ audioDroppedLate: this.audioDroppedLate,
32451
+ audioDroppedRate: this.audioDroppedRate,
32452
+ audioDroppedSeek: this.audioDroppedSeek,
32453
+ audioQueued: this.audioQueue.length
32454
+ };
32455
+ }
32456
+ settleAudio(report) {
32457
+ if (this.audioReport !== null) return;
32458
+ this.audioReport = report;
32459
+ const waiters = this.audioWaiters.splice(0);
32460
+ for (const resolve of waiters) resolve(report);
32461
+ }
32462
+ settleDuration(value) {
32463
+ if (this.duration !== null) return;
32464
+ this.duration = {
32465
+ settled: true,
32466
+ value
32467
+ };
32468
+ const waiters = this.durationWaiters.splice(0);
32469
+ for (const resolve of waiters) resolve(value);
32470
+ }
32471
+ teardown() {
32472
+ this.stopPacing();
32473
+ this.stopPositionReports();
32474
+ this.clearStall();
32475
+ const connection = this.connection;
32476
+ this.connection = null;
32477
+ connection?.destroy();
32478
+ }
32479
+ log(msg, data) {
32480
+ this.deps.onLog?.(msg, {
32481
+ ...this.tags,
32482
+ ...data
32483
+ });
32484
+ }
32485
+ };
32486
+ function describe(err) {
32487
+ return err instanceof Error ? err.message : String(err);
32488
+ }
32489
+ function headerText(res, name) {
32490
+ const value = res.headers[name];
32491
+ if (Array.isArray(value)) return value[0];
32492
+ return value;
32493
+ }
32494
+ function createHttpClipStreamOpener(options = {}) {
32495
+ const headersTimeoutMs = options.headersTimeoutMs ?? 3e4;
32496
+ return (url, handlers) => new Promise((resolve) => {
32497
+ let settled = false;
32498
+ const settle = (value) => {
32499
+ if (settled) return;
32500
+ settled = true;
32501
+ clearTimeout(timer);
32502
+ resolve(value);
32503
+ };
32504
+ let target;
32505
+ try {
32506
+ target = new URL(url);
32507
+ } catch (err) {
32508
+ settle({
32509
+ kind: "refused",
32510
+ status: 0,
32511
+ code: "bad-url",
32512
+ detail: String(err)
32513
+ });
32514
+ return;
32515
+ }
32516
+ if (target.protocol !== "http:") {
32517
+ settle({
32518
+ kind: "refused",
32519
+ status: 0,
32520
+ code: "bad-url",
32521
+ detail: `a clip stream is dialled over plain http on the provider's listener, not ${target.protocol}`
32522
+ });
32523
+ return;
32524
+ }
32525
+ const req = request(target, {
32526
+ method: "GET",
32527
+ headers: { accept: "video/mp4" }
32528
+ });
32529
+ const timer = setTimeout(() => {
32530
+ req.destroy(/* @__PURE__ */ new Error(`no status line in ${headersTimeoutMs} ms`));
32531
+ }, headersTimeoutMs);
32532
+ req.on("error", (err) => {
32533
+ settle({
32534
+ kind: "refused",
32535
+ status: 0,
32536
+ code: "connect-failed",
32537
+ detail: err.message
32538
+ });
32539
+ });
32540
+ req.on("response", (res) => {
32541
+ const status = res.statusCode ?? 0;
32542
+ if (status !== 200) {
32543
+ res.resume();
32544
+ settle({
32545
+ kind: "refused",
32546
+ status,
32547
+ code: headerText(res, "x-camstack-reason-code") ?? `http-${status}`,
32548
+ detail: headerText(res, "x-camstack-reason") ?? ""
32549
+ });
32550
+ return;
32551
+ }
32552
+ if (headerText(res, "x-camstack-clip-transport") !== "forward-only") {
32553
+ req.destroy();
32554
+ settle({
32555
+ kind: "refused",
32556
+ status,
32557
+ code: "not-a-stream",
32558
+ detail: "the URL answered 200 without declaring the forward-only clip transport"
32559
+ });
32560
+ return;
32561
+ }
32562
+ res.on("data", (chunk) => handlers.onData(chunk));
32563
+ res.on("end", () => handlers.onEnd());
32564
+ res.on("error", (err) => handlers.onError(err));
32565
+ settle({
32566
+ kind: "open",
32567
+ connection: {
32568
+ pause: () => {
32569
+ res.pause();
32570
+ },
32571
+ resume: () => {
32572
+ res.resume();
32573
+ },
32574
+ destroy: () => {
32575
+ req.destroy();
32576
+ }
32577
+ }
32578
+ });
32579
+ });
32580
+ req.end();
32581
+ });
32582
+ }
32583
+ //#endregion
30589
32584
  //#region src/stream-broker/recorded/demux-off-thread.ts
30590
32585
  /**
30591
32586
  * Off-thread JS demux client — mp4box parse on a `worker_threads` Worker
@@ -31332,6 +33327,7 @@ var BrokerWebrtcServer = class {
31332
33327
  readRecordedSegmentBytes;
31333
33328
  readRecordedGopBytes;
31334
33329
  readClipBytes;
33330
+ dialClipStream;
31335
33331
  resolveFfmpegPath;
31336
33332
  /**
31337
33333
  * THE ffmpeg primitive, for the transcode leg. `null` until the manager wires
@@ -31425,6 +33421,7 @@ var BrokerWebrtcServer = class {
31425
33421
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
31426
33422
  this.readRecordedGopBytes = options.readRecordedGopBytes;
31427
33423
  this.readClipBytes = options.readClipBytes;
33424
+ this.dialClipStream = options.dialClipStream;
31428
33425
  this.resolveFfmpegPath = options.resolveFfmpegPath;
31429
33426
  }
31430
33427
  registerBroker(brokerId, broker) {
@@ -32145,14 +34142,93 @@ var BrokerWebrtcServer = class {
32145
34142
  };
32146
34143
  } : void 0;
32147
34144
  const readClip = this.readClipBytes;
34145
+ const dialClip = this.dialClipStream;
34146
+ const openClipStream = createHttpClipStreamOpener();
32148
34147
  let timeline;
32149
34148
  /**
32150
34149
  * ONE feeder builder for BOTH sources (D575). `clip === null` is the
32151
34150
  * recorder's own footage; a clip swaps only the two byte closures —
32152
34151
  * everything else (demux, sink, audio codec branch, state/position/log
32153
34152
  * seams) is identical, which is the whole claim the clip path makes.
34153
+ *
34154
+ * Since D597 a clip has TWO paths, and which one it gets is decided
34155
+ * ONCE, here, by `chooseClipPath`, and logged with its reason: the
34156
+ * STREAM for the sub twin (forward-only, first frame at the first
34157
+ * fragment) whenever this broker can dial one, the FILE for `high` and
34158
+ * for a broker with no dial. Never an implicit fallthrough.
32154
34159
  */
32155
34160
  const buildFeeder = (profile, clip) => {
34161
+ if (clip) {
34162
+ const choice = chooseClipPath({
34163
+ profile: clip.profile,
34164
+ streamDialWired: dialClip !== void 0,
34165
+ fileReadWired: readClip !== void 0
34166
+ });
34167
+ sessionLogger.info("clip playback: path chosen", {
34168
+ tags: { deviceId: clip.deviceId },
34169
+ meta: {
34170
+ sessionId,
34171
+ clipId: clip.clipId,
34172
+ provider: clip.provider,
34173
+ profile: clip.profile,
34174
+ path: choice.path,
34175
+ reason: choice.reason
34176
+ }
34177
+ });
34178
+ const clipLog = (msg, data) => sessionLogger.info(msg, {
34179
+ tags: { deviceId: clip.deviceId },
34180
+ meta: {
34181
+ sessionId,
34182
+ clipId: clip.clipId,
34183
+ ...data
34184
+ }
34185
+ });
34186
+ const onClipState = (t) => {
34187
+ sessionLogger.info("recorded: feeder state", {
34188
+ tags: { deviceId: clip.deviceId },
34189
+ meta: {
34190
+ sessionId,
34191
+ deviceId,
34192
+ profile,
34193
+ clipId: clip.clipId,
34194
+ path: choice.path,
34195
+ from: t.from,
34196
+ to: t.to,
34197
+ reason: t.reason,
34198
+ seekToken: t.seekToken
34199
+ }
34200
+ });
34201
+ timeline._emitState(t.to);
34202
+ };
34203
+ if (choice.path === "none") return createRefusedClipFeeder({
34204
+ handle: clip,
34205
+ reason: choice.reason,
34206
+ onState: onClipState,
34207
+ onLog: clipLog
34208
+ });
34209
+ if (choice.path === "stream" && dialClip !== void 0) return createClipStreamFeeder({
34210
+ handle: clip,
34211
+ dial: (handle, audio) => dialClip({
34212
+ deviceId: handle.deviceId,
34213
+ clipId: handle.clipId,
34214
+ provider: handle.provider,
34215
+ profile: handle.profile,
34216
+ audio
34217
+ }),
34218
+ open: openClipStream,
34219
+ audio: session.isOpusAudio ? "opus" : "pcmu",
34220
+ sink: session.isOpusAudio ? {
34221
+ pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
34222
+ pushAudioOpus: (opus, ts) => session.writeRecordedAudioOpus(Buffer.from(opus), ts)
34223
+ } : {
34224
+ pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
34225
+ pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
34226
+ },
34227
+ onState: onClipState,
34228
+ onPosition: (epochMs) => timeline._emitPosition(epochMs),
34229
+ onLog: clipLog
34230
+ });
34231
+ }
32156
34232
  const opusSession = session.isOpusAudio;
32157
34233
  const audioSink = opusSession ? {
32158
34234
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
@@ -32266,7 +34342,7 @@ var BrokerWebrtcServer = class {
32266
34342
  });
32267
34343
  } : void 0,
32268
34344
  makeFeeder: (profile) => buildFeeder(profile, null),
32269
- ...readClip ? { makeClipFeeder: ({ handle }) => buildFeeder(handle.profile ?? "clip", handle) } : {},
34345
+ ...readClip || dialClip ? { makeClipFeeder: ({ handle }) => buildFeeder(handle.profile ?? "clip", handle) } : {},
32270
34346
  onLog: (msg, data) => sessionLogger.warn(msg, {
32271
34347
  tags: { deviceId },
32272
34348
  meta: {
@@ -33311,6 +35387,51 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
33311
35387
  }
33312
35388
  };
33313
35389
  //#endregion
35390
+ //#region src/stream-broker/recorded/clip-stream-dial-answer.ts
35391
+ /**
35392
+ * clip-stream-dial-answer — what a THROWN `videoclips.dialClipStream` means.
35393
+ *
35394
+ * The cap call has three ways to fail, and the feeder must tell them apart
35395
+ * because they lead to different lines and different states:
35396
+ *
35397
+ * - the provider PREDATES the method (`NOT_IMPLEMENTED`, "does not
35398
+ * implement") — the stream does not exist on this cluster yet, which is a
35399
+ * deploy-order fact, not a clip fact;
35400
+ * - the provider REFUSED in the clip vocabulary (`clipExportFailure`):
35401
+ * `sleeping`, `catalog-miss`, `clip-in-progress`, `unsupported-option`… —
35402
+ * the code is kept, the prose travels with it;
35403
+ * - anything else — the transport, a bug — is `fetch-failed` with the prose
35404
+ * verbatim, exactly as the export engine treats an unknown error (D572).
35405
+ */
35406
+ function trpcCodeOf(err) {
35407
+ if (typeof err !== "object" || err === null) return null;
35408
+ const shaped = err;
35409
+ if (typeof shaped.data?.code === "string") return shaped.data.code;
35410
+ if (typeof shaped.code === "string") return shaped.code;
35411
+ return null;
35412
+ }
35413
+ function dialAnswerFromError(err) {
35414
+ const message = err instanceof Error ? err.message : String(err);
35415
+ if (trpcCodeOf(err) === "NOT_IMPLEMENTED" || /does not implement/i.test(message)) return {
35416
+ kind: "unavailable",
35417
+ detail: message
35418
+ };
35419
+ const code = clipExportFailureOf(message);
35420
+ if (code !== null) {
35421
+ const sep = message.indexOf(":");
35422
+ return {
35423
+ kind: "refused",
35424
+ code,
35425
+ detail: sep === -1 ? "" : message.slice(sep + 1).trim()
35426
+ };
35427
+ }
35428
+ return {
35429
+ kind: "refused",
35430
+ code: "fetch-failed",
35431
+ detail: message
35432
+ };
35433
+ }
35434
+ //#endregion
33314
35435
  //#region src/stream-broker/event-loop-stall-monitor.ts
33315
35436
  var DEFAULT_INTERVAL_MS = 100;
33316
35437
  var DEFAULT_THRESHOLD_MS = 250;
@@ -33785,6 +35906,27 @@ var StreamBrokerAddon = class extends BaseAddon {
33785
35906
  });
33786
35907
  return new Uint8Array(Buffer.from(res.base64, "base64"));
33787
35908
  },
35909
+ dialClipStream: async (args) => {
35910
+ try {
35911
+ const dial = await this.ctx.api.videoclips.dialClipStream.query({
35912
+ deviceId: args.deviceId,
35913
+ clipId: args.clipId,
35914
+ provider: args.provider,
35915
+ ...args.profile !== null ? { profile: args.profile } : {},
35916
+ audio: args.audio
35917
+ });
35918
+ return {
35919
+ kind: "dialed",
35920
+ url: dial.url,
35921
+ expiresAtMs: dial.expiresAtMs,
35922
+ ...typeof dial.served === "string" ? { served: dial.served } : {},
35923
+ ...typeof dial.servedAudio === "string" ? { servedAudio: dial.servedAudio } : {},
35924
+ ...typeof dial.audioReason === "string" ? { audioReason: dial.audioReason } : {}
35925
+ };
35926
+ } catch (err) {
35927
+ return dialAnswerFromError(err);
35928
+ }
35929
+ },
33788
35930
  readRecordedGopBytes: async (args) => {
33789
35931
  try {
33790
35932
  return await this.ctx.api.recording.readGopBytes.query({