@camstack/addon-pipeline 1.2.283 → 1.2.285

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-CKdfniKn.js → default-detection-model-B9sgmjNE.js} +1 -1
  4. package/dist/{default-detection-model-i-0An9uD.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-BVWT6DUX.mjs → dist-L6fspiMs.mjs} +1479 -3
  8. package/dist/{dist-CB1sRktk.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-DZw6MNVd.js → node-BAHXRXps.js} +1 -1
  12. package/dist/{node-Dj1cgkd_.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-Cgxk_eAO.js → process-memory-B5Enn1QL.js} +1 -1
  16. package/dist/{process-memory-C0OIYwit.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-DNN3T56h.js → segment-demux-js-BCxiqxMJ.js} +166 -148
  20. package/dist/{segment-demux-js-B6OsKCIg.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-C1dCLSas.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-BNFyaEr_.mjs → hostInit-BMoj1Wh2.mjs} +2 -2
  28. package/dist/stream-broker/index.js +2141 -37
  29. package/dist/stream-broker/index.mjs +2141 -37
  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-BV2tQ1w3.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
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-CB1sRktk.js");
7
- const require_node = require("../node-DZw6MNVd.js");
6
+ const require_dist = require("../dist-bAVvh6zw.js");
7
+ const require_node = require("../node-BAHXRXps.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-DNN3T56h.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-BCxiqxMJ.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -28,6 +28,7 @@ let node_dgram = require("node:dgram");
28
28
  let events = require("events");
29
29
  let net = require("net");
30
30
  let node_dns_promises = require("node:dns/promises");
31
+ let node_http = require("node:http");
31
32
  let node_worker_threads = require("node:worker_threads");
32
33
  let node_events = require("node:events");
33
34
  //#region src/stream-broker/client-log-ingest.ts
@@ -26685,6 +26686,26 @@ var ServerMessageSchema = require_dist.discriminatedUnion("t", [
26685
26686
  "low"
26686
26687
  ]),
26687
26688
  requested: require_dist.string()
26689
+ }),
26690
+ require_dist.object({
26691
+ t: require_dist.literal("clipDuration"),
26692
+ clipId: require_dist.string().min(1),
26693
+ durationMs: require_dist.number().positive()
26694
+ }),
26695
+ require_dist.object({
26696
+ t: require_dist.literal("clipAudio"),
26697
+ clipId: require_dist.string().min(1),
26698
+ carried: require_dist.boolean(),
26699
+ codec: require_dist._enum(["opus", "pcmu"]).optional(),
26700
+ /** Why there is no sound. Present iff `carried` is false. */
26701
+ reason: require_dist._enum([
26702
+ "not-negotiated",
26703
+ "not-offered",
26704
+ "no-audio-track",
26705
+ "unsupported-codec",
26706
+ "codec-mismatch",
26707
+ "stream-failed"
26708
+ ]).optional()
26688
26709
  })
26689
26710
  ]);
26690
26711
  function parseControlMessage(raw) {
@@ -27262,7 +27283,7 @@ var SEGMENT_TIMEOUT_MS = 8e3;
27262
27283
  * 2 ms, p95 202 ms, max 285 ms), so a segment that CAN yield audio still does.
27263
27284
  */
27264
27285
  var AUDIO_DEMUX_TIMEOUT_MS = 1500;
27265
- var POSITION_INTERVAL_MS = 1e3;
27286
+ var POSITION_INTERVAL_MS$1 = 1e3;
27266
27287
  /** How often `recorded:play-census` fires while the advance loop is alive. */
27267
27288
  var PLAY_CENSUS_INTERVAL_TICKS = 5;
27268
27289
  /**
@@ -27284,7 +27305,7 @@ var PTS_REPAIR_LOG_MEMORY = 64;
27284
27305
  * PTS gap (or an empty/tail wait) still re-checks the abort guards promptly,
27285
27306
  * keeping seeks responsive without per-frame drift.
27286
27307
  */
27287
- var WAIT_TICK_MS = 250;
27308
+ var WAIT_TICK_MS$1 = 250;
27288
27309
  /**
27289
27310
  * Output-clock gap stamped at every seek/scrub commit (markDiscontinuity on
27290
27311
  * both A/V clocks). Root cause of the "smeared frame on scrub": the aborted
@@ -27297,7 +27318,7 @@ var WAIT_TICK_MS = 250;
27297
27318
  * can't look contiguous with the new GOP, and the receiver resets timing
27298
27319
  * instead of merging the two feeds.
27299
27320
  */
27300
- var SEEK_DISCONTINUITY_MS = 250;
27321
+ var SEEK_DISCONTINUITY_MS$1 = 250;
27301
27322
  /**
27302
27323
  * Coalescing window for feeder-scrub `scrubTo` (experimental). Pointer-move
27303
27324
  * targets arrive far faster than a segment demux completes; the feeder collapses
@@ -27883,8 +27904,8 @@ var RecordedFeeder = class {
27883
27904
  try {
27884
27905
  this.emitState("loading", "seek-start", token, dragId);
27885
27906
  if (this.clockHasAdvanced) {
27886
- this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
27887
- this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
27907
+ this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
27908
+ this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
27888
27909
  }
27889
27910
  const locateStartedAt = performance.now();
27890
27911
  let loc = await this.withTimeout(this.deps.locate({
@@ -28759,12 +28780,12 @@ var RecordedFeeder = class {
28759
28780
  return;
28760
28781
  }
28761
28782
  for (const s of jumpTravel) {
28762
- if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
28783
+ if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
28763
28784
  this.deps.sink.pushAccessUnit(s.au.data, this.clock.stamp(s.au.ptsMs), s.au.codec);
28764
28785
  this.clockHasAdvanced = true;
28765
28786
  this.deps.onPosition(this.clampIntoSegment(s.seg, s.tMs));
28766
28787
  }
28767
- if ((mode === "jump" || rewind) && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
28788
+ if ((mode === "jump" || rewind) && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
28768
28789
  else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
28769
28790
  for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
28770
28791
  const pushedAtMs = performance.now();
@@ -29188,7 +29209,7 @@ var RecordedFeeder = class {
29188
29209
  if (still === "deadline") break;
29189
29210
  if (still !== null && still.seg.startMs !== lastFragStartMs && !fragmentCovers(still.seg, toMs) && !fragmentCovers(still.seg, fromMs)) {
29190
29211
  lastFragStartMs = still.seg.startMs;
29191
- if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
29212
+ if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
29192
29213
  this.deps.sink.pushAccessUnit(still.au.data, this.clock.stamp(still.au.ptsMs), still.au.codec);
29193
29214
  this.clockHasAdvanced = true;
29194
29215
  this.deps.onPosition(this.clampIntoSegment(still.seg, tMs));
@@ -29635,7 +29656,7 @@ var RecordedFeeder = class {
29635
29656
  if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
29636
29657
  const remaining = targetWall - performance.now();
29637
29658
  if (remaining <= 0) return false;
29638
- const tick = Math.min(remaining, WAIT_TICK_MS);
29659
+ const tick = Math.min(remaining, WAIT_TICK_MS$1);
29639
29660
  await new Promise((resolve) => setTimeout(resolve, tick));
29640
29661
  }
29641
29662
  }
@@ -29760,7 +29781,7 @@ var RecordedFeeder = class {
29760
29781
  if (this.rate <= 0) return;
29761
29782
  this.positionTicks += 1;
29762
29783
  if (this.positionTicks % PLAY_CENSUS_INTERVAL_TICKS === 0) this.emitPlayCensus("tick");
29763
- }, POSITION_INTERVAL_MS);
29784
+ }, POSITION_INTERVAL_MS$1);
29764
29785
  }
29765
29786
  /** Tear down the ~1 Hz position interval so it stops reporting a stale cursor. */
29766
29787
  stopPositionReports() {
@@ -30054,7 +30075,124 @@ var TimelineSession = class {
30054
30075
  profile
30055
30076
  };
30056
30077
  this.lastPositionMs = null;
30057
- await this.enterRecorded().seek(offsetMs);
30078
+ const feeder = this.enterRecorded();
30079
+ await feeder.seek(offsetMs);
30080
+ await Promise.all([this.reportClipDuration(clipId, feeder), this.reportClipAudio(clipId, feeder)]);
30081
+ }
30082
+ /**
30083
+ * Tell the viewer whether this clip has sound.
30084
+ *
30085
+ * Silence has three causes the operator cannot tell apart by listening —
30086
+ * a camera with no microphone, a transport that carried no audio, and a
30087
+ * session that negotiated none — so the viewer is told which. Every branch
30088
+ * that sends nothing says so (D391): a feeder that cannot answer, an answer
30089
+ * that rejected, and an answer that landed after the pointer had left the
30090
+ * clip, which must not be pinned on whatever came next.
30091
+ */
30092
+ async reportClipAudio(clipId, feeder) {
30093
+ if (feeder.audio === void 0) {
30094
+ this.deps.onLog?.("clip playback: audio not reported — this feeder cannot say", {
30095
+ deviceId: this.deps.deviceId,
30096
+ clipId
30097
+ });
30098
+ return;
30099
+ }
30100
+ let report;
30101
+ try {
30102
+ report = await feeder.audio();
30103
+ } catch (err) {
30104
+ this.deps.onLog?.("clip playback: audio not reported — the answer failed", {
30105
+ deviceId: this.deps.deviceId,
30106
+ clipId,
30107
+ error: err instanceof Error ? err.message : String(err)
30108
+ });
30109
+ return;
30110
+ }
30111
+ if (this.clip?.clipId !== clipId) {
30112
+ this.deps.onLog?.("clip playback: audio dropped — the pointer has left this clip", {
30113
+ deviceId: this.deps.deviceId,
30114
+ clipId,
30115
+ carried: report.carried
30116
+ });
30117
+ return;
30118
+ }
30119
+ this.deps.onLog?.("clip playback: audio reported", {
30120
+ deviceId: this.deps.deviceId,
30121
+ clipId,
30122
+ carried: report.carried,
30123
+ codec: report.codec,
30124
+ reason: report.reason,
30125
+ detail: report.detail
30126
+ });
30127
+ this.deps.session.sendControl(serializeServerMessage({
30128
+ t: "clipAudio",
30129
+ clipId,
30130
+ carried: report.carried,
30131
+ ...report.codec !== null ? { codec: report.codec } : {},
30132
+ ...report.reason !== null ? { reason: report.reason } : {}
30133
+ }));
30134
+ }
30135
+ /**
30136
+ * Tell the viewer how long the file on the pointer REALLY is.
30137
+ *
30138
+ * The clip source has measured this from the MP4 index since D575 and no
30139
+ * one asked: the viewer's only other length is the camera's catalog row,
30140
+ * which freezes while a file is still growing. Asked AFTER the first seek,
30141
+ * because that seek is what loads and indexes the file — the cheap question
30142
+ * has already been answered by then and this costs no second read.
30143
+ *
30144
+ * Every branch that sends nothing says so (D391): a feeder with no measure,
30145
+ * a measure that rejected, and a measure that landed after the pointer had
30146
+ * already left this clip — that last one must not be pinned on whatever
30147
+ * came next.
30148
+ */
30149
+ async reportClipDuration(clipId, feeder) {
30150
+ if (feeder.durationMs === void 0) {
30151
+ this.deps.onLog?.("clip playback: duration not reported — this feeder cannot measure it", {
30152
+ deviceId: this.deps.deviceId,
30153
+ clipId
30154
+ });
30155
+ return;
30156
+ }
30157
+ let durationMs;
30158
+ try {
30159
+ durationMs = await feeder.durationMs();
30160
+ } catch (err) {
30161
+ this.deps.onLog?.("clip playback: duration not reported — the measure failed", {
30162
+ deviceId: this.deps.deviceId,
30163
+ clipId,
30164
+ error: err instanceof Error ? err.message : String(err)
30165
+ });
30166
+ return;
30167
+ }
30168
+ if (this.clip?.clipId !== clipId) {
30169
+ this.deps.onLog?.("clip playback: duration dropped — the pointer has left this clip", {
30170
+ deviceId: this.deps.deviceId,
30171
+ clipId,
30172
+ durationMs
30173
+ });
30174
+ return;
30175
+ }
30176
+ if (durationMs === null) {
30177
+ this.deps.onLog?.("clip playback: duration unknown — the source ended without a length", {
30178
+ deviceId: this.deps.deviceId,
30179
+ clipId
30180
+ });
30181
+ return;
30182
+ }
30183
+ if (!(durationMs > 0)) {
30184
+ this.deps.onLog?.("clip playback: duration not reported — the measure is not positive", {
30185
+ deviceId: this.deps.deviceId,
30186
+ clipId,
30187
+ durationMs
30188
+ });
30189
+ return;
30190
+ }
30191
+ this.deps.session.sendControl(serializeServerMessage({
30192
+ t: "clipDuration",
30193
+ clipId,
30194
+ durationMs
30195
+ }));
30058
30196
  }
30059
30197
  /** Drop the clip pointer. Every path back to the recorder's own footage — a
30060
30198
  * `playRecorded` and `goLive` — goes through here, so there is exactly one
@@ -30240,7 +30378,7 @@ var LARGE_SIZE_HEADER_LENGTH = 16;
30240
30378
  * structurally impossible (a size that does not fit), which the caller turns
30241
30379
  * into `malformed` — never a throw, because these bytes come off a camera.
30242
30380
  */
30243
- function childBoxes(bytes, start, end) {
30381
+ function childBoxes$1(bytes, start, end) {
30244
30382
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30245
30383
  const out = [];
30246
30384
  let off = start;
@@ -30270,7 +30408,7 @@ function childBoxes(bytes, start, end) {
30270
30408
  }
30271
30409
  return out;
30272
30410
  }
30273
- function findBox(boxes, type) {
30411
+ function findBox$1(boxes, type) {
30274
30412
  for (const b of boxes) if (b.type === type) return b;
30275
30413
  return null;
30276
30414
  }
@@ -30300,7 +30438,7 @@ function readSyncSampleNumbers(bytes, box) {
30300
30438
  for (let i = 0; i < entries; i++) out.add(view.getUint32(base + 4 + i * 4));
30301
30439
  return out;
30302
30440
  }
30303
- function mediaTimescale(bytes, mdhd) {
30441
+ function mediaTimescale$1(bytes, mdhd) {
30304
30442
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30305
30443
  const version = bytes[mdhd.contentStart] ?? 0;
30306
30444
  const at = mdhd.contentStart + 4 + (version === 1 ? 16 : 8);
@@ -30308,7 +30446,7 @@ function mediaTimescale(bytes, mdhd) {
30308
30446
  const ts = view.getUint32(at);
30309
30447
  return ts > 0 ? ts : null;
30310
30448
  }
30311
- function handlerType(bytes, hdlr) {
30449
+ function handlerType$1(bytes, hdlr) {
30312
30450
  const at = hdlr.contentStart + 8;
30313
30451
  if (at + FOURCC_LENGTH > hdlr.end) return "";
30314
30452
  let out = "";
@@ -30317,16 +30455,16 @@ function handlerType(bytes, hdlr) {
30317
30455
  }
30318
30456
  /** The video `trak` of a `moov`, or `null` when it has none. */
30319
30457
  function videoTrack(bytes, moov) {
30320
- const children = childBoxes(bytes, moov.contentStart, moov.end);
30458
+ const children = childBoxes$1(bytes, moov.contentStart, moov.end);
30321
30459
  if (!children) return null;
30322
30460
  for (const trak of children) {
30323
30461
  if (trak.type !== "trak") continue;
30324
- const trakKids = childBoxes(bytes, trak.contentStart, trak.end);
30325
- const mdia = trakKids ? findBox(trakKids, "mdia") : null;
30462
+ const trakKids = childBoxes$1(bytes, trak.contentStart, trak.end);
30463
+ const mdia = trakKids ? findBox$1(trakKids, "mdia") : null;
30326
30464
  if (!mdia) continue;
30327
- const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
30328
- const hdlr = mdiaKids ? findBox(mdiaKids, "hdlr") : null;
30329
- if (hdlr && handlerType(bytes, hdlr) === "vide") return mdia;
30465
+ const mdiaKids = childBoxes$1(bytes, mdia.contentStart, mdia.end);
30466
+ const hdlr = mdiaKids ? findBox$1(mdiaKids, "hdlr") : null;
30467
+ if (hdlr && handlerType$1(bytes, hdlr) === "vide") return mdia;
30330
30468
  }
30331
30469
  return null;
30332
30470
  }
@@ -30335,12 +30473,12 @@ function videoTrack(bytes, moov) {
30335
30473
  * comes back as a named refusal so the caller can say WHY a clip will not play.
30336
30474
  */
30337
30475
  function indexClipMp4(bytes) {
30338
- const top = childBoxes(bytes, 0, bytes.length);
30476
+ const top = childBoxes$1(bytes, 0, bytes.length);
30339
30477
  if (!top) return {
30340
30478
  kind: "refused",
30341
30479
  reason: "malformed"
30342
30480
  };
30343
- const moov = findBox(top, "moov");
30481
+ const moov = findBox$1(top, "moov");
30344
30482
  if (!moov) return {
30345
30483
  kind: "refused",
30346
30484
  reason: "no-moov"
@@ -30350,34 +30488,34 @@ function indexClipMp4(bytes) {
30350
30488
  kind: "refused",
30351
30489
  reason: "no-video-track"
30352
30490
  };
30353
- const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
30491
+ const mdiaKids = childBoxes$1(bytes, mdia.contentStart, mdia.end);
30354
30492
  if (!mdiaKids) return {
30355
30493
  kind: "refused",
30356
30494
  reason: "malformed"
30357
30495
  };
30358
- const mdhd = findBox(mdiaKids, "mdhd");
30359
- const minf = findBox(mdiaKids, "minf");
30496
+ const mdhd = findBox$1(mdiaKids, "mdhd");
30497
+ const minf = findBox$1(mdiaKids, "minf");
30360
30498
  if (!mdhd || !minf) return {
30361
30499
  kind: "refused",
30362
30500
  reason: "malformed"
30363
30501
  };
30364
- const timescale = mediaTimescale(bytes, mdhd);
30502
+ const timescale = mediaTimescale$1(bytes, mdhd);
30365
30503
  if (timescale === null) return {
30366
30504
  kind: "refused",
30367
30505
  reason: "malformed"
30368
30506
  };
30369
- const minfKids = childBoxes(bytes, minf.contentStart, minf.end);
30370
- const stbl = minfKids ? findBox(minfKids, "stbl") : null;
30507
+ const minfKids = childBoxes$1(bytes, minf.contentStart, minf.end);
30508
+ const stbl = minfKids ? findBox$1(minfKids, "stbl") : null;
30371
30509
  if (!stbl) return {
30372
30510
  kind: "refused",
30373
30511
  reason: "no-samples"
30374
30512
  };
30375
- const stblKids = childBoxes(bytes, stbl.contentStart, stbl.end);
30513
+ const stblKids = childBoxes$1(bytes, stbl.contentStart, stbl.end);
30376
30514
  if (!stblKids) return {
30377
30515
  kind: "refused",
30378
30516
  reason: "malformed"
30379
30517
  };
30380
- const sttsBox = findBox(stblKids, "stts");
30518
+ const sttsBox = findBox$1(stblKids, "stts");
30381
30519
  if (!sttsBox) return {
30382
30520
  kind: "refused",
30383
30521
  reason: "no-samples"
@@ -30387,7 +30525,7 @@ function indexClipMp4(bytes) {
30387
30525
  kind: "refused",
30388
30526
  reason: "malformed"
30389
30527
  };
30390
- const stssBox = findBox(stblKids, "stss");
30528
+ const stssBox = findBox$1(stblKids, "stss");
30391
30529
  const syncNumbers = stssBox === null ? null : readSyncSampleNumbers(bytes, stssBox);
30392
30530
  if (stssBox !== null && syncNumbers === null) return {
30393
30531
  kind: "refused",
@@ -30435,6 +30573,13 @@ var ClipSourceError = class extends Error {
30435
30573
  this.name = "ClipSourceError";
30436
30574
  }
30437
30575
  };
30576
+ /**
30577
+ * 64 MB. Above `VIDEOCLIPS_MAX_READ_BYTES` (50 MB) on purpose: the cap's own
30578
+ * bound refuses first and by name, and this one exists so a provider that ever
30579
+ * answers larger cannot pin a broker runner's heap. A `high` twin measured
30580
+ * 20–35 MB (D570 § 9.6).
30581
+ */
30582
+ var CLIP_SOURCE_MAX_BYTES = 64 * 1024 * 1024;
30438
30583
  function createClipFileSource(deps) {
30439
30584
  const maxBytes = deps.maxBytes ?? 67108864;
30440
30585
  const { deviceId, clipId, provider, profile } = deps.handle;
@@ -30528,6 +30673,1817 @@ function createClipFileSource(deps) {
30528
30673
  };
30529
30674
  }
30530
30675
  //#endregion
30676
+ //#region src/stream-broker/recorded/clip-path-choice.ts
30677
+ function chooseClipPath(input) {
30678
+ if (input.streamDialWired) return {
30679
+ path: "stream",
30680
+ reason: "stream-serves-every-profile"
30681
+ };
30682
+ if (input.fileReadWired) return {
30683
+ path: "file",
30684
+ reason: "no-stream-dial-wired"
30685
+ };
30686
+ return {
30687
+ path: "none",
30688
+ reason: "nothing-wired"
30689
+ };
30690
+ }
30691
+ /**
30692
+ * The feeder a `none` choice gets: every verb is a named refusal and the
30693
+ * first seek reports `error`, so a clip nothing can serve is never a frozen
30694
+ * frame with no line behind it (D391).
30695
+ */
30696
+ function createRefusedClipFeeder(deps) {
30697
+ const data = {
30698
+ deviceId: deps.handle.deviceId,
30699
+ clipId: deps.handle.clipId,
30700
+ reason: deps.reason
30701
+ };
30702
+ let announced = false;
30703
+ const refuse = (verb) => {
30704
+ deps.onLog?.("clip playback: refused — no path can serve this clip", {
30705
+ ...data,
30706
+ verb
30707
+ });
30708
+ if (announced) return;
30709
+ announced = true;
30710
+ deps.onState({
30711
+ from: null,
30712
+ to: "error",
30713
+ reason: `refused:${deps.reason}`,
30714
+ seekToken: 0
30715
+ });
30716
+ };
30717
+ return {
30718
+ seek: async () => refuse("seek"),
30719
+ setRate: () => {},
30720
+ stepFrame: () => refuse("stepFrame"),
30721
+ beginScrub: () => refuse("beginScrub"),
30722
+ scrubTo: () => {},
30723
+ scrubAck: () => {},
30724
+ durationMs: () => Promise.resolve(null),
30725
+ dispose: () => {}
30726
+ };
30727
+ }
30728
+ var PCMU_RATE_HZ = 8e3;
30729
+ function createPassthroughFramer() {
30730
+ return {
30731
+ push: (unit) => unit.data.byteLength === 0 ? [] : [{
30732
+ data: unit.data,
30733
+ ptsMs: unit.ptsMs,
30734
+ durationMs: unit.durationMs
30735
+ }],
30736
+ carriedBytes: 0,
30737
+ reanchors: 0
30738
+ };
30739
+ }
30740
+ var PcmuFramer = class {
30741
+ carry = new Uint8Array(0);
30742
+ /** `ptsMs` of the first byte currently held in {@link carry}. */
30743
+ anchorMs = null;
30744
+ /** Bytes emitted plus carried since {@link anchorMs} — the exact clock. */
30745
+ bytesSinceAnchor = 0;
30746
+ reanchorCount = 0;
30747
+ get carriedBytes() {
30748
+ return this.carry.byteLength;
30749
+ }
30750
+ get reanchors() {
30751
+ return this.reanchorCount;
30752
+ }
30753
+ push(unit) {
30754
+ if (unit.data.byteLength === 0) return [];
30755
+ const expectedMs = this.anchorMs === null ? null : this.anchorMs + this.bytesSinceAnchor * 1e3 / PCMU_RATE_HZ;
30756
+ if (expectedMs === null || Math.abs(expectedMs - unit.ptsMs) > 20) {
30757
+ if (expectedMs !== null) this.reanchorCount += 1;
30758
+ this.carry = new Uint8Array(0);
30759
+ this.anchorMs = unit.ptsMs;
30760
+ this.bytesSinceAnchor = 0;
30761
+ }
30762
+ const anchorMs = this.anchorMs;
30763
+ if (anchorMs === null) return [];
30764
+ const joined = new Uint8Array(this.carry.byteLength + unit.data.byteLength);
30765
+ joined.set(this.carry, 0);
30766
+ joined.set(unit.data, this.carry.byteLength);
30767
+ const out = [];
30768
+ let at = 0;
30769
+ while (at + 160 <= joined.byteLength) {
30770
+ out.push({
30771
+ data: joined.slice(at, at + 160),
30772
+ ptsMs: anchorMs + this.bytesSinceAnchor * 1e3 / PCMU_RATE_HZ,
30773
+ durationMs: 20
30774
+ });
30775
+ this.bytesSinceAnchor += 160;
30776
+ at += 160;
30777
+ }
30778
+ this.carry = joined.slice(at);
30779
+ return out;
30780
+ }
30781
+ };
30782
+ /** The framer for a codec: a pass-through for Opus, a re-cut for µ-law. */
30783
+ function createClipAudioFramer(codec) {
30784
+ return codec === "opus" ? createPassthroughFramer() : new PcmuFramer();
30785
+ }
30786
+ //#endregion
30787
+ //#region src/stream-broker/recorded/fmp4-stream-demux.ts
30788
+ /**
30789
+ * fmp4-stream-demux — an INCREMENTAL demux of a fragmented MP4 as it arrives.
30790
+ *
30791
+ * The clip STREAM (`/clip-stream/…`, D597) is an fMP4 written by ffmpeg with
30792
+ * `frag_keyframe+empty_moov+default_base_moof` while it is still reading the
30793
+ * camera: a `moov` with an EMPTY sample table, then one `moof`+`mdat` per
30794
+ * GOP, and no trailer until the end. Nothing in this directory can read that
30795
+ * shape as it flows — `clip-mp4-index.ts` wants `stbl` and refuses it
30796
+ * `no-samples` by design, `segment-demux-js.ts` (mp4box) wants a WHOLE
30797
+ * fragment in hand — so this module walks the boxes itself, byte by byte, and
30798
+ * emits every video sample the moment its bytes are in.
30799
+ *
30800
+ * ## What it emits and when
30801
+ *
30802
+ * {@link AccessUnit}s, in DECODE order, `ptsMs` anchored at 0 on the first
30803
+ * sample's composition time — the same contract `demuxSegmentFullJs` keeps,
30804
+ * so a streamed clip and a stored one push the same shape. A sample is
30805
+ * emitted as soon as ITS bytes have arrived, not when its `mdat` closes: a
30806
+ * GOP is a second or two of video, and a feeder that waited for the box would
30807
+ * hand the viewer that second late for nothing.
30808
+ *
30809
+ * Sync samples are fused with the parameter sets (`avcc-annexb.ts`), delta
30810
+ * samples are converted plain; the codec and the `avcC`/`hvcC` come from the
30811
+ * `moov`. ONE video track is followed, and — since D600 — ONE audio track
30812
+ * beside it; every other track's bytes in an `mdat` are skipped, never
30813
+ * emitted.
30814
+ *
30815
+ * ## The audio track, and the one clock both tracks are on
30816
+ *
30817
+ * A clip stream carries sound already transcoded to the session's codec
30818
+ * (D600): Opus, whose mp4 sample IS the RTP payload, or µ-law in a QuickTime
30819
+ * `ulaw` entry. Audio samples are handed to {@link Fmp4StreamDemuxOptions.onAudio}
30820
+ * as they land, interleaved with the video in FILE-OFFSET order — which is
30821
+ * the order the bytes arrive, and the only order a forward-only window can
30822
+ * drain in.
30823
+ *
30824
+ * **Both tracks are anchored on ONE origin**, the first sample of either to
30825
+ * be emitted, and every `ptsMs` is measured from it in exact integer
30826
+ * arithmetic over the two timescales. Anchoring each track on its own first
30827
+ * sample would zero out the offset BETWEEN them — which is the whole content
30828
+ * of lip sync — and would do it invisibly, in a way that only shows up as
30829
+ * drift on a long clip.
30830
+ *
30831
+ * An audio track whose sample entry is neither Opus nor µ-law is REPORTED
30832
+ * ({@link Fmp4StreamDemuxer.audio}) and not followed. It is never a refusal:
30833
+ * a codec this process cannot forward is a reason to play the clip silent,
30834
+ * never a reason to lose the video.
30835
+ *
30836
+ * ## What it refuses, by name
30837
+ *
30838
+ * A `moof` before any `moov` (`no-moov`), a `moov` with no video track
30839
+ * (`no-video-track`), a codec that is neither AVC nor HEVC
30840
+ * (`unsupported-codec`), and a box or a sample that cannot fit where it says
30841
+ * it is (`malformed`). Each is thrown ONCE as {@link Fmp4DemuxError}; the
30842
+ * demuxer is dead after it and says so on every further push. The bytes come
30843
+ * off a camera through two processes — a refusal here is a fact about the
30844
+ * stream, never a crash.
30845
+ *
30846
+ * ## Duration
30847
+ *
30848
+ * Unknown until the stream ENDS. `end()` answers the sum of the sample
30849
+ * durations, in ms, or `null` when no sample was ever emitted — never `0`
30850
+ * (D393). The caller learns the length when the camera has finished, which
30851
+ * is the truth about a forward-only source.
30852
+ */
30853
+ var Fmp4DemuxError = class extends Error {
30854
+ reason;
30855
+ constructor(reason, message) {
30856
+ super(message);
30857
+ this.reason = reason;
30858
+ this.name = "Fmp4DemuxError";
30859
+ }
30860
+ };
30861
+ var BOX_HEADER = 8;
30862
+ var LARGE_BOX_HEADER = 16;
30863
+ var FULLBOX = 4;
30864
+ /** `sample_is_non_sync_sample`, ISO/IEC 14496-12 § 8.8.3.1. */
30865
+ var FLAG_NON_SYNC = 65536;
30866
+ var TFHD_BASE_DATA_OFFSET = 1;
30867
+ var TFHD_SAMPLE_DESC_INDEX = 2;
30868
+ var TFHD_DEFAULT_DURATION = 8;
30869
+ var TFHD_DEFAULT_SIZE = 16;
30870
+ var TFHD_DEFAULT_FLAGS = 32;
30871
+ var TFHD_DEFAULT_BASE_IS_MOOF = 131072;
30872
+ var TRUN_DATA_OFFSET = 1;
30873
+ var TRUN_FIRST_SAMPLE_FLAGS = 4;
30874
+ var TRUN_SAMPLE_DURATION = 256;
30875
+ var TRUN_SAMPLE_SIZE = 512;
30876
+ var TRUN_SAMPLE_FLAGS = 1024;
30877
+ var TRUN_SAMPLE_CTS = 2048;
30878
+ /** VisualSampleEntry fields between the sample-entry header and its children. */
30879
+ var VISUAL_SAMPLE_ENTRY_FIELDS = 78;
30880
+ /**
30881
+ * AudioSampleEntry fixed fields, ISO/IEC 14496-12 § 12.2.3: the 8-byte
30882
+ * `SampleEntry` (6 reserved + `data_reference_index`), then version, revision,
30883
+ * vendor, `channelcount`, `samplesize`, `compression_id`, `packet_size` and a
30884
+ * 16.16 `samplerate`.
30885
+ */
30886
+ var AUDIO_SAMPLE_ENTRY_FIELDS = 28;
30887
+ var AUDIO_ENTRY_CHANNELS_AT = 16;
30888
+ var AUDIO_ENTRY_SAMPLE_RATE_AT = 24;
30889
+ /** `cts` in `timescale` ticks, as milliseconds from `origin`. Exact. */
30890
+ function ptsMsFrom(origin, cts, timescale) {
30891
+ if (timescale === origin.timescale) return Math.round((cts - origin.ticks) * 1e3 / timescale);
30892
+ const numerator = (cts * origin.timescale - origin.ticks * timescale) * 1e3;
30893
+ return Math.round(numerator / (timescale * origin.timescale));
30894
+ }
30895
+ function fourcc(bytes, at) {
30896
+ let out = "";
30897
+ for (let i = 0; i < 4; i++) out += String.fromCharCode(bytes[at + i] ?? 0);
30898
+ return out;
30899
+ }
30900
+ function u32(view, at) {
30901
+ return view.getUint32(at);
30902
+ }
30903
+ function u64(view, at) {
30904
+ return view.getUint32(at) * 4294967296 + view.getUint32(at + 4);
30905
+ }
30906
+ /** The direct children of `[start, end)`, or `null` when a size does not fit. */
30907
+ function childBoxes(bytes, start, end) {
30908
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30909
+ const out = [];
30910
+ let off = start;
30911
+ while (off + BOX_HEADER <= end) {
30912
+ const declared = u32(view, off);
30913
+ const type = fourcc(bytes, off + 4);
30914
+ let size = declared;
30915
+ let contentStart = off + BOX_HEADER;
30916
+ if (declared === 1) {
30917
+ if (off + LARGE_BOX_HEADER > end) return null;
30918
+ size = u64(view, off + 8);
30919
+ contentStart = off + LARGE_BOX_HEADER;
30920
+ } else if (declared === 0) size = end - off;
30921
+ if (size < contentStart - off) return null;
30922
+ const boxEnd = off + size;
30923
+ if (boxEnd > end) return null;
30924
+ out.push({
30925
+ type,
30926
+ start: off,
30927
+ end: boxEnd,
30928
+ contentStart
30929
+ });
30930
+ off = boxEnd;
30931
+ }
30932
+ return out;
30933
+ }
30934
+ function findBox(boxes, type) {
30935
+ if (boxes === null) return null;
30936
+ for (const b of boxes) if (b.type === type) return b;
30937
+ return null;
30938
+ }
30939
+ function handlerType(bytes, hdlr) {
30940
+ const at = hdlr.contentStart + FULLBOX + 4;
30941
+ return at + 4 <= hdlr.end ? fourcc(bytes, at) : "";
30942
+ }
30943
+ function trackId(bytes, tkhd) {
30944
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30945
+ const version = bytes[tkhd.contentStart] ?? 0;
30946
+ const at = tkhd.contentStart + FULLBOX + (version === 1 ? 16 : 8);
30947
+ return at + 4 <= tkhd.end ? u32(view, at) : null;
30948
+ }
30949
+ function mediaTimescale(bytes, mdhd) {
30950
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30951
+ const version = bytes[mdhd.contentStart] ?? 0;
30952
+ const at = mdhd.contentStart + FULLBOX + (version === 1 ? 16 : 8);
30953
+ if (at + 4 > mdhd.end) return null;
30954
+ const ts = u32(view, at);
30955
+ return ts > 0 ? ts : null;
30956
+ }
30957
+ /** The decoder config out of `stsd`, header stripped, with the codec it names. */
30958
+ function sampleDescription(bytes, stsd) {
30959
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30960
+ const countAt = stsd.contentStart + FULLBOX;
30961
+ if (countAt + 4 > stsd.end) return null;
30962
+ const count = u32(view, countAt);
30963
+ const entries = childBoxes(bytes, countAt + 4, stsd.end);
30964
+ if (entries === null || count === 0 || entries.length === 0) return null;
30965
+ for (const entry of entries) {
30966
+ const isAvc = entry.type === "avc1" || entry.type === "avc3";
30967
+ const isHevc = entry.type === "hvc1" || entry.type === "hev1";
30968
+ if (!isAvc && !isHevc) continue;
30969
+ const config = findBox(childBoxes(bytes, entry.contentStart + VISUAL_SAMPLE_ENTRY_FIELDS, entry.end), isAvc ? "avcC" : "hvcC");
30970
+ if (config === null) return null;
30971
+ return {
30972
+ codec: isAvc ? "H264" : "H265",
30973
+ description: bytes.subarray(config.contentStart, config.end)
30974
+ };
30975
+ }
30976
+ return "unsupported";
30977
+ }
30978
+ function audioSampleDescription(bytes, stsd) {
30979
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30980
+ const countAt = stsd.contentStart + FULLBOX;
30981
+ if (countAt + 4 > stsd.end) return null;
30982
+ const entry = childBoxes(bytes, countAt + 4, stsd.end)?.[0];
30983
+ if (entry === void 0) return null;
30984
+ if (entry.contentStart + AUDIO_SAMPLE_ENTRY_FIELDS > entry.end) return null;
30985
+ const codec = entry.type === "Opus" ? "opus" : entry.type === "ulaw" ? "pcmu" : null;
30986
+ if (codec === null) return { unsupported: entry.type };
30987
+ return {
30988
+ codec,
30989
+ channels: view.getUint16(entry.contentStart + AUDIO_ENTRY_CHANNELS_AT),
30990
+ sampleRateHz: view.getUint16(entry.contentStart + AUDIO_ENTRY_SAMPLE_RATE_AT)
30991
+ };
30992
+ }
30993
+ function readTrex(bytes, moov, id) {
30994
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30995
+ const mvex = findBox(childBoxes(bytes, moov.contentStart, moov.end), "mvex");
30996
+ const none = {
30997
+ duration: null,
30998
+ size: null,
30999
+ flags: null
31000
+ };
31001
+ if (mvex === null) return none;
31002
+ for (const trex of childBoxes(bytes, mvex.contentStart, mvex.end) ?? []) {
31003
+ if (trex.type !== "trex" || trex.contentStart + FULLBOX + 20 > trex.end) continue;
31004
+ const at = trex.contentStart + FULLBOX;
31005
+ if (u32(view, at) !== id) continue;
31006
+ return {
31007
+ duration: u32(view, at + 8),
31008
+ size: u32(view, at + 12),
31009
+ flags: u32(view, at + 16)
31010
+ };
31011
+ }
31012
+ return none;
31013
+ }
31014
+ /** The `trak`s of a `moov` whose handler is `want`, already unpacked. */
31015
+ function traksOfHandler(bytes, moov, want) {
31016
+ const kids = childBoxes(bytes, moov.contentStart, moov.end);
31017
+ if (kids === null) return "malformed";
31018
+ const out = [];
31019
+ for (const trak of kids) {
31020
+ if (trak.type !== "trak") continue;
31021
+ const trakKids = childBoxes(bytes, trak.contentStart, trak.end);
31022
+ const mdia = findBox(trakKids, "mdia");
31023
+ const tkhd = findBox(trakKids, "tkhd");
31024
+ if (mdia === null || tkhd === null) continue;
31025
+ const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
31026
+ const hdlr = findBox(mdiaKids, "hdlr");
31027
+ if (hdlr === null || handlerType(bytes, hdlr) !== want) continue;
31028
+ const id = trackId(bytes, tkhd);
31029
+ const mdhd = findBox(mdiaKids, "mdhd");
31030
+ const minf = findBox(mdiaKids, "minf");
31031
+ if (id === null || mdhd === null || minf === null) return "malformed";
31032
+ const timescale = mediaTimescale(bytes, mdhd);
31033
+ const stbl = findBox(childBoxes(bytes, minf.contentStart, minf.end), "stbl");
31034
+ const stsd = findBox(stbl ? childBoxes(bytes, stbl.contentStart, stbl.end) : null, "stsd");
31035
+ if (timescale === null || stsd === null) return "malformed";
31036
+ out.push({
31037
+ id,
31038
+ timescale,
31039
+ stsd
31040
+ });
31041
+ }
31042
+ return out;
31043
+ }
31044
+ /** The one video track of a `moov`, or the refusal that stands in for it. */
31045
+ function readVideoTrack(bytes, moov) {
31046
+ const traks = traksOfHandler(bytes, moov, "vide");
31047
+ if (traks === "malformed") return "malformed";
31048
+ for (const trak of traks) {
31049
+ const desc = sampleDescription(bytes, trak.stsd);
31050
+ if (desc === null) return "malformed";
31051
+ if (desc === "unsupported") return "unsupported-codec";
31052
+ let nalLengthSize;
31053
+ try {
31054
+ nalLengthSize = require_segment_demux_js.parseNalLengthSize(desc.description, desc.codec === "H265");
31055
+ } catch {
31056
+ return "malformed";
31057
+ }
31058
+ return {
31059
+ id: trak.id,
31060
+ timescale: trak.timescale,
31061
+ codec: desc.codec,
31062
+ description: desc.description,
31063
+ nalLengthSize,
31064
+ trex: readTrex(bytes, moov, trak.id)
31065
+ };
31066
+ }
31067
+ return "no-video-track";
31068
+ }
31069
+ /**
31070
+ * The one audio track of a `moov`, or what to REPORT instead of one.
31071
+ *
31072
+ * Never a refusal: sound this process cannot forward is a silent clip, and a
31073
+ * silent clip is a clip. The only thing that must not happen is silence
31074
+ * nobody can account for, which is what {@link Fmp4AudioState} is for.
31075
+ */
31076
+ function readAudioTrack$1(bytes, moov) {
31077
+ const traks = traksOfHandler(bytes, moov, "soun");
31078
+ if (traks === "malformed") return { kind: "absent" };
31079
+ for (const trak of traks) {
31080
+ const desc = audioSampleDescription(bytes, trak.stsd);
31081
+ if (desc === null) return {
31082
+ kind: "unsupported",
31083
+ sampleEntry: ""
31084
+ };
31085
+ if ("unsupported" in desc) return {
31086
+ kind: "unsupported",
31087
+ sampleEntry: desc.unsupported
31088
+ };
31089
+ return {
31090
+ id: trak.id,
31091
+ timescale: trak.timescale,
31092
+ codec: desc.codec,
31093
+ sampleRateHz: desc.sampleRateHz,
31094
+ channels: desc.channels,
31095
+ trex: readTrex(bytes, moov, trak.id)
31096
+ };
31097
+ }
31098
+ return { kind: "absent" };
31099
+ }
31100
+ function readTfhd(view, tfhd) {
31101
+ const flags = u32(view, tfhd.contentStart) & 16777215;
31102
+ let at = tfhd.contentStart + FULLBOX;
31103
+ if (at + 4 > tfhd.end) return null;
31104
+ const id = u32(view, at);
31105
+ at += 4;
31106
+ let baseDataOffset = null;
31107
+ if (flags & TFHD_BASE_DATA_OFFSET) {
31108
+ if (at + 8 > tfhd.end) return null;
31109
+ baseDataOffset = u64(view, at);
31110
+ at += 8;
31111
+ }
31112
+ if (flags & TFHD_SAMPLE_DESC_INDEX) at += 4;
31113
+ let duration = null;
31114
+ let size = null;
31115
+ let sampleFlags = null;
31116
+ if (flags & TFHD_DEFAULT_DURATION) {
31117
+ if (at + 4 > tfhd.end) return null;
31118
+ duration = u32(view, at);
31119
+ at += 4;
31120
+ }
31121
+ if (flags & TFHD_DEFAULT_SIZE) {
31122
+ if (at + 4 > tfhd.end) return null;
31123
+ size = u32(view, at);
31124
+ at += 4;
31125
+ }
31126
+ if (flags & TFHD_DEFAULT_FLAGS) {
31127
+ if (at + 4 > tfhd.end) return null;
31128
+ sampleFlags = u32(view, at);
31129
+ }
31130
+ return {
31131
+ trackId: id,
31132
+ baseDataOffset,
31133
+ defaultBaseIsMoof: (flags & TFHD_DEFAULT_BASE_IS_MOOF) !== 0,
31134
+ defaults: {
31135
+ duration,
31136
+ size,
31137
+ flags: sampleFlags
31138
+ }
31139
+ };
31140
+ }
31141
+ function readTfdt(view, bytes, tfdt) {
31142
+ const version = bytes[tfdt.contentStart] ?? 0;
31143
+ const at = tfdt.contentStart + FULLBOX;
31144
+ if (version === 1) return at + 8 <= tfdt.end ? u64(view, at) : null;
31145
+ return at + 4 <= tfdt.end ? u32(view, at) : null;
31146
+ }
31147
+ /** The samples one `trun` announces, or `null` when it does not fit. */
31148
+ function readTrun(args) {
31149
+ const { view, trun, base, trex, traf } = args;
31150
+ const version = view.getUint8(trun.contentStart);
31151
+ const flags = u32(view, trun.contentStart) & 16777215;
31152
+ let at = trun.contentStart + FULLBOX;
31153
+ if (at + 4 > trun.end) return null;
31154
+ const count = u32(view, at);
31155
+ at += 4;
31156
+ let dataOffset = 0;
31157
+ if (flags & TRUN_DATA_OFFSET) {
31158
+ if (at + 4 > trun.end) return null;
31159
+ dataOffset = view.getInt32(at);
31160
+ at += 4;
31161
+ }
31162
+ let firstSampleFlags = null;
31163
+ if (flags & TRUN_FIRST_SAMPLE_FLAGS) {
31164
+ if (at + 4 > trun.end) return null;
31165
+ firstSampleFlags = u32(view, at);
31166
+ at += 4;
31167
+ }
31168
+ const out = [];
31169
+ let offset = base + dataOffset;
31170
+ let dts = args.startDts;
31171
+ for (let i = 0; i < count; i++) {
31172
+ let duration = traf.defaults.duration ?? trex.duration;
31173
+ let size = traf.defaults.size ?? trex.size;
31174
+ let sampleFlags = traf.defaults.flags ?? trex.flags;
31175
+ let ctsOffset = 0;
31176
+ if (flags & TRUN_SAMPLE_DURATION) {
31177
+ if (at + 4 > trun.end) return null;
31178
+ duration = u32(view, at);
31179
+ at += 4;
31180
+ }
31181
+ if (flags & TRUN_SAMPLE_SIZE) {
31182
+ if (at + 4 > trun.end) return null;
31183
+ size = u32(view, at);
31184
+ at += 4;
31185
+ }
31186
+ if (flags & TRUN_SAMPLE_FLAGS) {
31187
+ if (at + 4 > trun.end) return null;
31188
+ sampleFlags = u32(view, at);
31189
+ at += 4;
31190
+ } else if (i === 0 && firstSampleFlags !== null) sampleFlags = firstSampleFlags;
31191
+ if (flags & TRUN_SAMPLE_CTS) {
31192
+ if (at + 4 > trun.end) return null;
31193
+ ctsOffset = version === 0 ? u32(view, at) : view.getInt32(at);
31194
+ at += 4;
31195
+ }
31196
+ if (duration === null || size === null) return null;
31197
+ const sync = sampleFlags === null ? i === 0 : (sampleFlags & FLAG_NON_SYNC) === 0;
31198
+ out.push({
31199
+ offset,
31200
+ size,
31201
+ dts,
31202
+ cts: dts + ctsOffset,
31203
+ duration,
31204
+ sync
31205
+ });
31206
+ offset += size;
31207
+ dts += duration;
31208
+ }
31209
+ return out;
31210
+ }
31211
+ /**
31212
+ * Every sample a `moof` announces for the followed tracks, in FILE-OFFSET
31213
+ * order, or `null`.
31214
+ *
31215
+ * Offset order, not decode order across tracks: the window is forward-only
31216
+ * and drains what has arrived, so two tracks whose runs are laid out one
31217
+ * after another inside one `mdat` — which is how ffmpeg writes them — must
31218
+ * be drained in the order their bytes land. Within one track the offsets are
31219
+ * increasing anyway, so each track's own decode order is preserved.
31220
+ */
31221
+ function readMoof(bytes, moof, tracks) {
31222
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31223
+ const kids = childBoxes(bytes, moof.contentStart, moof.end);
31224
+ if (kids === null) return null;
31225
+ const out = [];
31226
+ for (const traf of kids) {
31227
+ if (traf.type !== "traf") continue;
31228
+ const trafKids = childBoxes(bytes, traf.contentStart, traf.end);
31229
+ const tfhdBox = findBox(trafKids, "tfhd");
31230
+ if (tfhdBox === null) return null;
31231
+ const tfhd = readTfhd(view, tfhdBox);
31232
+ if (tfhd === null) return null;
31233
+ const kind = tfhd.trackId === tracks.video.id ? "video" : tracks.audio !== null && tfhd.trackId === tracks.audio.id ? "audio" : null;
31234
+ if (kind === null) continue;
31235
+ const trex = kind === "video" ? tracks.video.trex : tracks.audio?.trex ?? null;
31236
+ if (trex === null) continue;
31237
+ const tfdtBox = findBox(trafKids, "tfdt");
31238
+ const startDts = tfdtBox === null ? 0 : readTfdt(view, bytes, tfdtBox);
31239
+ if (startDts === null) return null;
31240
+ const base = tfhd.baseDataOffset ?? moof.start;
31241
+ let dts = startDts;
31242
+ for (const trun of trafKids ?? []) {
31243
+ if (trun.type !== "trun") continue;
31244
+ const samples = readTrun({
31245
+ view,
31246
+ trun,
31247
+ base,
31248
+ startDts: dts,
31249
+ trex,
31250
+ traf: tfhd
31251
+ });
31252
+ if (samples === null) return null;
31253
+ for (const sample of samples) out.push({
31254
+ kind,
31255
+ sample
31256
+ });
31257
+ const last = samples[samples.length - 1];
31258
+ if (last !== void 0) dts = last.dts + last.duration;
31259
+ }
31260
+ }
31261
+ return out.toSorted((a, b) => a.sample.offset - b.sample.offset);
31262
+ }
31263
+ /** Bytes are ABSOLUTE file offsets here, so the demuxer can reason about a
31264
+ * sample announced by a `moof` whose `mdat` has not fully arrived. */
31265
+ var ByteWindow = class {
31266
+ held = new Uint8Array(0);
31267
+ /** Absolute offset of `held[0]`. */
31268
+ base = 0;
31269
+ /** Bytes still to DISCARD from the front of what arrives next — a skip
31270
+ * past the end of what is held (the tail of an mdat nobody follows). */
31271
+ skipAhead = 0;
31272
+ get start() {
31273
+ return this.base;
31274
+ }
31275
+ get end() {
31276
+ return this.base + this.held.length;
31277
+ }
31278
+ get length() {
31279
+ return this.held.length;
31280
+ }
31281
+ append(chunk) {
31282
+ let incoming = chunk;
31283
+ if (this.skipAhead > 0) {
31284
+ const drop = Math.min(this.skipAhead, incoming.length);
31285
+ this.skipAhead -= drop;
31286
+ this.base += drop;
31287
+ incoming = incoming.subarray(drop);
31288
+ }
31289
+ if (incoming.length === 0) return;
31290
+ if (this.held.length === 0) {
31291
+ this.held = incoming;
31292
+ return;
31293
+ }
31294
+ const next = new Uint8Array(this.held.length + incoming.length);
31295
+ next.set(this.held, 0);
31296
+ next.set(incoming, this.held.length);
31297
+ this.held = next;
31298
+ }
31299
+ /** Drop everything before absolute `offset`, including bytes not yet arrived. */
31300
+ consumeTo(offset) {
31301
+ if (offset <= this.base) return;
31302
+ const drop = Math.min(offset - this.base, this.held.length);
31303
+ this.held = this.held.subarray(drop);
31304
+ this.base += drop;
31305
+ if (offset > this.base) {
31306
+ this.skipAhead += offset - this.base;
31307
+ this.base = offset;
31308
+ this.held = new Uint8Array(0);
31309
+ }
31310
+ }
31311
+ slice(absStart, absEnd) {
31312
+ return this.held.subarray(absStart - this.base, absEnd - this.base);
31313
+ }
31314
+ view() {
31315
+ return this.held;
31316
+ }
31317
+ };
31318
+ function createFmp4StreamDemuxer(options = {}) {
31319
+ const onAudio = options.onAudio;
31320
+ const window = new ByteWindow();
31321
+ let track = null;
31322
+ let audioTrack = null;
31323
+ let audioState = { kind: "unknown" };
31324
+ let dead = null;
31325
+ /** Samples announced by the current `moof`, not yet emitted, offset order. */
31326
+ let announced = [];
31327
+ /** Absolute end of the `mdat` the announced samples live in, once known. */
31328
+ let mdatEnd = null;
31329
+ /** The ONE origin both tracks are measured from — the first sample emitted. */
31330
+ let origin = null;
31331
+ let samples = 0;
31332
+ let audioSamples = 0;
31333
+ let durationTicks = 0;
31334
+ const refuse = (reason, message) => {
31335
+ dead = new Fmp4DemuxError(reason, message);
31336
+ throw dead;
31337
+ };
31338
+ /** Anchor the shared origin on the first sample of EITHER track. */
31339
+ const anchor = (cts, timescale) => {
31340
+ origin ??= {
31341
+ ticks: cts,
31342
+ timescale
31343
+ };
31344
+ return origin;
31345
+ };
31346
+ const toAccessUnit = (sample, data) => {
31347
+ if (track === null) throw new Error("unreachable: sample before moov");
31348
+ const at = anchor(sample.cts, track.timescale);
31349
+ const isHevc = track.codec === "H265";
31350
+ let annexB;
31351
+ try {
31352
+ annexB = sample.sync ? require_segment_demux_js.buildKeyframeAnnexB(data, track.description, isHevc) : require_segment_demux_js.avccSampleToAnnexB(data, track.nalLengthSize);
31353
+ } catch (err) {
31354
+ return refuse("malformed", `sample ${samples} is not a valid AVCC/HVCC sample: ${String(err)}`);
31355
+ }
31356
+ samples += 1;
31357
+ durationTicks += sample.duration;
31358
+ return {
31359
+ data: annexB,
31360
+ ptsMs: ptsMsFrom(at, sample.cts, track.timescale),
31361
+ keyframe: sample.sync,
31362
+ codec: track.codec
31363
+ };
31364
+ };
31365
+ const emitAudio = (sample, data) => {
31366
+ if (audioTrack === null || onAudio === void 0) return;
31367
+ const at = anchor(sample.cts, audioTrack.timescale);
31368
+ audioSamples += 1;
31369
+ onAudio({
31370
+ data: data.slice(),
31371
+ ptsMs: ptsMsFrom(at, sample.cts, audioTrack.timescale),
31372
+ durationMs: Math.round(sample.duration * 1e3 / audioTrack.timescale),
31373
+ codec: audioTrack.codec,
31374
+ sampleRateHz: audioTrack.sampleRateHz,
31375
+ channels: audioTrack.channels
31376
+ });
31377
+ };
31378
+ /** Emit every announced sample whose bytes are in, in order; stop at the first that is not. */
31379
+ const drainAnnounced = (out) => {
31380
+ while (announced.length > 0) {
31381
+ const next = announced[0];
31382
+ if (next === void 0) return;
31383
+ const sampleEnd = next.sample.offset + next.sample.size;
31384
+ if (mdatEnd !== null && sampleEnd > mdatEnd) refuse("malformed", `a sample runs past its mdat (${sampleEnd} > ${mdatEnd})`);
31385
+ if (next.sample.offset < window.start) refuse("malformed", `a sample points behind the bytes still held (${next.sample.offset} < ${window.start})`);
31386
+ if (sampleEnd > window.end) return;
31387
+ const bytes = window.slice(next.sample.offset, sampleEnd);
31388
+ if (next.kind === "video") out.push(toAccessUnit(next.sample, bytes));
31389
+ else emitAudio(next.sample, bytes);
31390
+ announced = announced.slice(1);
31391
+ window.consumeTo(sampleEnd);
31392
+ }
31393
+ if (mdatEnd !== null) {
31394
+ window.consumeTo(mdatEnd);
31395
+ mdatEnd = null;
31396
+ }
31397
+ };
31398
+ const push = (chunk) => {
31399
+ if (dead !== null) throw dead;
31400
+ window.append(chunk);
31401
+ const out = [];
31402
+ for (;;) {
31403
+ if (announced.length > 0 && mdatEnd !== null) {
31404
+ drainAnnounced(out);
31405
+ if (announced.length > 0) return out;
31406
+ }
31407
+ const held = window.view();
31408
+ if (held.length < BOX_HEADER) return out;
31409
+ const view = new DataView(held.buffer, held.byteOffset, held.byteLength);
31410
+ const declared = u32(view, 0);
31411
+ const type = fourcc(held, 4);
31412
+ let size = declared;
31413
+ let contentStart = BOX_HEADER;
31414
+ if (declared === 1) {
31415
+ if (held.length < LARGE_BOX_HEADER) return out;
31416
+ size = u64(view, 8);
31417
+ contentStart = LARGE_BOX_HEADER;
31418
+ } else if (declared === 0) refuse("malformed", `a "${type}" box extends to end-of-file, which a stream never reaches`);
31419
+ if (size < contentStart) refuse("malformed", `a "${type}" box declares ${size} bytes`);
31420
+ const boxStart = window.start;
31421
+ const boxEnd = boxStart + size;
31422
+ if (type === "mdat") {
31423
+ if (track === null) refuse("no-moov", "media arrived before any moov");
31424
+ if (announced.length === 0) {
31425
+ window.consumeTo(boxEnd);
31426
+ continue;
31427
+ }
31428
+ mdatEnd = boxEnd;
31429
+ window.consumeTo(boxStart + contentStart);
31430
+ continue;
31431
+ }
31432
+ if (held.length < size) return out;
31433
+ if (type === "moov") {
31434
+ const moov = {
31435
+ type,
31436
+ start: 0,
31437
+ end: size,
31438
+ contentStart
31439
+ };
31440
+ const read = readVideoTrack(held, moov);
31441
+ if (typeof read === "string") refuse(read, `the moov cannot be followed: ${read}`);
31442
+ else track = read;
31443
+ const audio = readAudioTrack$1(held, moov);
31444
+ if ("kind" in audio) audioState = audio;
31445
+ else {
31446
+ audioTrack = audio;
31447
+ audioState = {
31448
+ kind: "ready",
31449
+ codec: audio.codec,
31450
+ sampleRateHz: audio.sampleRateHz,
31451
+ channels: audio.channels
31452
+ };
31453
+ }
31454
+ } else if (type === "moof") {
31455
+ if (track === null) refuse("no-moov", "a moof arrived before any moov");
31456
+ const announcedHere = readMoof(held, {
31457
+ type,
31458
+ start: 0,
31459
+ end: size,
31460
+ contentStart
31461
+ }, {
31462
+ video: track,
31463
+ audio: onAudio === void 0 ? null : audioTrack
31464
+ });
31465
+ if (announcedHere === null) refuse("malformed", "a moof cannot be read");
31466
+ announced = announcedHere.map((p) => ({
31467
+ kind: p.kind,
31468
+ sample: {
31469
+ ...p.sample,
31470
+ offset: p.sample.offset + boxStart
31471
+ }
31472
+ }));
31473
+ }
31474
+ window.consumeTo(boxEnd);
31475
+ }
31476
+ };
31477
+ return {
31478
+ push,
31479
+ end: () => ({
31480
+ durationMs: track !== null && samples > 0 && durationTicks > 0 ? durationTicks * 1e3 / track.timescale : null,
31481
+ samples,
31482
+ audioSamples,
31483
+ trailingBytes: window.length,
31484
+ pendingSamples: announced.length
31485
+ }),
31486
+ get ready() {
31487
+ return track !== null;
31488
+ },
31489
+ get codec() {
31490
+ return track?.codec ?? null;
31491
+ },
31492
+ get audio() {
31493
+ return audioState;
31494
+ },
31495
+ get pendingBytes() {
31496
+ return window.length;
31497
+ }
31498
+ };
31499
+ }
31500
+ //#endregion
31501
+ //#region src/stream-broker/recorded/clip-stream-feeder.ts
31502
+ /**
31503
+ * clip-stream-feeder — a clip plays FROM A STREAM, forward-only (D597).
31504
+ *
31505
+ * The second clip feeder, beside `clip-file-source.ts` + `RecordedFeeder`
31506
+ * and not in their place. The file path fetches the whole clip by handle,
31507
+ * indexes `stbl`, and lets the recorded feeder PULL access units by time.
31508
+ * This path is the other shape: the provider's clip STREAM
31509
+ * (`/clip-stream/…`, stage 1) is a fragmented MP4 written from the first
31510
+ * muxed byte, with no index and no length, and the bytes reach this process
31511
+ * as the camera delivers them. Nothing here pulls — the stream PUSHES, the
31512
+ * demuxer emits access units as their bytes land, and this feeder paces them
31513
+ * onto the session's track at the playback rate.
31514
+ *
31515
+ * ## Forward-only, and what that means for every verb
31516
+ *
31517
+ * The camera offers no seek inside a recording (operator's decision from the
31518
+ * packet capture, 2026-09-22): a replay names a FILE, and the bytes behind
31519
+ * the head were never kept. So:
31520
+ *
31521
+ * - `seek(t)` with `t` behind the HEAD (the last frame pushed) is REFUSED BY
31522
+ * NAME — logged, the position re-sent so the viewer's playhead snaps back
31523
+ * to the truth, and nothing else changes. "Back to the start" is as
31524
+ * impossible as any other backward move; `forward` is measured against the
31525
+ * head, never against zero (the viewer's `clipSeekAllowed` says the same).
31526
+ * - `seek(t)` ahead of the head is honoured if the bytes have arrived: what
31527
+ * was buffered before the last keyframe ≤ `t` is dropped, the run from that
31528
+ * keyframe to `t` is BURST (decode priming, as the recorded feeder's seek
31529
+ * prefix does), and pacing resumes from `t`. If the bytes have NOT arrived,
31530
+ * the seek waits for them and lands when they do — a stream cannot answer
31531
+ * faster than the camera sends.
31532
+ * - `stepFrame(-1)` and the scrub verbs are refused by name; `stepFrame(+1)`
31533
+ * pushes the next buffered unit.
31534
+ * - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 4],
31535
+ * the recorded domain). The camera's own `playSpeed` (8, 16 — real, the app
31536
+ * reopens the replay at 8) is a different mechanism and is NOT wired: it
31537
+ * would go on the dial (`videoclips.dialClipStream` → the cmd-5 replay's
31538
+ * `<playSpeed>`), costing a new stream, and is named in D597 as open.
31539
+ *
31540
+ * ## Duration is unknown until it is known
31541
+ *
31542
+ * A stream has no length up front. `durationMs()` resolves when the stream
31543
+ * ENDS, with the demuxer's own sum, or with `null` when it was cut or left
31544
+ * before it ended — never `0` (D393). The session reports it then, named by
31545
+ * clip, exactly as it reports the file path's measure.
31546
+ *
31547
+ * ## D9/D18, stated
31548
+ *
31549
+ * Media crosses the process boundary here COMPRESSED, in one TCP stream, at
31550
+ * the camera's delivery rate — the same shape the live path ingests from an
31551
+ * RTSP camera, and the same shape the file path's one by-handle fetch has,
31552
+ * spread over time instead of held whole. What D9 forbids is RAW frames at
31553
+ * frame-rate across processes, and nothing raw exists until the session's
31554
+ * decoder, which is downstream of this process. The backlog is bounded
31555
+ * ({@link CLIP_STREAM_BUFFER_MAX_BYTES}, the file path's own ceiling): above
31556
+ * it the socket is PAUSED (D447 on the consuming hop — read no chunk you have
31557
+ * not taken), and the producer's drain gate is what decides a consumer that
31558
+ * never drains. Frames behind the head are released as soon as they are
31559
+ * pushed, which is the same fact as "no backward seek".
31560
+ *
31561
+ * ## Sound, and the one clock it shares with the picture (D600)
31562
+ *
31563
+ * The stream carries audio ALREADY in the session's negotiated codec — the
31564
+ * provider transcodes before it muxes, because it is the process that holds
31565
+ * the camera's AAC and it already runs an ffmpeg. So this feeder decodes
31566
+ * nothing: it frames (`clip-stream-audio.ts`) and paces, on ONE schedule with
31567
+ * the video, on the SAME `ptsMs` axis the demuxer anchors both tracks on, and
31568
+ * off ONE {@link MonotonicClock} whose output is converted to each track's
31569
+ * RTP rate ({@link ClipStreamFeeder.stampAudio}). One clock rather than the
31570
+ * recorded path's pair: two clocks re-base on their own last output at every
31571
+ * discontinuity, so each seek leaves them a frame further apart.
31572
+ *
31573
+ * Two rules keep it honest, and both exist because a wrong one is inaudible
31574
+ * in a unit test:
31575
+ *
31576
+ * - **Audio is never pushed late.** ffmpeg's muxer writes a track's run when
31577
+ * its own fragment closes, so the first seconds of sound can land after the
31578
+ * picture they belong to (measured: 43 ms behind at the head on a 60x
31579
+ * delivery, and nothing after). A unit whose moment has passed by more than
31580
+ * {@link CLIP_STREAM_AUDIO_LATE_MS} is DROPPED and counted — never pushed
31581
+ * in a burst the receiver would have to discard anyway, and never silently.
31582
+ * - **Audio plays at 1x only**, like the recorded feeder's: a rate change
31583
+ * would need a resample this path has no decoder for, and pitch-shifted
31584
+ * sound is worse than none. Units dropped for the rate are counted apart.
31585
+ *
31586
+ * What the clip turned out to carry — sound, or silence and WHY — settles on
31587
+ * {@link ClipStreamFeeder.audio} the moment the `moov` is read, and is what
31588
+ * the session reports to the viewer. A dial that promises a codec promises
31589
+ * the codec, never the content: whether the recording has a microphone track
31590
+ * is only ever knowable from the stream.
31591
+ *
31592
+ * ## Every exit is logged
31593
+ *
31594
+ * Opened, first frame (with the time since the seek and since the dial),
31595
+ * ended, cut (by reason), refused (by the provider's code), disposed, every
31596
+ * refused verb, and every backpressure transition — each with `deviceId` in
31597
+ * `data`, which the broker's `onLog` puts in `tags`.
31598
+ */
31599
+ /**
31600
+ * The same ceiling as the file path (`CLIP_SOURCE_MAX_BYTES`): a clip the
31601
+ * file path would refuse `too-large` is one this path stops taking. Frames
31602
+ * behind the head are released as they are pushed, so the backlog is the
31603
+ * distance between arrival and playback, not the clip.
31604
+ */
31605
+ var CLIP_STREAM_BUFFER_MAX_BYTES = CLIP_SOURCE_MAX_BYTES;
31606
+ /** The RTP clock each codec's re-stamped timestamps are counted on. */
31607
+ var AUDIO_CLOCK_HZ = {
31608
+ opus: 48e3,
31609
+ pcmu: 8e3
31610
+ };
31611
+ /** A forward seek is a discontinuity for the receiver, like the recorded feeder's. */
31612
+ var SEEK_DISCONTINUITY_MS = 250;
31613
+ var WAIT_TICK_MS = 250;
31614
+ var POSITION_INTERVAL_MS = 1e3;
31615
+ function createClipStreamFeeder(deps) {
31616
+ return new ClipStreamFeeder(deps);
31617
+ }
31618
+ var ClipStreamFeeder = class {
31619
+ deps;
31620
+ phase = "idle";
31621
+ connection = null;
31622
+ demuxer;
31623
+ clock = new MonotonicClock();
31624
+ /** The RTP rate the audio sender negotiated — a CONVERSION, not a clock. */
31625
+ audioClockHz;
31626
+ audioAsk;
31627
+ audioPush;
31628
+ audioFramer = null;
31629
+ audioQueue = [];
31630
+ audioQueuedBytes = 0;
31631
+ audioPushed = 0;
31632
+ audioDroppedLate = 0;
31633
+ audioDroppedRate = 0;
31634
+ audioDroppedSeek = 0;
31635
+ firstAudioLogged = false;
31636
+ audioReport = null;
31637
+ audioWaiters = [];
31638
+ clockHasAdvanced = false;
31639
+ rate = 1;
31640
+ queue = [];
31641
+ queuedBytes = 0;
31642
+ headMs = null;
31643
+ arrivedMs = null;
31644
+ pendingSeekMs = null;
31645
+ pendingSteps = 0;
31646
+ seekToken = 0;
31647
+ anchorWall = 0;
31648
+ anchorPts = 0;
31649
+ paceTimer = null;
31650
+ positionTimer = null;
31651
+ stallTimer = null;
31652
+ socketPaused = false;
31653
+ streamEnded = false;
31654
+ streamEnd = null;
31655
+ duration = null;
31656
+ durationWaiters = [];
31657
+ lastAnnounced = null;
31658
+ scrubRefusedFor = void 0;
31659
+ pushed = 0;
31660
+ dropped = 0;
31661
+ bytesIn = 0;
31662
+ dialedAt = null;
31663
+ seekStartedAt = null;
31664
+ firstFrameLogged = false;
31665
+ maxBufferedBytes;
31666
+ stallTimeoutMs;
31667
+ now;
31668
+ tags;
31669
+ constructor(deps) {
31670
+ this.deps = deps;
31671
+ this.maxBufferedBytes = deps.maxBufferedBytes ?? CLIP_STREAM_BUFFER_MAX_BYTES;
31672
+ this.stallTimeoutMs = deps.stallTimeoutMs ?? 15e3;
31673
+ this.now = deps.now ?? (() => Date.now());
31674
+ this.tags = {
31675
+ deviceId: deps.handle.deviceId,
31676
+ clipId: deps.handle.clipId
31677
+ };
31678
+ const push = this.resolveAudioPush(deps);
31679
+ this.audioAsk = push === null ? "none" : deps.audio ?? "none";
31680
+ this.audioPush = this.audioAsk === "none" ? null : push;
31681
+ this.audioClockHz = this.audioAsk === "none" ? AUDIO_CLOCK_HZ.pcmu : AUDIO_CLOCK_HZ[this.audioAsk];
31682
+ this.demuxer = createFmp4StreamDemuxer(this.audioPush === null ? {} : { onAudio: (unit) => this.onAudioUnit(unit) });
31683
+ if (this.audioPush === null) this.settleAudio({
31684
+ carried: false,
31685
+ codec: null,
31686
+ reason: "not-negotiated"
31687
+ });
31688
+ }
31689
+ /**
31690
+ * The sink function for the negotiated codec, or `null`.
31691
+ *
31692
+ * Mirrors `RecordedFeeder.resolveAudioPush`: the sink is what decides, and
31693
+ * a session that offered a codec on the dial but wired no push for it would
31694
+ * otherwise make the provider transcode for nobody.
31695
+ */
31696
+ resolveAudioPush(deps) {
31697
+ const { sink, audio } = deps;
31698
+ if (audio === "opus") {
31699
+ const opus = sink.pushAudioOpus;
31700
+ return opus === void 0 ? null : (data, ts) => opus.call(sink, data, ts);
31701
+ }
31702
+ if (audio === "pcmu") {
31703
+ const pcmu = sink.pushAudioPcmu;
31704
+ return pcmu === void 0 ? null : (data, ts) => pcmu.call(sink, data, ts);
31705
+ }
31706
+ return null;
31707
+ }
31708
+ async seek(epochMs) {
31709
+ const token = ++this.seekToken;
31710
+ if (this.phase === "disposed" || this.phase === "cut" || this.phase === "refused") {
31711
+ this.log("clip-stream:seek-after-exit", {
31712
+ targetMs: epochMs,
31713
+ phase: this.phase
31714
+ });
31715
+ return;
31716
+ }
31717
+ if (this.phase === "ended") {
31718
+ this.log("clip-stream:seek-at-end", {
31719
+ targetMs: epochMs,
31720
+ headMs: this.headMs
31721
+ });
31722
+ if (this.headMs !== null) this.deps.onPosition(this.headMs);
31723
+ return;
31724
+ }
31725
+ if (this.headMs !== null && epochMs < this.headMs) {
31726
+ this.log("clip-stream:seek-backward-refused", {
31727
+ targetMs: epochMs,
31728
+ headMs: this.headMs,
31729
+ behindMs: this.headMs - epochMs
31730
+ });
31731
+ this.deps.onPosition(this.headMs);
31732
+ return;
31733
+ }
31734
+ this.seekStartedAt = this.now();
31735
+ this.stopPacing();
31736
+ this.pendingSeekMs = epochMs;
31737
+ if (this.phase === "idle") {
31738
+ await this.open(token);
31739
+ if (token !== this.seekToken) return;
31740
+ if (!this.isStreaming()) return;
31741
+ } else if (this.headMs !== null && epochMs > this.headMs && this.clockHasAdvanced) {
31742
+ this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
31743
+ this.emitState("loading", "seek-forward", token);
31744
+ this.log("clip-stream:seek-forward", {
31745
+ targetMs: epochMs,
31746
+ headMs: this.headMs,
31747
+ arrivedMs: this.arrivedMs,
31748
+ queued: this.queue.length
31749
+ });
31750
+ }
31751
+ this.resolvePendingSeek(token);
31752
+ }
31753
+ setRate(rate) {
31754
+ const next = clampPlaybackRate(rate);
31755
+ if (next === 0) {
31756
+ this.rate = 0;
31757
+ this.stopPacing();
31758
+ this.stopPositionReports();
31759
+ if (this.phase === "streaming" && this.pendingSeekMs === null) this.emitState("paused", "set-rate-zero", this.seekToken);
31760
+ return;
31761
+ }
31762
+ this.clock.setRate(next);
31763
+ const wasPaused = this.rate === 0;
31764
+ this.rate = next;
31765
+ if (this.phase !== "streaming" || this.pendingSeekMs !== null) return;
31766
+ if (wasPaused) this.emitState("playing", "set-rate-resume", this.seekToken);
31767
+ this.startPacing();
31768
+ }
31769
+ stepFrame(dir) {
31770
+ if (this.rate !== 0 || this.phase !== "streaming") return;
31771
+ if (dir === -1) {
31772
+ this.log("clip-stream:step-backward-refused", { headMs: this.headMs });
31773
+ return;
31774
+ }
31775
+ if (!this.pushNext()) {
31776
+ if (this.streamEnded) {
31777
+ this.log("clip-stream:step-at-end", { headMs: this.headMs });
31778
+ return;
31779
+ }
31780
+ this.pendingSteps += 1;
31781
+ }
31782
+ }
31783
+ beginScrub(dragId) {
31784
+ if (this.scrubRefusedFor === dragId) return;
31785
+ this.scrubRefusedFor = dragId;
31786
+ this.log("clip-stream:scrub-refused", {
31787
+ dragId,
31788
+ reason: "forward-only",
31789
+ headMs: this.headMs
31790
+ });
31791
+ }
31792
+ scrubTo() {}
31793
+ scrubAck() {}
31794
+ durationMs() {
31795
+ if (this.duration !== null) return Promise.resolve(this.duration.value);
31796
+ return new Promise((resolve) => this.durationWaiters.push(resolve));
31797
+ }
31798
+ /**
31799
+ * What this clip turned out to carry as sound — the answer the viewer needs
31800
+ * to say "this clip has no audio" rather than leave the operator wondering.
31801
+ *
31802
+ * Settles as soon as the stream's `moov` has been read, which is the first
31803
+ * fragment, so it lands beside `durationMs()`'s much later answer rather
31804
+ * than waiting for the stream to end.
31805
+ */
31806
+ audio() {
31807
+ const settled = this.audioReport;
31808
+ if (settled !== null) return Promise.resolve(settled);
31809
+ return new Promise((resolve) => this.audioWaiters.push(resolve));
31810
+ }
31811
+ dispose() {
31812
+ if (this.phase === "disposed") return;
31813
+ const before = this.phase;
31814
+ this.phase = "disposed";
31815
+ this.teardown();
31816
+ this.settleDuration(null);
31817
+ this.settleAudio({
31818
+ carried: false,
31819
+ codec: null,
31820
+ reason: "stream-failed"
31821
+ });
31822
+ this.log("clip-stream:disposed", {
31823
+ phaseBefore: before,
31824
+ pushed: this.pushed,
31825
+ dropped: this.dropped,
31826
+ queued: this.queue.length,
31827
+ bytesIn: this.bytesIn,
31828
+ headMs: this.headMs
31829
+ });
31830
+ this.queue = [];
31831
+ this.queuedBytes = 0;
31832
+ this.audioQueue = [];
31833
+ this.audioQueuedBytes = 0;
31834
+ }
31835
+ /** A method, not a field read: `open()` moves the phase behind an await
31836
+ * that control-flow analysis does not see through. */
31837
+ isStreaming() {
31838
+ return this.phase === "streaming";
31839
+ }
31840
+ async open(token) {
31841
+ this.phase = "opening";
31842
+ this.emitState("loading", "dial", token);
31843
+ this.dialedAt = this.now();
31844
+ let answer;
31845
+ try {
31846
+ answer = await this.deps.dial(this.deps.handle, this.audioAsk);
31847
+ } catch (err) {
31848
+ answer = {
31849
+ kind: "refused",
31850
+ code: "fetch-failed",
31851
+ detail: describe(err)
31852
+ };
31853
+ }
31854
+ if (this.phase !== "opening") return;
31855
+ if (answer.kind !== "dialed") {
31856
+ this.settleAudio({
31857
+ carried: false,
31858
+ codec: null,
31859
+ reason: "stream-failed"
31860
+ });
31861
+ 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);
31862
+ return;
31863
+ }
31864
+ 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", {
31865
+ deviceId: this.deps.handle.deviceId,
31866
+ asked: "high",
31867
+ served: answer.served
31868
+ });
31869
+ if (this.audioAsk !== "none" && answer.servedAudio !== this.audioAsk) {
31870
+ this.settleAudio({
31871
+ carried: false,
31872
+ codec: null,
31873
+ reason: "not-offered",
31874
+ detail: answer.audioReason ?? answer.servedAudio ?? "the provider did not say"
31875
+ });
31876
+ this.log("clip-stream:audio-not-offered", {
31877
+ asked: this.audioAsk,
31878
+ servedAudio: answer.servedAudio ?? null,
31879
+ audioReason: answer.audioReason ?? null
31880
+ });
31881
+ }
31882
+ let opened;
31883
+ try {
31884
+ opened = await this.deps.open(answer.url, {
31885
+ onData: (chunk) => this.onData(chunk),
31886
+ onEnd: () => this.onEnd(),
31887
+ onError: (err) => this.cut("stream-failed", err.message)
31888
+ });
31889
+ } catch (err) {
31890
+ opened = {
31891
+ kind: "refused",
31892
+ status: 0,
31893
+ code: "fetch-failed",
31894
+ detail: describe(err)
31895
+ };
31896
+ }
31897
+ if (this.phase !== "opening") {
31898
+ if (opened.kind === "open") opened.connection.destroy();
31899
+ return;
31900
+ }
31901
+ if (opened.kind === "refused") {
31902
+ this.refuse(opened.code, opened.detail, token, void 0, opened.status);
31903
+ return;
31904
+ }
31905
+ this.connection = opened.connection;
31906
+ this.phase = "streaming";
31907
+ this.armStall();
31908
+ this.applyBackpressure();
31909
+ this.log("clip-stream:opened", {
31910
+ sinceDialMs: this.now() - (this.dialedAt ?? this.now()),
31911
+ expiresInMs: answer.expiresAtMs - Date.now()
31912
+ });
31913
+ }
31914
+ refuse(code, detail, token, hint, status) {
31915
+ this.phase = "refused";
31916
+ this.teardown();
31917
+ this.settleDuration(null);
31918
+ this.settleAudio({
31919
+ carried: false,
31920
+ codec: null,
31921
+ reason: "stream-failed"
31922
+ });
31923
+ this.log("clip-stream:refused", {
31924
+ code,
31925
+ detail,
31926
+ ...hint !== void 0 ? { hint } : {},
31927
+ ...status !== void 0 ? { status } : {}
31928
+ });
31929
+ this.emitState("error", `refused:${code}`, token);
31930
+ }
31931
+ /**
31932
+ * Bytes are taken while OPENING too. Node flushes body bytes it already
31933
+ * holds on `process.nextTick` the moment the `data` listener attaches,
31934
+ * and that tick runs BEFORE the promise continuation that flips this
31935
+ * feeder to `streaming` — so the head of the stream (ftyp, moov) arrives
31936
+ * while the phase still says `opening`. Dropping it there turned every
31937
+ * live stream into `no-moov` while a fake opener, which delivered only
31938
+ * after resolving, kept the suite green (2026-09-22).
31939
+ */
31940
+ takesBytes() {
31941
+ return this.phase === "streaming" || this.phase === "opening";
31942
+ }
31943
+ onData(chunk) {
31944
+ if (!this.takesBytes()) return;
31945
+ this.bytesIn += chunk.byteLength;
31946
+ this.armStall();
31947
+ let units;
31948
+ try {
31949
+ units = this.demuxer.push(chunk);
31950
+ } catch (err) {
31951
+ const reason = err instanceof Fmp4DemuxError ? err.reason : "malformed";
31952
+ this.cut("demux-refused", `${reason}: ${describe(err)}`);
31953
+ return;
31954
+ }
31955
+ for (const unit of units) {
31956
+ this.queue.push(unit);
31957
+ this.queuedBytes += unit.data.byteLength;
31958
+ this.arrivedMs = unit.ptsMs;
31959
+ }
31960
+ this.settleAudioFromMoov();
31961
+ if (units.length > 0 || this.audioQueue.length > 0) this.onUnitsArrived();
31962
+ this.applyBackpressure();
31963
+ }
31964
+ /**
31965
+ * What the stream really carries, decided from the `moov` and said ONCE.
31966
+ *
31967
+ * This is the answer a dial cannot give: the provider promises a codec
31968
+ * before the camera has delivered a single audio frame, and whether the
31969
+ * recording HAS sound is written in the container it produced. A track in a
31970
+ * codec this process cannot forward, or in one the session did not
31971
+ * negotiate, is named here and never re-encoded — a second transcode on
31972
+ * this hop is exactly the backlog this path is built to avoid.
31973
+ */
31974
+ settleAudioFromMoov() {
31975
+ if (this.audioReport !== null || this.audioPush === null) return;
31976
+ const state = this.demuxer.audio;
31977
+ if (state.kind === "unknown") return;
31978
+ if (state.kind === "absent") {
31979
+ this.settleAudio({
31980
+ carried: false,
31981
+ codec: null,
31982
+ reason: "no-audio-track"
31983
+ });
31984
+ this.log("clip-stream:audio-absent", { asked: this.audioAsk });
31985
+ return;
31986
+ }
31987
+ if (state.kind === "unsupported") {
31988
+ this.settleAudio({
31989
+ carried: false,
31990
+ codec: null,
31991
+ reason: "unsupported-codec"
31992
+ });
31993
+ this.log("clip-stream:audio-unsupported", {
31994
+ asked: this.audioAsk,
31995
+ sampleEntry: state.sampleEntry
31996
+ });
31997
+ return;
31998
+ }
31999
+ if (state.codec !== this.audioAsk) {
32000
+ this.settleAudio({
32001
+ carried: false,
32002
+ codec: null,
32003
+ reason: "codec-mismatch"
32004
+ });
32005
+ this.log("clip-stream:audio-codec-mismatch", {
32006
+ asked: this.audioAsk,
32007
+ got: state.codec
32008
+ });
32009
+ return;
32010
+ }
32011
+ this.audioFramer = createClipAudioFramer(state.codec);
32012
+ this.settleAudio({
32013
+ carried: true,
32014
+ codec: state.codec,
32015
+ reason: null
32016
+ });
32017
+ this.log("clip-stream:audio-ready", {
32018
+ codec: state.codec,
32019
+ sampleRateHz: state.sampleRateHz,
32020
+ channels: state.channels
32021
+ });
32022
+ }
32023
+ /** One demuxed audio sample, framed and queued on the same axis as video. */
32024
+ onAudioUnit(unit) {
32025
+ this.settleAudioFromMoov();
32026
+ const framer = this.audioFramer;
32027
+ if (framer === null) return;
32028
+ for (const frame of framer.push(unit)) {
32029
+ this.audioQueue.push(frame);
32030
+ this.audioQueuedBytes += frame.data.byteLength;
32031
+ }
32032
+ }
32033
+ onUnitsArrived() {
32034
+ if (this.pendingSeekMs !== null) {
32035
+ this.resolvePendingSeek(this.seekToken);
32036
+ return;
32037
+ }
32038
+ while (this.rate === 0 && this.pendingSteps > 0 && this.pushNext()) this.pendingSteps -= 1;
32039
+ if (this.rate > 0 && this.paceTimer === null) this.tick();
32040
+ }
32041
+ onEnd() {
32042
+ if (!this.takesBytes()) return;
32043
+ this.streamEnded = true;
32044
+ this.clearStall();
32045
+ this.streamEnd = this.demuxer.end();
32046
+ this.log("clip-stream:ended", {
32047
+ samples: this.streamEnd.samples,
32048
+ durationMs: this.streamEnd.durationMs,
32049
+ bytesIn: this.bytesIn,
32050
+ trailingBytes: this.streamEnd.trailingBytes,
32051
+ pendingSamples: this.streamEnd.pendingSamples,
32052
+ queued: this.queue.length
32053
+ });
32054
+ this.settleDuration(this.streamEnd.durationMs);
32055
+ if (this.pendingSeekMs !== null) {
32056
+ this.resolvePendingSeek(this.seekToken);
32057
+ return;
32058
+ }
32059
+ if (this.rate > 0 && this.paceTimer === null) this.tick();
32060
+ }
32061
+ cut(reason, detail) {
32062
+ if (this.phase !== "streaming" && this.phase !== "opening") return;
32063
+ this.phase = "cut";
32064
+ this.teardown();
32065
+ this.settleDuration(null);
32066
+ this.settleAudio({
32067
+ carried: false,
32068
+ codec: null,
32069
+ reason: "stream-failed"
32070
+ });
32071
+ this.log("clip-stream:cut", {
32072
+ reason,
32073
+ detail,
32074
+ pushed: this.pushed,
32075
+ queued: this.queue.length,
32076
+ bytesIn: this.bytesIn,
32077
+ headMs: this.headMs,
32078
+ arrivedMs: this.arrivedMs,
32079
+ ...this.audioTally()
32080
+ });
32081
+ this.emitState("error", `cut:${reason}`, this.seekToken);
32082
+ }
32083
+ armStall() {
32084
+ this.clearStall();
32085
+ this.stallTimer = setTimeout(() => this.cut("stream-stalled", `no bytes for ${this.stallTimeoutMs} ms`), this.stallTimeoutMs);
32086
+ }
32087
+ clearStall() {
32088
+ if (this.stallTimer !== null) clearTimeout(this.stallTimer);
32089
+ this.stallTimer = null;
32090
+ }
32091
+ /** D447 on the consuming hop: pause the socket above the bound, resume below half. */
32092
+ applyBackpressure() {
32093
+ if (this.connection === null) return;
32094
+ const held = this.queuedBytes + this.audioQueuedBytes + this.demuxer.pendingBytes;
32095
+ if (!this.socketPaused && held > this.maxBufferedBytes) {
32096
+ this.socketPaused = true;
32097
+ this.connection?.pause();
32098
+ this.log("clip-stream:backpressure-paused", {
32099
+ heldBytes: held,
32100
+ maxBytes: this.maxBufferedBytes
32101
+ });
32102
+ } else if (this.socketPaused && held < this.maxBufferedBytes / 2) {
32103
+ this.socketPaused = false;
32104
+ this.connection?.resume();
32105
+ this.log("clip-stream:backpressure-resumed", { heldBytes: held });
32106
+ }
32107
+ }
32108
+ /**
32109
+ * Land a pending seek if its bytes are here. Called on the seek itself,
32110
+ * on every arrival while one is pending, and on end-of-stream (the last
32111
+ * chance: a target past the end lands on the last frame and the clip ends).
32112
+ */
32113
+ resolvePendingSeek(token) {
32114
+ const target = this.pendingSeekMs;
32115
+ if (target === null || this.phase !== "streaming") return;
32116
+ let keep = 0;
32117
+ for (let i = 0; i < this.queue.length; i++) {
32118
+ const unit = this.queue[i];
32119
+ if (unit === void 0 || unit.ptsMs > target) break;
32120
+ if (unit.keyframe) keep = i;
32121
+ }
32122
+ if (keep > 0) {
32123
+ for (let i = 0; i < keep; i++) this.queuedBytes -= this.queue[i]?.data.byteLength ?? 0;
32124
+ this.dropped += keep;
32125
+ this.queue = this.queue.slice(keep);
32126
+ }
32127
+ if (!(this.arrivedMs !== null && this.arrivedMs >= target) && !this.streamEnded) return;
32128
+ if (this.queue.length === 0) {
32129
+ if (this.streamEnded) {
32130
+ this.pendingSeekMs = null;
32131
+ this.log("clip-stream:seek-past-end", {
32132
+ targetMs: target,
32133
+ headMs: this.headMs
32134
+ });
32135
+ this.finish();
32136
+ }
32137
+ return;
32138
+ }
32139
+ let burst = 0;
32140
+ while (this.queue.length > 0) {
32141
+ const next = this.queue[0];
32142
+ if (next === void 0 || burst > 0 && next.ptsMs > target) break;
32143
+ this.pushNext();
32144
+ burst += 1;
32145
+ }
32146
+ this.pendingSeekMs = null;
32147
+ this.dropAudioBefore(this.headMs);
32148
+ this.log("clip-stream:seek-landed", {
32149
+ targetMs: target,
32150
+ landedMs: this.headMs,
32151
+ droppedUnits: keep,
32152
+ burstUnits: burst,
32153
+ queued: this.queue.length,
32154
+ streamEnded: this.streamEnded
32155
+ });
32156
+ if (this.headMs !== null) this.deps.onPosition(this.headMs);
32157
+ if (this.rate === 0) {
32158
+ this.emitState("paused", "seek-landed-paused", token);
32159
+ return;
32160
+ }
32161
+ this.emitState("playing", "seek-landed", token);
32162
+ this.startPacing();
32163
+ }
32164
+ /**
32165
+ * The audio RTP timestamp for a source instant — off the SAME clock as the
32166
+ * video, converted to the negotiated rate.
32167
+ *
32168
+ * ONE mapping from source time to output time, not two. The recorded path
32169
+ * runs a second `MonotonicClock` for audio and keeps the pair aligned by
32170
+ * calling `markSeek()` on both, and that alignment is only as good as the
32171
+ * discipline: each clock re-bases on its OWN last output, so every
32172
+ * discontinuity leaves the two a frame further apart than it found them —
32173
+ * a skew that is invisible on a short clip, grows with each seek, and is
32174
+ * exactly the drift this path is not going to pay for twice. Deriving the
32175
+ * audio stamp from the video clock's own output makes the alignment
32176
+ * structural: there is nothing to keep in step.
32177
+ *
32178
+ * The conversion is exact to within a rounding tick (the video clock counts
32179
+ * 90 kHz, so an output instant is known to 1/90 ms) and is computed from
32180
+ * the source pts every time, never accumulated.
32181
+ */
32182
+ stampAudio(ptsMs) {
32183
+ return Math.round(this.clock.stamp(ptsMs) * this.audioClockHz / 9e4);
32184
+ }
32185
+ /** Forget sound the playhead has already passed — a forward seek's share. */
32186
+ dropAudioBefore(headMs) {
32187
+ if (headMs === null || this.audioQueue.length === 0) return;
32188
+ let drop = 0;
32189
+ while (drop < this.audioQueue.length) {
32190
+ const frame = this.audioQueue[drop];
32191
+ if (frame === void 0 || frame.ptsMs + frame.durationMs >= headMs) break;
32192
+ this.audioQueuedBytes -= frame.data.byteLength;
32193
+ drop += 1;
32194
+ }
32195
+ if (drop === 0) return;
32196
+ this.audioQueue = this.audioQueue.slice(drop);
32197
+ this.audioDroppedSeek += drop;
32198
+ }
32199
+ startPacing() {
32200
+ this.stopPacing();
32201
+ this.anchorWall = this.now();
32202
+ this.anchorPts = this.headMs ?? this.queue[0]?.ptsMs ?? 0;
32203
+ this.startPositionReports();
32204
+ this.tick();
32205
+ }
32206
+ stopPacing() {
32207
+ if (this.paceTimer !== null) clearTimeout(this.paceTimer);
32208
+ this.paceTimer = null;
32209
+ }
32210
+ /**
32211
+ * Push every unit that is due; sleep until the next; wait for data, or end.
32212
+ *
32213
+ * ONE schedule for both tracks, ordered by `ptsMs` with VIDEO first on a
32214
+ * tie — the same ordering `RecordedFeeder.paceSegment` builds, so a clip
32215
+ * and the recorder's own footage interleave identically. The two clocks
32216
+ * stay separate (each codec's RTP rate) but they stamp the SAME axis.
32217
+ */
32218
+ tick() {
32219
+ this.paceTimer = null;
32220
+ if (this.phase !== "streaming" || this.rate === 0 || this.pendingSeekMs !== null) return;
32221
+ for (;;) {
32222
+ const video = this.queue[0];
32223
+ const audio = this.audioQueue[0];
32224
+ if (video === void 0 && audio === void 0) {
32225
+ if (this.streamEnded) this.finish();
32226
+ return;
32227
+ }
32228
+ const takeVideo = video !== void 0 && (audio === void 0 || video.ptsMs <= audio.ptsMs);
32229
+ const ptsMs = takeVideo ? video?.ptsMs ?? 0 : audio?.ptsMs ?? 0;
32230
+ const wait = this.anchorWall + (ptsMs - this.anchorPts) / this.rate - this.now();
32231
+ if (takeVideo) {
32232
+ if (wait > 0) {
32233
+ this.paceTimer = setTimeout(() => this.tick(), Math.min(wait, WAIT_TICK_MS));
32234
+ return;
32235
+ }
32236
+ this.pushNext();
32237
+ continue;
32238
+ }
32239
+ if (wait > 0) {
32240
+ this.paceTimer = setTimeout(() => this.tick(), Math.min(wait, WAIT_TICK_MS));
32241
+ return;
32242
+ }
32243
+ this.pushNextAudio(-wait);
32244
+ }
32245
+ }
32246
+ /**
32247
+ * Push or DROP the head of the audio queue. False only when it was empty.
32248
+ *
32249
+ * `lateMs` is how far the frame's moment has already passed. Past the
32250
+ * bound it is dropped and counted: a receiver would discard it anyway, and
32251
+ * pushing a run of them is the burst that makes a clip sound broken rather
32252
+ * than silent. At any rate but 1x it is dropped for a different reason,
32253
+ * counted apart, because resampling would need a decoder this path has not
32254
+ * got and pitch-shifted sound is worse than none.
32255
+ */
32256
+ pushNextAudio(lateMs) {
32257
+ const frame = this.audioQueue[0];
32258
+ if (frame === void 0) return false;
32259
+ this.audioQueue = this.audioQueue.slice(1);
32260
+ this.audioQueuedBytes -= frame.data.byteLength;
32261
+ if (this.rate !== 1) {
32262
+ this.audioDroppedRate += 1;
32263
+ return true;
32264
+ }
32265
+ if (lateMs > 120) {
32266
+ this.audioDroppedLate += 1;
32267
+ return true;
32268
+ }
32269
+ this.audioPush?.(frame.data, this.stampAudio(frame.ptsMs));
32270
+ this.audioPushed += 1;
32271
+ if (!this.firstAudioLogged) {
32272
+ this.firstAudioLogged = true;
32273
+ this.log("clip-stream:first-audio", {
32274
+ ptsMs: frame.ptsMs,
32275
+ lateMs: Math.round(lateMs),
32276
+ headMs: this.headMs,
32277
+ droppedLate: this.audioDroppedLate
32278
+ });
32279
+ }
32280
+ this.applyBackpressure();
32281
+ return true;
32282
+ }
32283
+ /** Push the head of the queue onto the track. False when the queue is empty. */
32284
+ pushNext() {
32285
+ const unit = this.queue.shift();
32286
+ if (unit === void 0) return false;
32287
+ this.queuedBytes -= unit.data.byteLength;
32288
+ this.deps.sink.pushAccessUnit(unit.data, this.clock.stamp(unit.ptsMs), unit.codec);
32289
+ this.clockHasAdvanced = true;
32290
+ this.headMs = this.headMs === null ? unit.ptsMs : Math.max(this.headMs, unit.ptsMs);
32291
+ this.pushed += 1;
32292
+ if (!this.firstFrameLogged) {
32293
+ this.firstFrameLogged = true;
32294
+ const at = this.now();
32295
+ this.log("clip-stream:first-frame", {
32296
+ sinceSeekMs: this.seekStartedAt === null ? null : at - this.seekStartedAt,
32297
+ sinceDialMs: this.dialedAt === null ? null : at - this.dialedAt,
32298
+ bytesIn: this.bytesIn,
32299
+ ptsMs: unit.ptsMs,
32300
+ codec: unit.codec
32301
+ });
32302
+ }
32303
+ this.applyBackpressure();
32304
+ return true;
32305
+ }
32306
+ finish() {
32307
+ if (this.phase !== "streaming") return;
32308
+ this.phase = "ended";
32309
+ this.settleAudio({
32310
+ carried: false,
32311
+ codec: null,
32312
+ reason: this.demuxer.ready ? "no-audio-track" : "stream-failed"
32313
+ });
32314
+ this.stopPacing();
32315
+ this.stopPositionReports();
32316
+ if (this.headMs !== null) this.deps.onPosition(this.headMs);
32317
+ this.log("clip-stream:played-out", {
32318
+ pushed: this.pushed,
32319
+ dropped: this.dropped,
32320
+ headMs: this.headMs,
32321
+ durationMs: this.streamEnd?.durationMs ?? null,
32322
+ ...this.audioTally()
32323
+ });
32324
+ this.emitState("ended", "stream-played-out", this.seekToken);
32325
+ }
32326
+ startPositionReports() {
32327
+ if (this.positionTimer !== null) return;
32328
+ this.positionTimer = setInterval(() => {
32329
+ if (this.headMs !== null && this.phase === "streaming") this.deps.onPosition(this.headMs);
32330
+ }, POSITION_INTERVAL_MS);
32331
+ }
32332
+ stopPositionReports() {
32333
+ if (this.positionTimer !== null) clearInterval(this.positionTimer);
32334
+ this.positionTimer = null;
32335
+ }
32336
+ emitState(to, reason, seekToken) {
32337
+ const from = this.lastAnnounced;
32338
+ this.lastAnnounced = to;
32339
+ this.deps.onState({
32340
+ from,
32341
+ to,
32342
+ reason,
32343
+ seekToken
32344
+ });
32345
+ }
32346
+ /** The audio counters every exit line carries. Silence is never unexplained. */
32347
+ audioTally() {
32348
+ return {
32349
+ audioAsked: this.audioAsk,
32350
+ audioCarried: this.audioReport?.carried ?? false,
32351
+ audioSilentReason: this.audioReport?.reason ?? null,
32352
+ audioPushed: this.audioPushed,
32353
+ audioDroppedLate: this.audioDroppedLate,
32354
+ audioDroppedRate: this.audioDroppedRate,
32355
+ audioDroppedSeek: this.audioDroppedSeek,
32356
+ audioQueued: this.audioQueue.length
32357
+ };
32358
+ }
32359
+ settleAudio(report) {
32360
+ if (this.audioReport !== null) return;
32361
+ this.audioReport = report;
32362
+ const waiters = this.audioWaiters.splice(0);
32363
+ for (const resolve of waiters) resolve(report);
32364
+ }
32365
+ settleDuration(value) {
32366
+ if (this.duration !== null) return;
32367
+ this.duration = {
32368
+ settled: true,
32369
+ value
32370
+ };
32371
+ const waiters = this.durationWaiters.splice(0);
32372
+ for (const resolve of waiters) resolve(value);
32373
+ }
32374
+ teardown() {
32375
+ this.stopPacing();
32376
+ this.stopPositionReports();
32377
+ this.clearStall();
32378
+ const connection = this.connection;
32379
+ this.connection = null;
32380
+ connection?.destroy();
32381
+ }
32382
+ log(msg, data) {
32383
+ this.deps.onLog?.(msg, {
32384
+ ...this.tags,
32385
+ ...data
32386
+ });
32387
+ }
32388
+ };
32389
+ function describe(err) {
32390
+ return err instanceof Error ? err.message : String(err);
32391
+ }
32392
+ function headerText(res, name) {
32393
+ const value = res.headers[name];
32394
+ if (Array.isArray(value)) return value[0];
32395
+ return value;
32396
+ }
32397
+ function createHttpClipStreamOpener(options = {}) {
32398
+ const headersTimeoutMs = options.headersTimeoutMs ?? 3e4;
32399
+ return (url, handlers) => new Promise((resolve) => {
32400
+ let settled = false;
32401
+ const settle = (value) => {
32402
+ if (settled) return;
32403
+ settled = true;
32404
+ clearTimeout(timer);
32405
+ resolve(value);
32406
+ };
32407
+ let target;
32408
+ try {
32409
+ target = new URL(url);
32410
+ } catch (err) {
32411
+ settle({
32412
+ kind: "refused",
32413
+ status: 0,
32414
+ code: "bad-url",
32415
+ detail: String(err)
32416
+ });
32417
+ return;
32418
+ }
32419
+ if (target.protocol !== "http:") {
32420
+ settle({
32421
+ kind: "refused",
32422
+ status: 0,
32423
+ code: "bad-url",
32424
+ detail: `a clip stream is dialled over plain http on the provider's listener, not ${target.protocol}`
32425
+ });
32426
+ return;
32427
+ }
32428
+ const req = (0, node_http.request)(target, {
32429
+ method: "GET",
32430
+ headers: { accept: "video/mp4" }
32431
+ });
32432
+ const timer = setTimeout(() => {
32433
+ req.destroy(/* @__PURE__ */ new Error(`no status line in ${headersTimeoutMs} ms`));
32434
+ }, headersTimeoutMs);
32435
+ req.on("error", (err) => {
32436
+ settle({
32437
+ kind: "refused",
32438
+ status: 0,
32439
+ code: "connect-failed",
32440
+ detail: err.message
32441
+ });
32442
+ });
32443
+ req.on("response", (res) => {
32444
+ const status = res.statusCode ?? 0;
32445
+ if (status !== 200) {
32446
+ res.resume();
32447
+ settle({
32448
+ kind: "refused",
32449
+ status,
32450
+ code: headerText(res, "x-camstack-reason-code") ?? `http-${status}`,
32451
+ detail: headerText(res, "x-camstack-reason") ?? ""
32452
+ });
32453
+ return;
32454
+ }
32455
+ if (headerText(res, "x-camstack-clip-transport") !== "forward-only") {
32456
+ req.destroy();
32457
+ settle({
32458
+ kind: "refused",
32459
+ status,
32460
+ code: "not-a-stream",
32461
+ detail: "the URL answered 200 without declaring the forward-only clip transport"
32462
+ });
32463
+ return;
32464
+ }
32465
+ res.on("data", (chunk) => handlers.onData(chunk));
32466
+ res.on("end", () => handlers.onEnd());
32467
+ res.on("error", (err) => handlers.onError(err));
32468
+ settle({
32469
+ kind: "open",
32470
+ connection: {
32471
+ pause: () => {
32472
+ res.pause();
32473
+ },
32474
+ resume: () => {
32475
+ res.resume();
32476
+ },
32477
+ destroy: () => {
32478
+ req.destroy();
32479
+ }
32480
+ }
32481
+ });
32482
+ });
32483
+ req.end();
32484
+ });
32485
+ }
32486
+ //#endregion
30531
32487
  //#region src/stream-broker/recorded/demux-off-thread.ts
30532
32488
  /**
30533
32489
  * Off-thread JS demux client — mp4box parse on a `worker_threads` Worker
@@ -31274,6 +33230,7 @@ var BrokerWebrtcServer = class {
31274
33230
  readRecordedSegmentBytes;
31275
33231
  readRecordedGopBytes;
31276
33232
  readClipBytes;
33233
+ dialClipStream;
31277
33234
  resolveFfmpegPath;
31278
33235
  /**
31279
33236
  * THE ffmpeg primitive, for the transcode leg. `null` until the manager wires
@@ -31367,6 +33324,7 @@ var BrokerWebrtcServer = class {
31367
33324
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
31368
33325
  this.readRecordedGopBytes = options.readRecordedGopBytes;
31369
33326
  this.readClipBytes = options.readClipBytes;
33327
+ this.dialClipStream = options.dialClipStream;
31370
33328
  this.resolveFfmpegPath = options.resolveFfmpegPath;
31371
33329
  }
31372
33330
  registerBroker(brokerId, broker) {
@@ -32087,14 +34045,93 @@ var BrokerWebrtcServer = class {
32087
34045
  };
32088
34046
  } : void 0;
32089
34047
  const readClip = this.readClipBytes;
34048
+ const dialClip = this.dialClipStream;
34049
+ const openClipStream = createHttpClipStreamOpener();
32090
34050
  let timeline;
32091
34051
  /**
32092
34052
  * ONE feeder builder for BOTH sources (D575). `clip === null` is the
32093
34053
  * recorder's own footage; a clip swaps only the two byte closures —
32094
34054
  * everything else (demux, sink, audio codec branch, state/position/log
32095
34055
  * seams) is identical, which is the whole claim the clip path makes.
34056
+ *
34057
+ * Since D597 a clip has TWO paths, and which one it gets is decided
34058
+ * ONCE, here, by `chooseClipPath`, and logged with its reason: the
34059
+ * STREAM for the sub twin (forward-only, first frame at the first
34060
+ * fragment) whenever this broker can dial one, the FILE for `high` and
34061
+ * for a broker with no dial. Never an implicit fallthrough.
32096
34062
  */
32097
34063
  const buildFeeder = (profile, clip) => {
34064
+ if (clip) {
34065
+ const choice = chooseClipPath({
34066
+ profile: clip.profile,
34067
+ streamDialWired: dialClip !== void 0,
34068
+ fileReadWired: readClip !== void 0
34069
+ });
34070
+ sessionLogger.info("clip playback: path chosen", {
34071
+ tags: { deviceId: clip.deviceId },
34072
+ meta: {
34073
+ sessionId,
34074
+ clipId: clip.clipId,
34075
+ provider: clip.provider,
34076
+ profile: clip.profile,
34077
+ path: choice.path,
34078
+ reason: choice.reason
34079
+ }
34080
+ });
34081
+ const clipLog = (msg, data) => sessionLogger.info(msg, {
34082
+ tags: { deviceId: clip.deviceId },
34083
+ meta: {
34084
+ sessionId,
34085
+ clipId: clip.clipId,
34086
+ ...data
34087
+ }
34088
+ });
34089
+ const onClipState = (t) => {
34090
+ sessionLogger.info("recorded: feeder state", {
34091
+ tags: { deviceId: clip.deviceId },
34092
+ meta: {
34093
+ sessionId,
34094
+ deviceId,
34095
+ profile,
34096
+ clipId: clip.clipId,
34097
+ path: choice.path,
34098
+ from: t.from,
34099
+ to: t.to,
34100
+ reason: t.reason,
34101
+ seekToken: t.seekToken
34102
+ }
34103
+ });
34104
+ timeline._emitState(t.to);
34105
+ };
34106
+ if (choice.path === "none") return createRefusedClipFeeder({
34107
+ handle: clip,
34108
+ reason: choice.reason,
34109
+ onState: onClipState,
34110
+ onLog: clipLog
34111
+ });
34112
+ if (choice.path === "stream" && dialClip !== void 0) return createClipStreamFeeder({
34113
+ handle: clip,
34114
+ dial: (handle, audio) => dialClip({
34115
+ deviceId: handle.deviceId,
34116
+ clipId: handle.clipId,
34117
+ provider: handle.provider,
34118
+ profile: handle.profile,
34119
+ audio
34120
+ }),
34121
+ open: openClipStream,
34122
+ audio: session.isOpusAudio ? "opus" : "pcmu",
34123
+ sink: session.isOpusAudio ? {
34124
+ pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
34125
+ pushAudioOpus: (opus, ts) => session.writeRecordedAudioOpus(Buffer.from(opus), ts)
34126
+ } : {
34127
+ pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
34128
+ pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
34129
+ },
34130
+ onState: onClipState,
34131
+ onPosition: (epochMs) => timeline._emitPosition(epochMs),
34132
+ onLog: clipLog
34133
+ });
34134
+ }
32098
34135
  const opusSession = session.isOpusAudio;
32099
34136
  const audioSink = opusSession ? {
32100
34137
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
@@ -32132,7 +34169,7 @@ var BrokerWebrtcServer = class {
32132
34169
  }
32133
34170
  })
32134
34171
  }) : null;
32135
- return new RecordedFeeder({
34172
+ const feeder = new RecordedFeeder({
32136
34173
  deviceId: clip ? clip.deviceId : deviceId,
32137
34174
  profile,
32138
34175
  locate: clipSource ? clipSource.locate : locateRecorded,
@@ -32175,6 +34212,7 @@ var BrokerWebrtcServer = class {
32175
34212
  }
32176
34213
  })
32177
34214
  });
34215
+ return clipSource ? Object.assign(feeder, { durationMs: () => clipSource.durationMs() }) : feeder;
32178
34216
  };
32179
34217
  timeline = new TimelineSession({
32180
34218
  session,
@@ -32207,7 +34245,7 @@ var BrokerWebrtcServer = class {
32207
34245
  });
32208
34246
  } : void 0,
32209
34247
  makeFeeder: (profile) => buildFeeder(profile, null),
32210
- ...readClip ? { makeClipFeeder: ({ handle }) => buildFeeder(handle.profile ?? "clip", handle) } : {},
34248
+ ...readClip || dialClip ? { makeClipFeeder: ({ handle }) => buildFeeder(handle.profile ?? "clip", handle) } : {},
32211
34249
  onLog: (msg, data) => sessionLogger.warn(msg, {
32212
34250
  tags: { deviceId },
32213
34251
  meta: {
@@ -33252,6 +35290,51 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
33252
35290
  }
33253
35291
  };
33254
35292
  //#endregion
35293
+ //#region src/stream-broker/recorded/clip-stream-dial-answer.ts
35294
+ /**
35295
+ * clip-stream-dial-answer — what a THROWN `videoclips.dialClipStream` means.
35296
+ *
35297
+ * The cap call has three ways to fail, and the feeder must tell them apart
35298
+ * because they lead to different lines and different states:
35299
+ *
35300
+ * - the provider PREDATES the method (`NOT_IMPLEMENTED`, "does not
35301
+ * implement") — the stream does not exist on this cluster yet, which is a
35302
+ * deploy-order fact, not a clip fact;
35303
+ * - the provider REFUSED in the clip vocabulary (`clipExportFailure`):
35304
+ * `sleeping`, `catalog-miss`, `clip-in-progress`, `unsupported-option`… —
35305
+ * the code is kept, the prose travels with it;
35306
+ * - anything else — the transport, a bug — is `fetch-failed` with the prose
35307
+ * verbatim, exactly as the export engine treats an unknown error (D572).
35308
+ */
35309
+ function trpcCodeOf(err) {
35310
+ if (typeof err !== "object" || err === null) return null;
35311
+ const shaped = err;
35312
+ if (typeof shaped.data?.code === "string") return shaped.data.code;
35313
+ if (typeof shaped.code === "string") return shaped.code;
35314
+ return null;
35315
+ }
35316
+ function dialAnswerFromError(err) {
35317
+ const message = err instanceof Error ? err.message : String(err);
35318
+ if (trpcCodeOf(err) === "NOT_IMPLEMENTED" || /does not implement/i.test(message)) return {
35319
+ kind: "unavailable",
35320
+ detail: message
35321
+ };
35322
+ const code = require_dist.clipExportFailureOf(message);
35323
+ if (code !== null) {
35324
+ const sep = message.indexOf(":");
35325
+ return {
35326
+ kind: "refused",
35327
+ code,
35328
+ detail: sep === -1 ? "" : message.slice(sep + 1).trim()
35329
+ };
35330
+ }
35331
+ return {
35332
+ kind: "refused",
35333
+ code: "fetch-failed",
35334
+ detail: message
35335
+ };
35336
+ }
35337
+ //#endregion
33255
35338
  //#region src/stream-broker/event-loop-stall-monitor.ts
33256
35339
  var DEFAULT_INTERVAL_MS = 100;
33257
35340
  var DEFAULT_THRESHOLD_MS = 250;
@@ -33726,6 +35809,27 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
33726
35809
  });
33727
35810
  return new Uint8Array(Buffer.from(res.base64, "base64"));
33728
35811
  },
35812
+ dialClipStream: async (args) => {
35813
+ try {
35814
+ const dial = await this.ctx.api.videoclips.dialClipStream.query({
35815
+ deviceId: args.deviceId,
35816
+ clipId: args.clipId,
35817
+ provider: args.provider,
35818
+ ...args.profile !== null ? { profile: args.profile } : {},
35819
+ audio: args.audio
35820
+ });
35821
+ return {
35822
+ kind: "dialed",
35823
+ url: dial.url,
35824
+ expiresAtMs: dial.expiresAtMs,
35825
+ ...typeof dial.served === "string" ? { served: dial.served } : {},
35826
+ ...typeof dial.servedAudio === "string" ? { servedAudio: dial.servedAudio } : {},
35827
+ ...typeof dial.audioReason === "string" ? { audioReason: dial.audioReason } : {}
35828
+ };
35829
+ } catch (err) {
35830
+ return dialAnswerFromError(err);
35831
+ }
35832
+ },
33729
35833
  readRecordedGopBytes: async (args) => {
33730
35834
  try {
33731
35835
  return await this.ctx.api.recording.readGopBytes.query({