@camstack/addon-pipeline 0.1.17 → 0.1.19

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 (52) hide show
  1. package/dist/audio-analyzer/index.js +8 -3
  2. package/dist/audio-analyzer/index.js.map +1 -1
  3. package/dist/audio-analyzer/index.mjs +8 -3
  4. package/dist/audio-analyzer/index.mjs.map +1 -1
  5. package/dist/audio-codec-nodeav/index.js +1 -1
  6. package/dist/audio-codec-nodeav/index.mjs +1 -1
  7. package/dist/decoder-nodeav/index.js +1 -1
  8. package/dist/decoder-nodeav/index.mjs +1 -1
  9. package/dist/detection-pipeline/index.js +23 -20
  10. package/dist/detection-pipeline/index.js.map +1 -1
  11. package/dist/detection-pipeline/index.mjs +23 -20
  12. package/dist/detection-pipeline/index.mjs.map +1 -1
  13. package/dist/{index-p-6GfKOg.js → index-BbPPvoCx.js} +469 -57
  14. package/dist/index-BbPPvoCx.js.map +1 -0
  15. package/dist/{index-CVzLrojg.mjs → index-Bmlkm0Fd.mjs} +469 -57
  16. package/dist/index-Bmlkm0Fd.mjs.map +1 -0
  17. package/dist/motion-wasm/index.js +1 -1
  18. package/dist/motion-wasm/index.mjs +1 -1
  19. package/dist/pipeline-runner/index.js +132 -14
  20. package/dist/pipeline-runner/index.js.map +1 -1
  21. package/dist/pipeline-runner/index.mjs +133 -15
  22. package/dist/pipeline-runner/index.mjs.map +1 -1
  23. package/dist/stream-broker/@mf-types.zip +0 -0
  24. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-NjF4kxzW.mjs +19 -0
  25. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BAv_5ISf.mjs +20 -0
  26. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-DAssX3h0.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-BsB2G7oY.mjs} +2 -1
  27. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-DFoJJhpt.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-xrRiPUpA.mjs} +1 -1
  28. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-x7XMEeuJ.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-C0E2yCzO.mjs} +1 -1
  29. package/dist/stream-broker/_stub.js +2 -2
  30. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Sx8tgpFZ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CupRlwqG.mjs} +6 -6
  31. package/dist/stream-broker/{client-CZXrddDR.mjs → client-NPZqorv9.mjs} +2 -2
  32. package/dist/stream-broker/{hostInit-D0jPgChu.mjs → hostInit-Bh4w7o5_.mjs} +12 -12
  33. package/dist/stream-broker/{index-C0BzaWmB.mjs → index-2Qp8vT3w.mjs} +1 -1
  34. package/dist/stream-broker/{index-CZNxa0ad.mjs → index-BBcZvb5t.mjs} +1 -1
  35. package/dist/stream-broker/index-CIJue-4t.mjs +37880 -0
  36. package/dist/stream-broker/{index-BvV3RVTZ.mjs → index-Cc6QBqMk.mjs} +2 -2
  37. package/dist/stream-broker/{index-cYW01SNH.mjs → index-D_1p2K9B.mjs} +1 -1
  38. package/dist/stream-broker/{index-BCEx31Mh.mjs → index-Dy2V7VOm.mjs} +3808 -3277
  39. package/dist/stream-broker/{index-KtR7Pp0O.mjs → index-mX3Kgiv1.mjs} +1 -1
  40. package/dist/stream-broker/index.js +1565 -280
  41. package/dist/stream-broker/index.js.map +1 -1
  42. package/dist/stream-broker/index.mjs +1567 -281
  43. package/dist/stream-broker/index.mjs.map +1 -1
  44. package/dist/stream-broker/{jsx-runtime-B_evVsXl.mjs → jsx-runtime-lb0mH5st.mjs} +1 -1
  45. package/dist/stream-broker/remoteEntry.js +1 -1
  46. package/dist/stream-broker/{schemas-ChN4Ih0h.mjs → schemas-ClCuS4qa.mjs} +151 -141
  47. package/package.json +1 -1
  48. package/dist/index-CVzLrojg.mjs.map +0 -1
  49. package/dist/index-p-6GfKOg.js.map +0 -1
  50. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +0 -19
  51. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-B4l8Nb2y.mjs +0 -20
  52. package/dist/stream-broker/index-Kb4xa8FX.mjs +0 -36403
@@ -1,15 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
- const index = require("../index-p-6GfKOg.js");
3
+ const index = require("../index-BbPPvoCx.js");
4
4
  const crypto = require("node:crypto");
5
5
  const net = require("node:net");
6
6
  const net$1 = require("net");
7
7
  const events = require("events");
8
8
  const node_child_process = require("node:child_process");
9
9
  const promises = require("node:dns/promises");
10
+ const os = require("node:os");
10
11
  const fs = require("node:fs");
11
12
  const path = require("node:path");
12
- const os = require("node:os");
13
13
  const node_events = require("node:events");
14
14
  function _interopNamespaceDefault(e) {
15
15
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -29,9 +29,9 @@ function _interopNamespaceDefault(e) {
29
29
  }
30
30
  const crypto__namespace = /* @__PURE__ */ _interopNamespaceDefault(crypto);
31
31
  const net__namespace = /* @__PURE__ */ _interopNamespaceDefault(net);
32
+ const os__namespace = /* @__PURE__ */ _interopNamespaceDefault(os);
32
33
  const fs__namespace = /* @__PURE__ */ _interopNamespaceDefault(fs);
33
34
  const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path);
34
- const os__namespace = /* @__PURE__ */ _interopNamespaceDefault(os);
35
35
  class DecoderSessionProxy {
36
36
  constructor(api, sessionId) {
37
37
  this.api = api;
@@ -57,14 +57,28 @@ class DecoderSessionProxy {
57
57
  * Mirrors `startPolling` but drains `pullHandles` — the decoder has
58
58
  * already written the pixels into a shared-memory ring, so what crosses
59
59
  * the cap boundary is the tiny serialisable handle. Runs until
60
- * `stopPolling` or `destroy`.
60
+ * `stopPolling`, `destroy`, or the session is destroyed externally.
61
+ *
62
+ * When the decoder reports the session is gone (`unknown sessionId` /
63
+ * `Service … not found`) the loop exits gracefully — those errors are
64
+ * expected during decoder restart/shutdown and must not be re-thrown to
65
+ * the caller's `.catch()` handler, which would otherwise spin forever.
61
66
  */
62
67
  async startHandlePolling(onHandle) {
63
68
  this.polling = true;
64
69
  while (this.polling) {
65
- const handles = await this.api.pullHandles({ sessionId: this.sessionId, maxCount: 4 });
66
- for (const handle of handles) onHandle(handle);
67
- if (handles.length === 0) await new Promise((r) => setTimeout(r, 1));
70
+ try {
71
+ const handles = await this.api.pullHandles({ sessionId: this.sessionId, maxCount: 4 });
72
+ for (const handle of handles) onHandle(handle);
73
+ if (handles.length === 0) await new Promise((r) => setTimeout(r, 1));
74
+ } catch (err) {
75
+ this.polling = false;
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ if (!msg.includes("unknown sessionId") && !msg.includes("Service") && !msg.includes("not found")) {
78
+ throw err;
79
+ }
80
+ return;
81
+ }
68
82
  }
69
83
  }
70
84
  stopPolling() {
@@ -178,6 +192,8 @@ class FrameHandlePlane {
178
192
  pullHandles(subscriptionId, maxCount) {
179
193
  const subscription = this.subscriptions.get(subscriptionId);
180
194
  if (!subscription) return [];
195
+ const stale = subscription.queue.size - maxCount;
196
+ if (stale > 0) subscription.queue.drain(stale);
181
197
  const handles = subscription.queue.drain(maxCount);
182
198
  subscription.framesDelivered += handles.length;
183
199
  return handles;
@@ -231,9 +247,18 @@ class FrameHandlePlane {
231
247
  for (const session of this.sessions.values()) {
232
248
  if (!session.proxy) continue;
233
249
  session.proxy.pushPacket(packet).catch((err) => {
250
+ const msg = index.errMsg(err);
234
251
  this.logger?.warn("frame-handle plane: decoder push error", {
235
- meta: { format: session.format, error: index.errMsg(err) }
252
+ meta: { format: session.format, error: msg }
236
253
  });
254
+ if (msg.includes("unknown sessionId") || msg.includes("Service") || msg.includes("not found")) {
255
+ this.logger?.info("frame-handle plane: invalidating stale proxy", {
256
+ meta: { format: session.format }
257
+ });
258
+ void this.destroySession(session).catch(() => {
259
+ });
260
+ this.sessions.delete(session.format);
261
+ }
237
262
  });
238
263
  }
239
264
  }
@@ -3778,6 +3803,26 @@ class StreamBroker {
3778
3803
  /** Tracking flags set synchronously by the RTP depacketizer callback. */
3779
3804
  _lastNalKeyframe = false;
3780
3805
  _lastNalParamSet = false;
3806
+ /**
3807
+ * Source-RTP pre-buffer: a ring of raw source RTP packets trimmed to
3808
+ * begin at the most recent keyframe access unit — i.e. the current GOP so
3809
+ * far. A late-joining WebRTC viewer on the repacketizer path replays this
3810
+ * on connect (`getRtpPreBuffer`) so its decoder initialises immediately
3811
+ * from a keyframe instead of waiting for the camera's next IDR (which can
3812
+ * be many seconds away on a long-GOP 4K stream). This is the RTP-level
3813
+ * counterpart to the AnnexB `preBuffer`, kept separately because the
3814
+ * repacketizer path needs the original RTP wire packets, not AnnexB.
3815
+ * Only populated for RTP sources (`isRtpSource()`).
3816
+ */
3817
+ rtpRing = [];
3818
+ rtpRingCurAuStart = 0;
3819
+ rtpRingPrevMarker = true;
3820
+ rtpRingHasKeyframe = false;
3821
+ rtpRingBytes = 0;
3822
+ /** Memory ceiling for the RTP ring. A GOP that exceeds this (pathological
3823
+ * long-GOP high-bitrate stream) drops the bootstrap rather than balloon
3824
+ * memory — late joiners fall back to waiting for the next keyframe. */
3825
+ static RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
3781
3826
  /** Stream stats tracking */
3782
3827
  totalBytes = 0;
3783
3828
  bytesInWindow = 0;
@@ -4196,7 +4241,7 @@ class StreamBroker {
4196
4241
  this.lastKeyframeMs = now;
4197
4242
  }
4198
4243
  }
4199
- if (packet.type === "video") {
4244
+ if (packet.type === "video" && !this.isRtpSource()) {
4200
4245
  this.preBuffer.push(packet);
4201
4246
  }
4202
4247
  for (const cb of this.encodedCallbacks) {
@@ -4342,6 +4387,46 @@ class StreamBroker {
4342
4387
  getSdpParameterSets() {
4343
4388
  return this.sdpParameterSets;
4344
4389
  }
4390
+ /**
4391
+ * Append a source RTP packet to the pre-buffer ring, trimming it to begin
4392
+ * at the most recent keyframe access unit. Access-unit boundaries are
4393
+ * detected via the RTP marker bit (set on the last packet of a frame);
4394
+ * keyframe-ness comes from the depacketizer flags set just before this
4395
+ * call. The ring therefore always holds `[current keyframe AU start .. now]`.
4396
+ */
4397
+ captureRtpForPreBuffer(rtpData) {
4398
+ const marker = rtpData.length > 1 && (rtpData[1] & 128) !== 0;
4399
+ const auStart = this.rtpRingPrevMarker || this.rtpRing.length === 0;
4400
+ if (auStart) this.rtpRingCurAuStart = this.rtpRing.length;
4401
+ this.rtpRing.push(rtpData);
4402
+ this.rtpRingBytes += rtpData.length;
4403
+ if (this._lastNalParamSet || this._lastNalKeyframe) {
4404
+ if (this.rtpRingCurAuStart > 0) {
4405
+ const dropped = this.rtpRing.splice(0, this.rtpRingCurAuStart);
4406
+ for (const b of dropped) this.rtpRingBytes -= b.length;
4407
+ this.rtpRingCurAuStart = 0;
4408
+ }
4409
+ this.rtpRingHasKeyframe = true;
4410
+ }
4411
+ this.rtpRingPrevMarker = marker;
4412
+ if (this.rtpRingBytes > StreamBroker.RTP_RING_MAX_BYTES) {
4413
+ this.rtpRing = [];
4414
+ this.rtpRingBytes = 0;
4415
+ this.rtpRingCurAuStart = 0;
4416
+ this.rtpRingPrevMarker = true;
4417
+ this.rtpRingHasKeyframe = false;
4418
+ }
4419
+ }
4420
+ /**
4421
+ * Snapshot of the source-RTP pre-buffer (the current GOP from its
4422
+ * keyframe) for a late-joining repacketizer-path viewer to replay on
4423
+ * connect — instant decoder start. Empty until a keyframe has been seen
4424
+ * (or after a cap overflow), in which case the viewer falls back to
4425
+ * waiting for the camera's next keyframe.
4426
+ */
4427
+ getRtpPreBuffer() {
4428
+ return this.rtpRingHasKeyframe ? this.rtpRing.slice() : [];
4429
+ }
4345
4430
  getSourceType() {
4346
4431
  return this.source?.type ?? null;
4347
4432
  }
@@ -4737,6 +4822,7 @@ class StreamBroker {
4737
4822
  this._lastNalKeyframe,
4738
4823
  this._lastNalParamSet
4739
4824
  );
4825
+ this.captureRtpForPreBuffer(rtpData);
4740
4826
  if (this.rtpVideoCallbacks.size > 0) {
4741
4827
  for (const cb of this.rtpVideoCallbacks) {
4742
4828
  try {
@@ -6239,6 +6325,14 @@ const STREAM_HEALTH_POLL_MS = 15e3;
6239
6325
  function brokerIdFor(deviceId, camStreamId) {
6240
6326
  return `${deviceId}/${camStreamId}`;
6241
6327
  }
6328
+ function parseBrokerId(brokerId) {
6329
+ const slash = brokerId.indexOf("/");
6330
+ if (slash <= 0) return null;
6331
+ const deviceId = Number(brokerId.slice(0, slash));
6332
+ const camStreamId = brokerId.slice(slash + 1);
6333
+ if (!Number.isInteger(deviceId) || deviceId < 0 || camStreamId.length === 0) return null;
6334
+ return { deviceId, camStreamId };
6335
+ }
6242
6336
  class StreamBrokerManager {
6243
6337
  /**
6244
6338
  * brokers keyed by brokerId = `${deviceId}/${camStreamId}`.
@@ -6662,6 +6756,114 @@ class StreamBrokerManager {
6662
6756
  this.emitCamStreamsChanged(deviceId);
6663
6757
  return { success: true };
6664
6758
  }
6759
+ // ── Catalog reconcile (PULL) ─────────────────────────────────────────
6760
+ //
6761
+ // The broker is the authority for its own cam-stream registry: it PULLS
6762
+ // each camera's `stream-catalog` (via DeviceProxy) and reconciles, rather
6763
+ // than relying on providers to push. This makes the broker self-heal after
6764
+ // a restart — it re-derives the full registry from the providers. Driven by
6765
+ // the host addon on start + a configurable poll + device / stream-params
6766
+ // events.
6767
+ /**
6768
+ * Race a catalog pull against a timeout so a wedged provider handler
6769
+ * can't leave the reconcile pending forever. Rejects (caught upstream →
6770
+ * retry next tick) if the provider hasn't answered in time.
6771
+ */
6772
+ async withCatalogTimeout(promise, deviceId) {
6773
+ let timer;
6774
+ const timeout = new Promise((_resolve, reject) => {
6775
+ timer = setTimeout(
6776
+ () => reject(new Error(`stream-catalog.getCatalog timed out for device ${String(deviceId)}`)),
6777
+ 12e3
6778
+ );
6779
+ });
6780
+ try {
6781
+ return await Promise.race([promise, timeout]);
6782
+ } finally {
6783
+ if (timer !== void 0) clearTimeout(timer);
6784
+ }
6785
+ }
6786
+ /**
6787
+ * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
6788
+ * every descriptor, retract any cam-stream no longer in the catalog. A pull
6789
+ * that throws (provider mid-restart, no `stream-catalog` cap) is skipped —
6790
+ * the poll retries. An EMPTY catalog is also skipped rather than tearing
6791
+ * down (a provider probe can transiently return none); genuine removal flows
6792
+ * through the device-unregistered path.
6793
+ */
6794
+ async reconcileDeviceCatalog(deviceId) {
6795
+ if (!this.api) return;
6796
+ let descriptors;
6797
+ try {
6798
+ descriptors = await this.withCatalogTimeout(
6799
+ this.api.streamCatalog.getCatalog.query({ deviceId }),
6800
+ deviceId
6801
+ ) ?? [];
6802
+ } catch (err) {
6803
+ this.logger.debug("reconcileDeviceCatalog: stream-catalog pull failed — will retry", {
6804
+ tags: { deviceId },
6805
+ meta: { error: index.errMsg(err) }
6806
+ });
6807
+ return;
6808
+ }
6809
+ if (descriptors.length === 0) return;
6810
+ const keep = new Set(descriptors.map((d) => d.camStreamId));
6811
+ for (const d of descriptors) {
6812
+ await this.publishCameraStream({ deviceId, ...d });
6813
+ }
6814
+ const existing = this.cameraStreams.get(deviceId);
6815
+ if (existing) {
6816
+ for (const camStreamId of [...existing.keys()]) {
6817
+ if (!keep.has(camStreamId)) {
6818
+ await this.retractCameraStream({ deviceId, camStreamId });
6819
+ }
6820
+ }
6821
+ }
6822
+ }
6823
+ /**
6824
+ * Enumerate every camera and reconcile its catalog. The reconcile backstop
6825
+ * (poll cadence) and the start-up fetch. Per-device failures are isolated so
6826
+ * one unreachable camera can't block the rest.
6827
+ */
6828
+ async reconcileAllCatalogs() {
6829
+ if (!this.api) return;
6830
+ let devices;
6831
+ try {
6832
+ devices = await this.api.deviceManager.listAll.query({});
6833
+ } catch (err) {
6834
+ this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", {
6835
+ meta: { error: index.errMsg(err) }
6836
+ });
6837
+ return;
6838
+ }
6839
+ const cameras = devices.filter((d) => d.isCamera || d.type === index.DeviceType.Camera);
6840
+ await Promise.allSettled(cameras.map((d) => this.reconcileDeviceCatalog(d.id)));
6841
+ }
6842
+ /**
6843
+ * Drop a device's cam-streams entirely — fired on `device.unregistered`.
6844
+ */
6845
+ async retractDevice(deviceId) {
6846
+ const existing = this.cameraStreams.get(deviceId);
6847
+ if (!existing) return;
6848
+ for (const camStreamId of [...existing.keys()]) {
6849
+ await this.retractCameraStream({ deviceId, camStreamId });
6850
+ }
6851
+ }
6852
+ /**
6853
+ * Lazily (re)create the broker instance for a `brokerId` if its cam-stream
6854
+ * is known. The audio/frame subscribe paths call this so a consumer's
6855
+ * retry RESURRECTS the broker after a restart — the video path already
6856
+ * `ensureBroker`s, but audio/frame subscribers previously assumed an
6857
+ * existing instance and failed forever once the process respawned.
6858
+ * No-op when the brokerId is malformed or its cam-stream isn't (yet)
6859
+ * reconciled into the registry (`ensureBroker` itself guards on that).
6860
+ */
6861
+ async ensureBrokerForId(brokerId) {
6862
+ if (this.brokers.has(brokerId)) return;
6863
+ const parsed = parseBrokerId(brokerId);
6864
+ if (!parsed) return;
6865
+ await this.ensureBroker(parsed.deviceId, parsed.camStreamId);
6866
+ }
6665
6867
  // ── Cap methods: profile assignment ─────────────────────────────────
6666
6868
  async assignProfile(input) {
6667
6869
  const { deviceId, profile, camStreamId } = input;
@@ -6757,6 +6959,7 @@ class StreamBrokerManager {
6757
6959
  * `FrameRingReader`.
6758
6960
  */
6759
6961
  async subscribeFrames(input) {
6962
+ await this.ensureBrokerForId(input.brokerId);
6760
6963
  const broker = this.brokers.get(input.brokerId);
6761
6964
  if (!broker) {
6762
6965
  throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
@@ -6815,6 +7018,7 @@ class StreamBrokerManager {
6815
7018
  * so their bytes travel inline on the RPC wire — no shared memory.
6816
7019
  */
6817
7020
  async subscribeAudioChunks(input) {
7021
+ await this.ensureBrokerForId(input.brokerId);
6818
7022
  const broker = this.brokers.get(input.brokerId);
6819
7023
  if (!broker) {
6820
7024
  throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
@@ -7932,6 +8136,58 @@ function isH264IdrAccessUnit(annexB) {
7932
8136
  }
7933
8137
  return hasSps;
7934
8138
  }
8139
+ function extractH264ParamSets(annexB) {
8140
+ const nals = splitAnnexBToNals(annexB);
8141
+ let sps;
8142
+ let pps;
8143
+ let profileLevelId;
8144
+ for (const nal of nals) {
8145
+ if (nal.length < 1) continue;
8146
+ const nalType = nal[0] & 31;
8147
+ if (nalType === 7) {
8148
+ sps = nal;
8149
+ if (nal.length >= 4) {
8150
+ profileLevelId = Buffer.from([nal[1], nal[2], nal[3]]).toString(
8151
+ "hex"
8152
+ );
8153
+ }
8154
+ } else if (nalType === 8) {
8155
+ pps = nal;
8156
+ }
8157
+ }
8158
+ const out = {};
8159
+ if (sps) out.sps = sps;
8160
+ if (pps) out.pps = pps;
8161
+ if (profileLevelId) out.profileLevelId = profileLevelId;
8162
+ return out;
8163
+ }
8164
+ function isBaselineProfileLevelId(profileLevelId) {
8165
+ return profileLevelId.length >= 2 && profileLevelId.slice(0, 2).toLowerCase() === "42";
8166
+ }
8167
+ function groupNalsIntoAccessUnits(nals) {
8168
+ const accessUnits = [];
8169
+ let current = [];
8170
+ let currentHasVcl = false;
8171
+ const isVcl = (type) => type >= 1 && type <= 5;
8172
+ const startsNewAccessUnit = (type, nal) => {
8173
+ if (isVcl(type)) return nal.length >= 2 && (nal[1] & 128) === 128;
8174
+ return type === 9 || type === 7 || type === 8 || type === 6;
8175
+ };
8176
+ for (const nal of nals) {
8177
+ if (nal.length < 1) continue;
8178
+ const type = nal[0] & 31;
8179
+ if (currentHasVcl && startsNewAccessUnit(type, nal)) {
8180
+ accessUnits.push(current);
8181
+ current = [];
8182
+ currentHasVcl = false;
8183
+ }
8184
+ if (type === 9) continue;
8185
+ current.push(nal);
8186
+ if (isVcl(type)) currentHasVcl = true;
8187
+ }
8188
+ if (current.length > 0) accessUnits.push(current);
8189
+ return accessUnits;
8190
+ }
7935
8191
  function tryConvertWithLengthReader(data, readLen) {
7936
8192
  const result = [];
7937
8193
  let offset = 0;
@@ -8047,14 +8303,20 @@ async function resolveMdnsCandidatesInSdp(sdp, logger, sessionTag) {
8047
8303
  const hosts = /* @__PURE__ */ new Set();
8048
8304
  for (const match of sdp.matchAll(mdnsHostRe)) hosts.add(match[1]);
8049
8305
  if (hosts.size === 0) return sdp;
8306
+ const MDNS_LOOKUP_TIMEOUT_MS = 400;
8050
8307
  const replacements = await Promise.all(
8051
8308
  [...hosts].map(async (host) => {
8052
8309
  try {
8053
- const { address } = await promises.lookup(host, { family: 4 });
8310
+ const address = await Promise.race([
8311
+ promises.lookup(host, { family: 4 }).then((r) => r.address),
8312
+ new Promise(
8313
+ (_, reject) => setTimeout(() => reject(new Error(`mDNS lookup timed out after ${MDNS_LOOKUP_TIMEOUT_MS}ms`)), MDNS_LOOKUP_TIMEOUT_MS).unref?.()
8314
+ )
8315
+ ]);
8054
8316
  logger.info("mDNS resolve succeeded", { meta: { sessionTag, host, address } });
8055
8317
  return [host, address];
8056
8318
  } catch (err) {
8057
- logger.warn("mDNS resolve failed", { meta: { sessionTag, host, error: index.errMsg(err) } });
8319
+ logger.warn("mDNS resolve failed (dropping host candidate, srflx/relay still used)", { meta: { sessionTag, host, error: index.errMsg(err) } });
8058
8320
  return [host, null];
8059
8321
  }
8060
8322
  })
@@ -8145,17 +8407,17 @@ const NAL_TYPE_IDR_N_LP = 20;
8145
8407
  const NAL_TYPE_CRA_NUT = 21;
8146
8408
  const NAL_TYPE_RSV_IRAP_VCL23 = 23;
8147
8409
  const NAL_TYPE_VPS = 32;
8148
- const NAL_TYPE_SPS = 33;
8149
- const NAL_TYPE_PPS = 34;
8410
+ const NAL_TYPE_SPS$1 = 33;
8411
+ const NAL_TYPE_PPS$1 = 34;
8150
8412
  const NAL_TYPE_AUD = 35;
8151
8413
  const NAL_TYPE_SEI_PREFIX = 39;
8152
8414
  const NAL_TYPE_SEI_SUFFIX = 40;
8153
8415
  const NAL_TYPE_AP = 48;
8154
8416
  const NAL_TYPE_FU = 49;
8155
- const NAL_HEADER_SIZE = 2;
8417
+ const NAL_HEADER_SIZE$1 = 2;
8156
8418
  const FU_HEADER_SIZE = 3;
8157
- const LENGTH_FIELD_SIZE = 2;
8158
- const AP_HEADER_SIZE = NAL_HEADER_SIZE + LENGTH_FIELD_SIZE;
8419
+ const LENGTH_FIELD_SIZE$1 = 2;
8420
+ const AP_HEADER_SIZE = NAL_HEADER_SIZE$1 + LENGTH_FIELD_SIZE$1;
8159
8421
  function getNalType(data) {
8160
8422
  return (data[0] & 126) >> 1;
8161
8423
  }
@@ -8168,12 +8430,12 @@ function isKeyFrame(nalType) {
8168
8430
  function depacketizeAP(data) {
8169
8431
  const ret = [];
8170
8432
  let lastPos;
8171
- let pos = NAL_HEADER_SIZE;
8433
+ let pos = NAL_HEADER_SIZE$1;
8172
8434
  while (pos < data.length) {
8173
8435
  if (lastPos !== void 0)
8174
8436
  ret.push(data.subarray(lastPos, pos));
8175
8437
  const naluSize = data.readUInt16BE(pos);
8176
- pos += LENGTH_FIELD_SIZE;
8438
+ pos += LENGTH_FIELD_SIZE$1;
8177
8439
  lastPos = pos;
8178
8440
  pos += naluSize;
8179
8441
  }
@@ -8293,7 +8555,7 @@ class H265Repacketizer {
8293
8555
  const fuHeaderEnd = noEnd ? fuHeaderMiddle : Buffer.from([...fuNalHeader, nalType | 64]);
8294
8556
  let fuHeader = fuHeaderStart;
8295
8557
  const packages = [];
8296
- let offset = NAL_HEADER_SIZE;
8558
+ let offset = NAL_HEADER_SIZE$1;
8297
8559
  while (offset < data.length) {
8298
8560
  let payload;
8299
8561
  const packageSize = Math.min(this.fuMax, data.length - offset);
@@ -8318,9 +8580,9 @@ class H265Repacketizer {
8318
8580
  apHeader[0] = NAL_TYPE_AP << 1;
8319
8581
  apHeader[1] = 1;
8320
8582
  const payload = [apHeader];
8321
- while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize && counter < 9) {
8583
+ while (datas.length && datas[0].length + LENGTH_FIELD_SIZE$1 <= availableSize && counter < 9) {
8322
8584
  const nalu = datas.shift();
8323
- availableSize -= LENGTH_FIELD_SIZE + nalu.length;
8585
+ availableSize -= LENGTH_FIELD_SIZE$1 + nalu.length;
8324
8586
  counter += 1;
8325
8587
  const lengthField = Buffer.alloc(2);
8326
8588
  lengthField.writeUInt16BE(nalu.length, 0);
@@ -8376,7 +8638,7 @@ class H265Repacketizer {
8376
8638
  originalFragments.unshift(originalNalHeader);
8377
8639
  return Buffer.concat(originalFragments);
8378
8640
  };
8379
- if (originalNalType === NAL_TYPE_VPS || originalNalType === NAL_TYPE_SPS) {
8641
+ if (originalNalType === NAL_TYPE_VPS || originalNalType === NAL_TYPE_SPS$1) {
8380
8642
  const defragmented = getDefragmentedPendingFu();
8381
8643
  const splits = splitH265NaluStartCode(defragmented);
8382
8644
  while (splits.length) {
@@ -8384,9 +8646,9 @@ class H265Repacketizer {
8384
8646
  const splitNaluType = getNalType(split);
8385
8647
  if (splitNaluType === NAL_TYPE_VPS) {
8386
8648
  this.updateVps(split);
8387
- } else if (splitNaluType === NAL_TYPE_SPS) {
8649
+ } else if (splitNaluType === NAL_TYPE_SPS$1) {
8388
8650
  this.updateSps(split);
8389
- } else if (splitNaluType === NAL_TYPE_PPS) {
8651
+ } else if (splitNaluType === NAL_TYPE_PPS$1) {
8390
8652
  this.updatePps(split);
8391
8653
  } else {
8392
8654
  if (isKeyFrame(splitNaluType)) {
@@ -8521,7 +8783,7 @@ class H265Repacketizer {
8521
8783
  this.pendingFU.push(packet);
8522
8784
  if (isFuEnd) {
8523
8785
  this.flushPendingFU(ret);
8524
- } else if (this.pendingFU.reduce((p, c) => p + c.payload.length - FU_HEADER_SIZE, NAL_HEADER_SIZE) > this.maxPacketSize) {
8786
+ } else if (this.pendingFU.reduce((p, c) => p + c.payload.length - FU_HEADER_SIZE, NAL_HEADER_SIZE$1) > this.maxPacketSize) {
8525
8787
  const last = this.pendingFU[this.pendingFU.length - 1].clone();
8526
8788
  const partial = [];
8527
8789
  this.flushPendingFU(partial);
@@ -8542,10 +8804,10 @@ class H265Repacketizer {
8542
8804
  if (nalType2 === NAL_TYPE_VPS) {
8543
8805
  hasVps = true;
8544
8806
  this.updateVps(payload);
8545
- } else if (nalType2 === NAL_TYPE_SPS) {
8807
+ } else if (nalType2 === NAL_TYPE_SPS$1) {
8546
8808
  hasSps = true;
8547
8809
  this.updateSps(payload);
8548
- } else if (nalType2 === NAL_TYPE_PPS) {
8810
+ } else if (nalType2 === NAL_TYPE_PPS$1) {
8549
8811
  hasPps = true;
8550
8812
  this.updatePps(payload);
8551
8813
  } else if (nalType2 === NAL_TYPE_SEI_PREFIX) {
@@ -8586,11 +8848,11 @@ class H265Repacketizer {
8586
8848
  this.extraPackets--;
8587
8849
  this.updateVps(packet.payload);
8588
8850
  return;
8589
- } else if (nalType === NAL_TYPE_SPS) {
8851
+ } else if (nalType === NAL_TYPE_SPS$1) {
8590
8852
  this.extraPackets--;
8591
8853
  this.updateSps(packet.payload);
8592
8854
  return;
8593
- } else if (nalType === NAL_TYPE_PPS) {
8855
+ } else if (nalType === NAL_TYPE_PPS$1) {
8594
8856
  this.extraPackets--;
8595
8857
  this.updatePps(packet.payload);
8596
8858
  return;
@@ -8618,136 +8880,740 @@ class H265Repacketizer {
8618
8880
  return;
8619
8881
  }
8620
8882
  }
8621
- let _werift;
8622
- async function loadWerift() {
8623
- if (_werift) return _werift;
8624
- try {
8625
- const moduleName = "werift";
8626
- _werift = await Function("m", "return import(m)")(moduleName);
8627
- return _werift;
8628
- } catch {
8629
- throw new Error(
8630
- "The 'werift' package is required for WebRTC support but is not installed. Install it with: npm install werift"
8631
- );
8883
+ const NAL_TYPE_STAP_A = 24;
8884
+ const NAL_TYPE_FU_A = 28;
8885
+ const NAL_TYPE_IDR = 5;
8886
+ const NAL_TYPE_SEI = 6;
8887
+ const NAL_TYPE_SPS = 7;
8888
+ const NAL_TYPE_PPS = 8;
8889
+ const NAL_HEADER_SIZE = 1;
8890
+ const FU_A_HEADER_SIZE = 2;
8891
+ const LENGTH_FIELD_SIZE = 2;
8892
+ const STAP_A_HEADER_SIZE = NAL_HEADER_SIZE + LENGTH_FIELD_SIZE;
8893
+ function depacketizeStapA(data) {
8894
+ const ret = [];
8895
+ let lastPos;
8896
+ let pos = NAL_HEADER_SIZE;
8897
+ while (pos < data.length) {
8898
+ if (lastPos !== void 0)
8899
+ ret.push(data.subarray(lastPos, pos));
8900
+ const naluSize = data.readUInt16BE(pos);
8901
+ pos += LENGTH_FIELD_SIZE;
8902
+ lastPos = pos;
8903
+ pos += naluSize;
8632
8904
  }
8905
+ ret.push(data.subarray(lastPos));
8906
+ return ret;
8633
8907
  }
8634
- class AdaptiveSession {
8635
- sessionId;
8636
- source;
8637
- logger;
8638
- intercom;
8639
- iceConfig;
8640
- onStats;
8641
- debug;
8642
- sourceCodec;
8643
- /** Codec actually negotiated with the browser after SDP answer. */
8644
- negotiatedCodec = "H264";
8645
- /** True when source is H.265 but browser negotiated H.264 — needs transcode. */
8646
- get needsTranscode() {
8647
- return this.sourceCodec === "H265" && this.negotiatedCodec === "H264";
8908
+ function splitH264NaluStartCode(data) {
8909
+ const ret = [];
8910
+ let previous = 0;
8911
+ let offset = 0;
8912
+ const maybeAddSlice = () => {
8913
+ const slice = data.subarray(previous, offset);
8914
+ if (slice.length)
8915
+ ret.push(slice);
8916
+ offset += 4;
8917
+ previous = offset;
8918
+ };
8919
+ while (offset < data.length - 4) {
8920
+ const startCode = data.readUInt32BE(offset);
8921
+ if (startCode === 1) {
8922
+ maybeAddSlice();
8923
+ } else {
8924
+ offset++;
8925
+ }
8648
8926
  }
8649
- _firstKeyFrame;
8650
- /**
8651
- * Last seen SPS and PPS NALs. Many cameras send SPS/PPS only once
8652
- * at stream start (not inline with every IDR). We cache them so
8653
- * PLI-triggered keyframe re-sends include the parameter sets the
8654
- * decoder needs to re-initialise.
8655
- */
8656
- lastSps = null;
8657
- lastPps = null;
8658
- /** H.265 VPS (Video Parameter Set) — required before every IRAP for decoder init. */
8659
- lastVps = null;
8660
- /** Whether replaceRTP has been called on the video sender to sync SSRC/seq. */
8661
- videoRtpSynced = false;
8662
- createdAt;
8663
- state = "new";
8664
- pc = null;
8665
- videoTrack = null;
8666
- audioTrack = null;
8667
- /** Transceiver senders for direct sendRtp (more reliable than track.writeRtp) */
8668
- videoSender = null;
8669
- audioSender = null;
8670
- feedAbort = null;
8671
- closed = false;
8672
- statsTimer = null;
8673
- /**
8674
- * Notification hook invoked exactly once when `close()` finishes. The
8675
- * adaptive server wires this up after `createSession()` so the ICE
8676
- * disconnect/failed path (see the `iceConnectionStateChange` handler
8677
- * that calls `this.close()` on its own) reaches `cam.sessions.delete`
8678
- * + `scheduleCameraAutoStop`. Without this callback the server kept
8679
- * stale entries in `cam.sessions`, the auto-stop guard (`size > 0`)
8680
- * never fired, ffmpeg stayed up, and the RTSP client toward the
8681
- * broker leaked forever (`rtspClients: 2` on idle brokers).
8682
- */
8683
- onClosed = null;
8684
- /** RTP sequence number counter (must increment per packet). */
8685
- videoSeqNum = 0;
8686
- audioSeqNum = 0;
8687
- /**
8688
- * Cached last keyframe NALs (SPS + PPS + IDR slices) in Annex-B format,
8689
- * split and ready for RTP packetization. When the browser sends a PLI
8690
- * (Picture Loss Indication) because it lost reference frames, we
8691
- * immediately re-send this stored keyframe so the decoder can recover
8692
- * without waiting for the next natural keyframe from the encoder.
8693
- */
8694
- lastKeyframeNals = null;
8695
- lastKeyframeRtpTs = 0;
8696
- /** Throttle: minimum interval between PLI-triggered keyframe re-sends (ms). */
8697
- static PLI_RESEND_COOLDOWN_MS = 500;
8698
- lastPliResendAt = 0;
8699
- /**
8700
- * Per-session H.265 RTP repacketizer (lazy-init). The H.265 path
8701
- * forwards source RTP from the broker through this repacketizer so
8702
- * Chrome's HEVC depacketizer sees an RTP shape it actually accepts —
8703
- * the prior depacketize → AnnexB → re-packetize-from-scratch path
8704
- * produced `framesAssembledFromMultiplePackets = 0` regardless of
8705
- * codec metadata being correct. See `forwardSourceRtpVideo`.
8706
- */
8707
- h265Repacketizer = null;
8708
- /** RTP MTU for the repacketizer — leave headroom for SRTP auth tag. */
8709
- static H265_REPACKETIZER_MTU = 1180;
8710
- /** Source SSRC — captured on first source RTP, used to populate the
8711
- * outbound packets so werift's send pipeline doesn't reject them. */
8712
- sourceVideoSsrc = null;
8713
- constructor(options) {
8714
- this.sessionId = options.sessionId;
8715
- this.source = options.source;
8716
- this.logger = options.logger;
8717
- this.intercom = options.intercom;
8718
- this.iceConfig = options.iceConfig;
8719
- this.onStats = options.onStats;
8720
- this.debug = options.debug ?? false;
8721
- this.sourceCodec = options.sourceCodec ?? "H264";
8722
- this.createdAt = Date.now();
8927
+ offset = data.length;
8928
+ maybeAddSlice();
8929
+ return ret;
8930
+ }
8931
+ class H264Repacketizer {
8932
+ constructor(console2, maxPacketSize, codecInfo, jitterBuffer = new JitterBuffer(console2, 4)) {
8933
+ this.console = console2;
8934
+ this.maxPacketSize = maxPacketSize;
8935
+ this.codecInfo = codecInfo;
8936
+ this.jitterBuffer = jitterBuffer;
8937
+ this.setMaxPacketSize(maxPacketSize);
8723
8938
  }
8724
- /** Build PeerConnection options including H.264 codec config. */
8725
- async buildPcOptions() {
8726
- const werift = await loadWerift();
8727
- const iceServers = [];
8728
- for (const entry of this.iceConfig?.iceServers ?? []) {
8729
- const urlList = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
8730
- for (const url of urlList) {
8731
- iceServers.push({
8732
- urls: url,
8733
- ...entry.username !== void 0 ? { username: entry.username } : {},
8734
- ...entry.credential !== void 0 ? { credential: entry.credential } : {}
8735
- });
8736
- }
8939
+ extraPackets = 0;
8940
+ fuaMax;
8941
+ pendingFuA;
8942
+ // the stapa packet that will be sent before an idr frame.
8943
+ stapa;
8944
+ fuaMin;
8945
+ setMaxPacketSize(maxPacketSize) {
8946
+ this.maxPacketSize = maxPacketSize;
8947
+ this.fuaMax = maxPacketSize - FU_A_HEADER_SIZE;
8948
+ this.fuaMin = Math.round(maxPacketSize * 0.8);
8949
+ }
8950
+ ensureCodecInfo() {
8951
+ if (!this.codecInfo) {
8952
+ this.codecInfo = {};
8737
8953
  }
8738
- const rtcpFeedback = [
8739
- { type: "transport-cc" },
8740
- { type: "ccm", parameter: "fir" },
8741
- { type: "nack" },
8742
- { type: "nack", parameter: "pli" },
8743
- { type: "goog-remb" }
8744
- ];
8745
- const h264Codec = new werift.RTCRtpCodecParameters({
8746
- mimeType: "video/H264",
8747
- clockRate: 9e4,
8748
- payloadType: 96,
8749
- parameters: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
8750
- rtcpFeedback
8954
+ return this.codecInfo;
8955
+ }
8956
+ updateSps(sps) {
8957
+ this.ensureCodecInfo().sps = sps;
8958
+ }
8959
+ updatePps(pps) {
8960
+ this.ensureCodecInfo().pps = pps;
8961
+ }
8962
+ updateSei(sei) {
8963
+ this.ensureCodecInfo().sei = sei;
8964
+ }
8965
+ shouldFilter(_nalType) {
8966
+ return false;
8967
+ }
8968
+ // a fragmentation unit (fua) is a NAL unit broken into multiple fragments.
8969
+ // https://datatracker.ietf.org/doc/html/rfc6184#section-5.8
8970
+ packetizeFuA(data, noStart, noEnd) {
8971
+ const initialNalType = data[0] & 31;
8972
+ if (initialNalType === NAL_TYPE_FU_A) {
8973
+ const fnri2 = data[0] & (128 | 96);
8974
+ const originalNalType = data[1] & 31;
8975
+ const isFuStart = !!(data[1] & 128);
8976
+ const isFuEnd = !!(data[1] & 64);
8977
+ const isFuMiddle = !isFuStart && !isFuEnd;
8978
+ const originalNalHeader = Buffer.from([fnri2 | originalNalType]);
8979
+ data = Buffer.concat([originalNalHeader, data.subarray(FU_A_HEADER_SIZE)]);
8980
+ if (isFuStart) {
8981
+ noEnd = true;
8982
+ } else if (isFuEnd) {
8983
+ noStart = true;
8984
+ } else if (isFuMiddle) {
8985
+ noStart = true;
8986
+ noEnd = true;
8987
+ }
8988
+ }
8989
+ const fnri = data[0] & (128 | 96);
8990
+ const nalType = data[0] & 31;
8991
+ const fuIndicator = fnri | NAL_TYPE_FU_A;
8992
+ const fuHeaderMiddle = Buffer.from([fuIndicator, nalType]);
8993
+ const fuHeaderStart = noStart ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 128]);
8994
+ const fuHeaderEnd = noEnd ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 64]);
8995
+ let fuHeader = fuHeaderStart;
8996
+ const packages = [];
8997
+ let offset = NAL_HEADER_SIZE;
8998
+ while (offset < data.length) {
8999
+ const packageSize = Math.min(this.fuaMax, data.length - offset);
9000
+ const payload = data.subarray(offset, offset + packageSize);
9001
+ offset += packageSize;
9002
+ if (offset === data.length) {
9003
+ fuHeader = fuHeaderEnd;
9004
+ }
9005
+ packages.push(Buffer.concat([fuHeader, payload]));
9006
+ fuHeader = fuHeaderMiddle;
9007
+ }
9008
+ return packages;
9009
+ }
9010
+ // https://datatracker.ietf.org/doc/html/rfc6184#section-5.7.1
9011
+ packetizeOneStapA(datas) {
9012
+ const payload = [];
9013
+ if (!datas.length)
9014
+ throw new Error("packetizeOneStapA requires at least one NAL");
9015
+ let counter = 0;
9016
+ let availableSize = this.maxPacketSize - STAP_A_HEADER_SIZE;
9017
+ const stapHeader = NAL_TYPE_STAP_A;
9018
+ while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize && counter < 9) {
9019
+ const nalu = datas.shift();
9020
+ availableSize -= LENGTH_FIELD_SIZE + nalu.length;
9021
+ counter += 1;
9022
+ const packed = Buffer.alloc(2);
9023
+ packed.writeUInt16BE(nalu.length, 0);
9024
+ payload.push(packed, nalu);
9025
+ }
9026
+ if (counter === 0)
9027
+ return datas.shift();
9028
+ if (counter === 1) {
9029
+ return payload[1];
9030
+ }
9031
+ payload.unshift(Buffer.from([stapHeader]));
9032
+ return Buffer.concat(payload);
9033
+ }
9034
+ packetizeStapA(datas) {
9035
+ const ret = [];
9036
+ while (datas.length) {
9037
+ const nalu = this.packetizeOneStapA(datas);
9038
+ if (nalu.length < this.maxPacketSize) {
9039
+ ret.push(nalu);
9040
+ continue;
9041
+ }
9042
+ const fuas = this.packetizeFuA(nalu);
9043
+ ret.push(...fuas);
9044
+ }
9045
+ return ret;
9046
+ }
9047
+ createPacket(rtp, data, marker) {
9048
+ const ret = rtp.clone();
9049
+ ret.header.sequenceNumber = (rtp.header.sequenceNumber + this.extraPackets + 65536) % 65536;
9050
+ ret.header.marker = marker;
9051
+ ret.header.padding = false;
9052
+ ret.payload = data;
9053
+ if (data.length > this.maxPacketSize)
9054
+ this.console.warn("packet exceeded max packet size. this may be a bug.");
9055
+ return ret;
9056
+ }
9057
+ flushPendingFuA(ret) {
9058
+ const pending = this.pendingFuA;
9059
+ if (!pending || pending.length === 0)
9060
+ return;
9061
+ const first = pending[0];
9062
+ const last = pending[pending.length - 1];
9063
+ const originalNalType = first.payload[1] & 31;
9064
+ const hasFuStart = !!(first.payload[1] & 128);
9065
+ const hasFuEnd = !!(last.payload[1] & 64);
9066
+ const fnri = first.payload[0] & (128 | 96);
9067
+ const originalNalHeader = Buffer.from([fnri | originalNalType]);
9068
+ const getDefragmentedPendingFua = () => {
9069
+ const originalFragments = pending.map((packet) => packet.payload.subarray(FU_A_HEADER_SIZE));
9070
+ originalFragments.unshift(originalNalHeader);
9071
+ return Buffer.concat(originalFragments);
9072
+ };
9073
+ if (originalNalType === NAL_TYPE_SPS) {
9074
+ const defragmented = getDefragmentedPendingFua();
9075
+ const splits = splitH264NaluStartCode(defragmented);
9076
+ while (splits.length) {
9077
+ const split = splits.shift();
9078
+ const splitNaluType = split[0] & 31;
9079
+ if (splitNaluType === NAL_TYPE_SPS) {
9080
+ this.updateSps(split);
9081
+ } else if (splitNaluType === NAL_TYPE_PPS) {
9082
+ this.updatePps(split);
9083
+ } else {
9084
+ if (splitNaluType === NAL_TYPE_IDR)
9085
+ this.maybeSendStapACodecInfo(first, ret);
9086
+ this.fragment(first, ret, {
9087
+ payload: split,
9088
+ noStart: !hasFuStart,
9089
+ noEnd: !hasFuEnd,
9090
+ marker: last.header.marker
9091
+ });
9092
+ }
9093
+ }
9094
+ } else {
9095
+ while (pending.length) {
9096
+ const fua = pending[0];
9097
+ if (fua.payload.length > this.maxPacketSize || fua.payload.length < this.fuaMin)
9098
+ break;
9099
+ pending.shift();
9100
+ ret.push(this.createPacket(fua, fua.payload, fua.header.marker));
9101
+ }
9102
+ if (!pending.length) {
9103
+ this.pendingFuA = void 0;
9104
+ return;
9105
+ }
9106
+ const refragFirst = pending[0];
9107
+ const refragLast = pending[pending.length - 1];
9108
+ const refragHasFuStart = !!(refragFirst.payload[1] & 128);
9109
+ const refragHasFuEnd = !!(refragLast.payload[1] & 64);
9110
+ const defragmented = getDefragmentedPendingFua();
9111
+ this.fragment(refragFirst, ret, {
9112
+ payload: defragmented,
9113
+ noStart: !refragHasFuStart,
9114
+ noEnd: !refragHasFuEnd,
9115
+ marker: refragLast.header.marker
9116
+ });
9117
+ }
9118
+ this.extraPackets -= pending.length - 1;
9119
+ this.pendingFuA = void 0;
9120
+ }
9121
+ createRtpPackets(packet, nalus, ret, hadMarker = packet.header.marker) {
9122
+ nalus.forEach((packetized, index2) => {
9123
+ if (index2 !== 0)
9124
+ this.extraPackets++;
9125
+ const marker = hadMarker && index2 === nalus.length - 1;
9126
+ ret.push(this.createPacket(packet, packetized, marker));
9127
+ });
9128
+ }
9129
+ maybeSendStapACodecInfo(packet, ret) {
9130
+ if (this.stapa) {
9131
+ this.stapa = void 0;
9132
+ return;
9133
+ }
9134
+ if (!this.codecInfo?.sps || !this.codecInfo?.pps)
9135
+ return;
9136
+ const agg = [this.codecInfo.sps, this.codecInfo.pps];
9137
+ if (this.codecInfo?.sei)
9138
+ agg.push(this.codecInfo.sei);
9139
+ const aggregates = this.packetizeStapA(agg);
9140
+ if (aggregates.length !== 1) {
9141
+ this.console.error("expected only 1 packet for sps/pps stapa");
9142
+ return;
9143
+ }
9144
+ this.createRtpPackets(packet, aggregates, ret, false);
9145
+ this.extraPackets++;
9146
+ }
9147
+ // given the packet, fragment it into multiple packets as needed.
9148
+ // a fragment of a payload may be provided via fuaOptions.
9149
+ fragment(packet, ret, fuaOptions = {
9150
+ payload: packet.payload,
9151
+ noStart: false,
9152
+ noEnd: false,
9153
+ marker: packet.header.marker
9154
+ }) {
9155
+ const { payload, noStart, noEnd, marker } = fuaOptions;
9156
+ if (payload.length > this.maxPacketSize || noStart || noEnd) {
9157
+ const fragments = this.packetizeFuA(payload, noStart, noEnd);
9158
+ this.createRtpPackets(packet, fragments, ret, marker);
9159
+ } else {
9160
+ ret.push(this.createPacket(packet, payload, marker));
9161
+ }
9162
+ }
9163
+ repacketize(packet) {
9164
+ const ret = [];
9165
+ for (const dejittered of this.jitterBuffer.queue(packet)) {
9166
+ this.repacketizeOne(dejittered, ret);
9167
+ }
9168
+ return ret;
9169
+ }
9170
+ repacketizeOne(packet, ret) {
9171
+ if (!packet.payload.length) {
9172
+ this.flushPendingFuA(ret);
9173
+ this.extraPackets--;
9174
+ return;
9175
+ }
9176
+ const nalType = packet.payload[0] & 31;
9177
+ if (this.pendingFuA && this.pendingFuA[0].header.timestamp !== packet.header.timestamp) {
9178
+ this.flushPendingFuA(ret);
9179
+ }
9180
+ if (nalType === NAL_TYPE_FU_A) {
9181
+ const data = packet.payload;
9182
+ const originalNalType = data[1] & 31;
9183
+ if (this.shouldFilter(originalNalType)) {
9184
+ this.extraPackets--;
9185
+ return;
9186
+ }
9187
+ const isFuStart = !!(data[1] & 128);
9188
+ if (isFuStart) {
9189
+ if (this.pendingFuA)
9190
+ this.console.error("fua restarted. skipping refragmentation of previous fua.", originalNalType);
9191
+ this.pendingFuA = void 0;
9192
+ if (originalNalType === NAL_TYPE_IDR) {
9193
+ this.maybeSendStapACodecInfo(packet, ret);
9194
+ }
9195
+ } else {
9196
+ if (this.pendingFuA) {
9197
+ const last = this.pendingFuA[this.pendingFuA.length - 1];
9198
+ if (!isNextSequenceNumber(last.header.sequenceNumber, packet.header.sequenceNumber)) {
9199
+ this.console.error("fua packet missing. skipping refragmentation.", originalNalType);
9200
+ return;
9201
+ }
9202
+ }
9203
+ }
9204
+ if (!this.pendingFuA)
9205
+ this.pendingFuA = [];
9206
+ this.pendingFuA.push(packet);
9207
+ const isFuEnd = !!(packet.payload[1] & 64);
9208
+ if (isFuEnd) {
9209
+ this.flushPendingFuA(ret);
9210
+ } else if (this.pendingFuA.reduce((p, c) => p + c.payload.length - FU_A_HEADER_SIZE, NAL_HEADER_SIZE) > this.maxPacketSize) {
9211
+ const last = this.pendingFuA[this.pendingFuA.length - 1].clone();
9212
+ const partial = [];
9213
+ this.flushPendingFuA(partial);
9214
+ const retain = partial.pop();
9215
+ if (retain)
9216
+ last.payload = retain.payload;
9217
+ this.pendingFuA = [last];
9218
+ ret.push(...partial);
9219
+ }
9220
+ } else if (nalType === NAL_TYPE_STAP_A) {
9221
+ this.flushPendingFuA(ret);
9222
+ let hasSps = false;
9223
+ let hasPps = false;
9224
+ const depacketized = depacketizeStapA(packet.payload);
9225
+ depacketized.forEach((payload) => {
9226
+ const stapNalType = payload[0] & 31;
9227
+ if (stapNalType === NAL_TYPE_SPS) {
9228
+ hasSps = true;
9229
+ this.updateSps(payload);
9230
+ } else if (stapNalType === NAL_TYPE_PPS) {
9231
+ hasPps = true;
9232
+ this.updatePps(payload);
9233
+ } else if (stapNalType === NAL_TYPE_SEI) {
9234
+ this.updateSei(payload);
9235
+ }
9236
+ });
9237
+ if (hasSps && hasPps)
9238
+ this.stapa = packet;
9239
+ const stapa = this.packetizeStapA(depacketized);
9240
+ this.createRtpPackets(packet, stapa, ret);
9241
+ } else if (nalType >= 1 && nalType < 24) {
9242
+ this.flushPendingFuA(ret);
9243
+ if (this.shouldFilter(nalType)) {
9244
+ this.extraPackets--;
9245
+ return;
9246
+ }
9247
+ if (nalType === NAL_TYPE_SPS) {
9248
+ this.extraPackets--;
9249
+ this.updateSps(packet.payload);
9250
+ return;
9251
+ } else if (nalType === NAL_TYPE_PPS) {
9252
+ this.extraPackets--;
9253
+ this.updatePps(packet.payload);
9254
+ return;
9255
+ } else if (nalType === NAL_TYPE_SEI) {
9256
+ this.extraPackets--;
9257
+ this.updateSei(packet.payload);
9258
+ return;
9259
+ }
9260
+ if (nalType === NAL_TYPE_IDR) {
9261
+ this.maybeSendStapACodecInfo(packet, ret);
9262
+ }
9263
+ this.fragment(packet, ret);
9264
+ } else {
9265
+ this.console.error("unknown nal unit type " + nalType);
9266
+ this.extraPackets--;
9267
+ }
9268
+ }
9269
+ }
9270
+ let _werift;
9271
+ async function loadWerift() {
9272
+ if (_werift) return _werift;
9273
+ try {
9274
+ const moduleName = "werift";
9275
+ _werift = await Function("m", "return import(m)")(moduleName);
9276
+ return _werift;
9277
+ } catch {
9278
+ throw new Error(
9279
+ "The 'werift' package is required for WebRTC support but is not installed. Install it with: npm install werift"
9280
+ );
9281
+ }
9282
+ }
9283
+ function isTailscaleIpv4(address) {
9284
+ const parts = address.split(".");
9285
+ if (parts.length !== 4) return false;
9286
+ const a = Number.parseInt(parts[0] ?? "", 10);
9287
+ const b = Number.parseInt(parts[1] ?? "", 10);
9288
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
9289
+ return a === 100 && b >= 64 && b <= 127;
9290
+ }
9291
+ function isTailscaleIpv6(address) {
9292
+ const pct = address.indexOf("%");
9293
+ const clean = (pct === -1 ? address : address.slice(0, pct)).toLowerCase();
9294
+ return clean.startsWith("fd7a:");
9295
+ }
9296
+ function getTailscaleHostAddresses(interfaces = os.networkInterfaces()) {
9297
+ const found = /* @__PURE__ */ new Set();
9298
+ for (const entries of Object.values(interfaces)) {
9299
+ if (!entries) continue;
9300
+ for (const entry of entries) {
9301
+ if (entry.internal) continue;
9302
+ const fam = entry.family;
9303
+ const isV4 = fam === "IPv4" || fam === 4;
9304
+ const isV6 = fam === "IPv6" || fam === 6;
9305
+ if (isV4 && isTailscaleIpv4(entry.address)) found.add(entry.address);
9306
+ else if (isV6 && isTailscaleIpv6(entry.address)) {
9307
+ const pct = entry.address.indexOf("%");
9308
+ found.add(pct === -1 ? entry.address : entry.address.slice(0, pct));
9309
+ }
9310
+ }
9311
+ }
9312
+ return [...found];
9313
+ }
9314
+ function resolveVideoTranscode(input) {
9315
+ const { sourceCodec, negotiatedCodec, transcodeToBaseline } = input;
9316
+ if (sourceCodec === "H265" && negotiatedCodec === "H264") return true;
9317
+ if (sourceCodec === "H264" && negotiatedCodec === "H264" && transcodeToBaseline) return true;
9318
+ return false;
9319
+ }
9320
+ function detectNegotiatedVideoCodec(sdp) {
9321
+ const m = sdp.match(/a=rtpmap:\d+ (H264|H265)\/90000/i)?.[1]?.toUpperCase();
9322
+ return m === "H264" || m === "H265" ? m : void 0;
9323
+ }
9324
+ function transcodeInputFormat(sourceCodec) {
9325
+ return sourceCodec === "H265" ? "hevc" : "h264";
9326
+ }
9327
+ class AdaptiveSession {
9328
+ sessionId;
9329
+ deviceId;
9330
+ source;
9331
+ logger;
9332
+ intercom;
9333
+ iceConfig;
9334
+ /** Force TURN-relay-only ICE — set for client-offer (Alexa) sessions whose
9335
+ * peer is a cloud media server unreachable via host/srflx behind NAT. */
9336
+ forceRelayOnly = false;
9337
+ /** Latest RTCP Receiver Report from the remote viewer (jitter/loss),
9338
+ * captured via sender.onRtcp. null until the first RR arrives. */
9339
+ lastRr = null;
9340
+ /** Monotonic base (ms, performance.now) for send-time RTP timestamps. */
9341
+ videoRtpClockBaseMs = null;
9342
+ /** Log the camera's actual H.264 profile-level-id once per session. */
9343
+ profileLogged = false;
9344
+ onStats;
9345
+ debug;
9346
+ sourceCodec;
9347
+ /**
9348
+ * H.264 source profile is non-Baseline (Main/High) → egress must be
9349
+ * re-encoded to Constrained Baseline for iOS. Set by the broker from the
9350
+ * camera SPS; ignored for H.265 sources. Default false.
9351
+ */
9352
+ transcodeToBaseline;
9353
+ /** Codec actually negotiated with the browser after SDP answer. */
9354
+ negotiatedCodec = "H264";
9355
+ /**
9356
+ * True when the egress can't be the camera's native bytes: H.265 source
9357
+ * with an H.264-only browser, or an H.264 Main/High source that must be
9358
+ * forced to Constrained Baseline for iOS. See `resolveVideoTranscode`.
9359
+ */
9360
+ get needsTranscode() {
9361
+ return resolveVideoTranscode({
9362
+ sourceCodec: this.sourceCodec,
9363
+ negotiatedCodec: this.negotiatedCodec,
9364
+ transcodeToBaseline: this.transcodeToBaseline
9365
+ });
9366
+ }
9367
+ _firstKeyFrame;
9368
+ /**
9369
+ * Last seen SPS and PPS NALs. Many cameras send SPS/PPS only once
9370
+ * at stream start (not inline with every IDR). We cache them so
9371
+ * PLI-triggered keyframe re-sends include the parameter sets the
9372
+ * decoder needs to re-initialise.
9373
+ */
9374
+ lastSps = null;
9375
+ lastPps = null;
9376
+ /** H.265 VPS (Video Parameter Set) — required before every IRAP for decoder init. */
9377
+ lastVps = null;
9378
+ /** Whether replaceRTP has been called on the video sender to sync SSRC/seq. */
9379
+ videoRtpSynced = false;
9380
+ createdAt;
9381
+ state = "new";
9382
+ pc = null;
9383
+ videoTrack = null;
9384
+ audioTrack = null;
9385
+ /** Transceiver senders for direct sendRtp (more reliable than track.writeRtp) */
9386
+ videoSender = null;
9387
+ audioSender = null;
9388
+ feedAbort = null;
9389
+ closed = false;
9390
+ statsTimer = null;
9391
+ /**
9392
+ * Notification hook invoked exactly once when `close()` finishes. The
9393
+ * adaptive server wires this up after `createSession()` so the ICE
9394
+ * disconnect/failed path (see the `iceConnectionStateChange` handler
9395
+ * that calls `this.close()` on its own) reaches `cam.sessions.delete`
9396
+ * + `scheduleCameraAutoStop`. Without this callback the server kept
9397
+ * stale entries in `cam.sessions`, the auto-stop guard (`size > 0`)
9398
+ * never fired, ffmpeg stayed up, and the RTSP client toward the
9399
+ * broker leaked forever (`rtspClients: 2` on idle brokers).
9400
+ */
9401
+ onClosed = null;
9402
+ /** RTP sequence number counter (must increment per packet). */
9403
+ videoSeqNum = 0;
9404
+ audioSeqNum = 0;
9405
+ /**
9406
+ * Cached last keyframe NALs (SPS + PPS + IDR slices) in Annex-B format,
9407
+ * split and ready for RTP packetization. When the browser sends a PLI
9408
+ * (Picture Loss Indication) because it lost reference frames, we
9409
+ * immediately re-send this stored keyframe so the decoder can recover
9410
+ * without waiting for the next natural keyframe from the encoder.
9411
+ */
9412
+ lastKeyframeNals = null;
9413
+ lastKeyframeRtpTs = 0;
9414
+ /** Throttle: minimum interval between PLI-triggered keyframe re-sends (ms). */
9415
+ static PLI_RESEND_COOLDOWN_MS = 500;
9416
+ lastPliResendAt = 0;
9417
+ /**
9418
+ * Per-session H.265 RTP repacketizer (lazy-init). The H.265 path
9419
+ * forwards source RTP from the broker through this repacketizer so
9420
+ * Chrome's HEVC depacketizer sees an RTP shape it actually accepts —
9421
+ * the prior depacketize → AnnexB → re-packetize-from-scratch path
9422
+ * produced `framesAssembledFromMultiplePackets = 0` regardless of
9423
+ * codec metadata being correct. See `forwardSourceRtpVideo`.
9424
+ */
9425
+ h265Repacketizer = null;
9426
+ /** RTP MTU for the repacketizer. 1100 keeps the full wire packet
9427
+ * (payload + RTP hdr + hdr-extensions + SRTP/GCM tag + UDP + IPv4/IPv6)
9428
+ * comfortably under the Tailscale/WireGuard overlay MTU (1280) — at the
9429
+ * old 1180/1200 a packet is ~1266 B, which fits a plain 1500-MTU LAN but
9430
+ * is at/over the 1280 overlay limit, so keyframe packets silently drop
9431
+ * over Tailscale → the keyframe never reassembles → endless PLI storm
9432
+ * (LAN-OK / remote-fail). 1100 leaves ~90 B of headroom for IPv6. */
9433
+ static H265_REPACKETIZER_MTU = 1100;
9434
+ /**
9435
+ * Per-session H.264 RTP repacketizer (lazy-init). H.264 RTSP sources
9436
+ * forward source RTP through this — same Tailscale-safe MTU and the same
9437
+ * reason as H.265: iOS (WebKit) rejects the `writeVideoNals` synthesized
9438
+ * RTP shape for Main/High passthrough; the repacketizer preserves the
9439
+ * native RTP layout (STAP-A SPS/PPS + FU-A). See `forwardSourceRtpVideo`.
9440
+ */
9441
+ h264Repacketizer = null;
9442
+ static H264_REPACKETIZER_MTU = 1100;
9443
+ /** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
9444
+ rtpBootstrap;
9445
+ rtpBootstrapDone = false;
9446
+ /**
9447
+ * Trickle ICE: the server's locally-gathered candidates, buffered as werift
9448
+ * surfaces them via `onIceCandidate`. The client polls `getIceCandidates`
9449
+ * and adds each. Lets `handleOffer` return the answer IMMEDIATELY (no
9450
+ * gathering wait) — candidates flow afterwards → ~0s connect.
9451
+ */
9452
+ localIceCandidates = [];
9453
+ iceGatheringComplete = false;
9454
+ /** Source SSRC — captured on first source RTP, used to populate the
9455
+ * outbound packets so werift's send pipeline doesn't reject them. */
9456
+ sourceVideoSsrc = null;
9457
+ constructor(options) {
9458
+ this.sessionId = options.sessionId;
9459
+ this.deviceId = options.deviceId;
9460
+ this.source = options.source;
9461
+ this.logger = options.logger;
9462
+ this.intercom = options.intercom;
9463
+ this.iceConfig = options.iceConfig;
9464
+ this.onStats = options.onStats;
9465
+ this.debug = options.debug ?? false;
9466
+ this.sourceCodec = options.sourceCodec ?? "H264";
9467
+ this.transcodeToBaseline = options.transcodeToBaseline ?? false;
9468
+ this.rtpBootstrap = options.rtpBootstrap;
9469
+ this.createdAt = Date.now();
9470
+ }
9471
+ /**
9472
+ * Force TURN-relay-only ICE for this session. MUST be called before
9473
+ * `createOffer()` (which builds the PeerConnection via
9474
+ * `buildPcOptions`) — once the PC exists the policy is fixed.
9475
+ *
9476
+ * Set by the broker for remote (non-LAN) viewers: with the patched
9477
+ * werift, `iceTransportPolicy:'relay'` produces a genuinely relay-only
9478
+ * SDP, so a CGNAT client (which can only offer a relay candidate) gets
9479
+ * a clean relay↔relay media path instead of werift nominating a dead
9480
+ * host/hairpin-srflx pair. The Alexa/WHEP `handleOffer` path sets this
9481
+ * internally; this setter is the server-offer (browser live-view) path.
9482
+ */
9483
+ setForceRelayOnly(value) {
9484
+ this.forceRelayOnly = value;
9485
+ }
9486
+ /**
9487
+ * Wait for ICE gathering to yield a usable candidate, then return — do NOT
9488
+ * block on full gathering "complete". The direct LAN/Tailscale path only
9489
+ * needs a host candidate (present in <1s); waiting for STUN/TURN gathering
9490
+ * to finish cost many seconds of dead startup latency before the offer/
9491
+ * answer could be sent. srflx/relay keep gathering into the local SDP; we
9492
+ * just don't wait on them. A small floor (600ms) lets a fast srflx also
9493
+ * land in the SDP. Hard cap (2.5s) bounds a stalled agent — e.g. a
9494
+ * remote-only peer that genuinely needs a slow relay.
9495
+ */
9496
+ async waitForIceGatheringFast() {
9497
+ if (!this.pc) return;
9498
+ if (this.pc.iceGatheringState === "complete") return;
9499
+ await new Promise((resolve) => {
9500
+ let settled = false;
9501
+ let poll;
9502
+ const finish = () => {
9503
+ if (settled) return;
9504
+ settled = true;
9505
+ if (poll) clearInterval(poll);
9506
+ resolve();
9507
+ };
9508
+ this.pc?.iceGatheringStateChange.subscribe((state) => {
9509
+ if (state === "complete") finish();
9510
+ });
9511
+ const start = Date.now();
9512
+ poll = setInterval(() => {
9513
+ const sdp = this.pc?.localDescription?.sdp ?? "";
9514
+ if (/ typ host/.test(sdp) && Date.now() - start >= 600) finish();
9515
+ }, 100);
9516
+ setTimeout(finish, 2500);
9517
+ });
9518
+ }
9519
+ /**
9520
+ * Read the nominated (selected) ICE candidate pair off the live
9521
+ * werift connection, via the video sender's DTLS → ICE transport.
9522
+ * Returns null until ICE has nominated a pair (or if werift hasn't
9523
+ * wired the transport chain yet). Used by the media diagnostic to
9524
+ * confirm — on a remote retest — whether werift converged on a
9525
+ * relay↔relay pair.
9526
+ */
9527
+ readNominatedPair() {
9528
+ const conn = this.videoSender?.dtlsTransport?.iceTransport?.connection;
9529
+ const pair = conn?.nominated;
9530
+ if (!pair) return null;
9531
+ const lc = pair.localCandidate;
9532
+ const rc = pair.remoteCandidate;
9533
+ return {
9534
+ localType: lc.type,
9535
+ localAddr: `${lc.host}:${lc.port}`,
9536
+ remoteType: rc.type,
9537
+ remoteAddr: `${rc.host}:${rc.port}`
9538
+ };
9539
+ }
9540
+ /**
9541
+ * Log the nominated ICE candidate pair once ICE connects. werift may
9542
+ * populate `connection.nominated` a tick AFTER the "connected" event
9543
+ * fires, so retry briefly before giving up. This is the decisive
9544
+ * one-shot line for a remote retest: a `relay/…` ↔ `relay/…` pair
9545
+ * means the relay-only fix took effect; anything else (or null) on a
9546
+ * remote session is the dead-pair symptom.
9547
+ */
9548
+ logNominatedPair(attempt = 0) {
9549
+ if (this.closed) return;
9550
+ const pair = this.readNominatedPair();
9551
+ if (!pair) {
9552
+ if (attempt < 5) {
9553
+ setTimeout(() => this.logNominatedPair(attempt + 1), 200);
9554
+ return;
9555
+ }
9556
+ this.logger.info("ICE nominated pair", {
9557
+ meta: {
9558
+ phase: "session",
9559
+ sessionId: this.sessionId,
9560
+ deviceId: this.deviceId,
9561
+ forceRelayOnly: this.forceRelayOnly,
9562
+ nominated: "none"
9563
+ }
9564
+ });
9565
+ return;
9566
+ }
9567
+ this.logger.info("ICE nominated pair", {
9568
+ meta: {
9569
+ phase: "session",
9570
+ sessionId: this.sessionId,
9571
+ deviceId: this.deviceId,
9572
+ forceRelayOnly: this.forceRelayOnly,
9573
+ selectedLocalType: pair.localType,
9574
+ selectedLocalAddr: pair.localAddr,
9575
+ selectedRemoteType: pair.remoteType,
9576
+ selectedRemoteAddr: pair.remoteAddr
9577
+ }
9578
+ });
9579
+ }
9580
+ /** Build PeerConnection options including H.264 codec config. */
9581
+ async buildPcOptions() {
9582
+ const werift = await loadWerift();
9583
+ const iceServers = [];
9584
+ for (const entry of this.iceConfig?.iceServers ?? []) {
9585
+ const urlList = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
9586
+ for (const url of urlList) {
9587
+ iceServers.push({
9588
+ urls: url,
9589
+ ...entry.username !== void 0 ? { username: entry.username } : {},
9590
+ ...entry.credential !== void 0 ? { credential: entry.credential } : {}
9591
+ });
9592
+ }
9593
+ }
9594
+ const rtcpFeedback = [
9595
+ { type: "transport-cc" },
9596
+ { type: "ccm", parameter: "fir" },
9597
+ { type: "nack" },
9598
+ { type: "nack", parameter: "pli" },
9599
+ { type: "goog-remb" }
9600
+ ];
9601
+ const h264Codec = new werift.RTCRtpCodecParameters({
9602
+ mimeType: "video/H264",
9603
+ clockRate: 9e4,
9604
+ payloadType: 96,
9605
+ // Constrained Baseline 3.1. iOS Safari WebRTC ONLY negotiates Constrained
9606
+ // Baseline for H.264 RECEIVE — offering Main (4d0033) or High (640034)
9607
+ // makes werift's handleAnswer throw "negotiate codecs failed" because iOS
9608
+ // answers Baseline and werift can't reconcile. So Baseline is the only
9609
+ // value that negotiates with iOS; the catch is iOS then decodes ONLY
9610
+ // Baseline, so the CAMERA must emit Baseline for the picture to render
9611
+ // (set the camera/substream to a low/baseline profile). Desktop Chrome
9612
+ // decodes any profile regardless. For a Main/High camera that can't be
9613
+ // set to Baseline, the only werift-passthrough route to iOS is H.265
9614
+ // (iOS negotiates + HW-decodes HEVC natively); H.264 needs transcoding.
9615
+ parameters: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
9616
+ rtcpFeedback
8751
9617
  });
8752
9618
  const h265Codec = new werift.RTCRtpCodecParameters({
8753
9619
  mimeType: "video/H265",
@@ -8770,13 +9636,20 @@ class AdaptiveSession {
8770
9636
  })
8771
9637
  ]
8772
9638
  },
8773
- // RTP header extensions required for BUNDLE demuxing and congestion control.
8774
- // Without sdes:mid, browsers cannot demux incoming RTP on a BUNDLE'd connection.
9639
+ // RTP header extensions KEEP `sdes:mid` only (required for BUNDLE
9640
+ // demux; without it browsers can't route incoming RTP to the right
9641
+ // m-line). DROP `transport-wide-cc` + `abs-send-time`: werift emits
9642
+ // unreliable timing values on those, which a Chrome receiver feeds into
9643
+ // its jitter buffer. On a ~zero-jitter LAN the bad timing is harmless,
9644
+ // but over a real remote path (Tailscale/4G jitter+RTT) the jitter
9645
+ // buffer discards frames as "too late" → never decodes → PLI storm,
9646
+ // even though every packet arrives (browser sends RR, sends NO NACK).
9647
+ // Dropping them lets Chrome time playout off its own receive clock.
9648
+ // (Distinct from the earlier mistaken full-disable that also removed
9649
+ // sdes:mid and regressed LAN demux.)
8775
9650
  headerExtensions: {
8776
9651
  video: [
8777
- { uri: "urn:ietf:params:rtp-hdrext:sdes:mid" },
8778
- { uri: "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01" },
8779
- { uri: "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" }
9652
+ { uri: "urn:ietf:params:rtp-hdrext:sdes:mid" }
8780
9653
  ],
8781
9654
  audio: [
8782
9655
  { uri: "urn:ietf:params:rtp-hdrext:sdes:mid" }
@@ -8784,9 +9657,51 @@ class AdaptiveSession {
8784
9657
  }
8785
9658
  };
8786
9659
  if (iceServers.length > 0) pcOptions.iceServers = iceServers;
9660
+ if (this.forceRelayOnly) pcOptions.iceTransportPolicy = "relay";
9661
+ if (this.debug) {
9662
+ const loggedPairs = /* @__PURE__ */ new Set();
9663
+ pcOptions.iceFilterCandidatePair = (pair) => {
9664
+ try {
9665
+ const lc = pair.localCandidate;
9666
+ const rc = pair.remoteCandidate;
9667
+ const key = `${lc?.type}:${lc?.host}:${lc?.port}|${rc?.type}:${rc?.host}:${rc?.port}`;
9668
+ if (!loggedPairs.has(key)) {
9669
+ loggedPairs.add(key);
9670
+ this.logger.info("ICE candidate pair", {
9671
+ meta: {
9672
+ phase: "session",
9673
+ sessionId: this.sessionId,
9674
+ local: `${lc?.type ?? "?"}/${lc?.host ?? "?"}:${lc?.port ?? "?"}`,
9675
+ remote: `${rc?.type ?? "?"}/${rc?.host ?? "?"}:${rc?.port ?? "?"}`,
9676
+ proto: pair?.protocol?.type
9677
+ }
9678
+ });
9679
+ }
9680
+ } catch {
9681
+ }
9682
+ return true;
9683
+ };
9684
+ }
8787
9685
  if (this.iceConfig?.portRange) pcOptions.icePortRange = this.iceConfig.portRange;
8788
- if (this.iceConfig?.additionalHostAddresses?.length) {
8789
- pcOptions.iceAdditionalHostAddresses = [...this.iceConfig.additionalHostAddresses];
9686
+ const tailscaleHosts = getTailscaleHostAddresses();
9687
+ const mergedHostAddrs = [
9688
+ .../* @__PURE__ */ new Set([
9689
+ ...this.iceConfig?.additionalHostAddresses ?? [],
9690
+ ...tailscaleHosts
9691
+ ])
9692
+ ];
9693
+ if (mergedHostAddrs.length > 0) {
9694
+ pcOptions.iceAdditionalHostAddresses = mergedHostAddrs;
9695
+ if (tailscaleHosts.length > 0) {
9696
+ this.logger.info("offering Tailscale host candidate(s)", {
9697
+ meta: {
9698
+ phase: "session",
9699
+ sessionId: this.sessionId,
9700
+ tailscaleHosts: tailscaleHosts.join(","),
9701
+ additionalHostAddresses: mergedHostAddrs.join(",")
9702
+ }
9703
+ });
9704
+ }
8790
9705
  }
8791
9706
  return { werift, pcOptions };
8792
9707
  }
@@ -8795,9 +9710,10 @@ class AdaptiveSession {
8795
9710
  const { werift, pcOptions } = await this.buildPcOptions();
8796
9711
  this.pc = new werift.RTCPeerConnection(pcOptions);
8797
9712
  this.pc.iceConnectionStateChange.subscribe((state) => {
8798
- this.logger.info("ICE state changed", { meta: { phase: "session", sessionId: this.sessionId, state } });
9713
+ this.logger.info("ICE state changed", { meta: { phase: "session", sessionId: this.sessionId, deviceId: this.deviceId, state, forceRelayOnly: this.forceRelayOnly } });
8799
9714
  if (state === "connected") {
8800
9715
  this.state = "connected";
9716
+ this.logNominatedPair();
8801
9717
  this.startStatsCollection();
8802
9718
  } else if (state === "disconnected" || state === "failed" || state === "closed") {
8803
9719
  this.state = state === "disconnected" ? "disconnected" : "closed";
@@ -8830,16 +9746,7 @@ class AdaptiveSession {
8830
9746
  }
8831
9747
  const offer = await this.pc.createOffer();
8832
9748
  await this.pc.setLocalDescription(offer);
8833
- await new Promise((resolve) => {
8834
- if (this.pc?.iceGatheringState === "complete") {
8835
- resolve();
8836
- return;
8837
- }
8838
- this.pc?.iceGatheringStateChange.subscribe((state) => {
8839
- if (state === "complete") resolve();
8840
- });
8841
- setTimeout(resolve, 5e3);
8842
- });
9749
+ await this.waitForIceGatheringFast();
8843
9750
  let finalSdp = this.pc.localDescription?.sdp ?? offer.sdp;
8844
9751
  finalSdp = finalSdp.replace(/a=setup:active\r?\n/g, "a=setup:actpass\r\n");
8845
9752
  this.state = "connecting";
@@ -8864,8 +9771,8 @@ class AdaptiveSession {
8864
9771
  const resolvedSdp = await resolveMdnsCandidatesInSdp(answer.sdp, this.logger, `session:${this.sessionId}`);
8865
9772
  const desc = new werift.RTCSessionDescription(resolvedSdp, answer.type);
8866
9773
  await this.pc.setRemoteDescription(desc);
8867
- const answerVideoCodec = resolvedSdp.match(/a=rtpmap:\d+ (H264|H265)\/90000/i)?.[1]?.toUpperCase();
8868
- if (answerVideoCodec === "H264" || answerVideoCodec === "H265") {
9774
+ const answerVideoCodec = detectNegotiatedVideoCodec(resolvedSdp);
9775
+ if (answerVideoCodec) {
8869
9776
  this.negotiatedCodec = answerVideoCodec;
8870
9777
  }
8871
9778
  this.logger.info("Codec negotiated", {
@@ -8905,27 +9812,49 @@ class AdaptiveSession {
8905
9812
  const { werift, pcOptions } = await this.buildPcOptions();
8906
9813
  this.pc = new werift.RTCPeerConnection(pcOptions);
8907
9814
  this.pc.iceConnectionStateChange.subscribe((state) => {
8908
- this.logger.debug("ICE state", { meta: { phase: "session", sessionId: this.sessionId, state } });
9815
+ this.logger.info("ICE state", { meta: { phase: "session", sessionId: this.sessionId, deviceId: this.deviceId, state, forceRelayOnly: this.forceRelayOnly } });
8909
9816
  if (state === "connected") {
8910
9817
  this.state = "connected";
9818
+ this.logNominatedPair();
8911
9819
  this.startStatsCollection();
8912
9820
  } else if (state === "disconnected" || state === "failed" || state === "closed") {
8913
9821
  this.state = state === "disconnected" ? "disconnected" : "closed";
8914
9822
  void this.close();
8915
9823
  }
8916
9824
  });
9825
+ this.pc.onIceCandidate.subscribe((c) => {
9826
+ if (!c || !c.candidate) {
9827
+ this.iceGatheringComplete = true;
9828
+ return;
9829
+ }
9830
+ this.localIceCandidates.push({
9831
+ candidate: c.candidate,
9832
+ sdpMid: c.sdpMid ?? null,
9833
+ sdpMLineIndex: c.sdpMLineIndex ?? null
9834
+ });
9835
+ });
8917
9836
  const resolvedOfferSdp = await resolveMdnsCandidatesInSdp(clientOffer.sdp, this.logger, `session:${this.sessionId}`);
9837
+ const offerCandidates = resolvedOfferSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
9838
+ if (this.debug) {
9839
+ this.logger.info("client offer ICE candidates", {
9840
+ meta: { phase: "session", sessionId: this.sessionId, count: offerCandidates.length, candidates: offerCandidates }
9841
+ });
9842
+ }
8918
9843
  const remoteDesc = new werift.RTCSessionDescription(resolvedOfferSdp, clientOffer.type);
8919
9844
  await this.pc.setRemoteDescription(remoteDesc);
8920
9845
  const transceivers = this.pc.getTransceivers();
8921
9846
  for (const t of transceivers) {
8922
9847
  const kind = t.receiver?.track?.kind ?? t.kind;
8923
9848
  if (kind === "video" && !this.videoTrack) {
9849
+ t.setDirection("sendonly");
8924
9850
  this.videoTrack = new werift.MediaStreamTrack({ kind: "video" });
8925
9851
  await t.sender.replaceTrack(this.videoTrack);
9852
+ this.videoSender = t.sender;
8926
9853
  } else if (kind === "audio" && !this.audioTrack) {
9854
+ t.setDirection("sendonly");
8927
9855
  this.audioTrack = new werift.MediaStreamTrack({ kind: "audio" });
8928
9856
  await t.sender.replaceTrack(this.audioTrack);
9857
+ this.audioSender = t.sender;
8929
9858
  }
8930
9859
  }
8931
9860
  if (!this.videoTrack) {
@@ -8933,28 +9862,76 @@ class AdaptiveSession {
8933
9862
  meta: { phase: "session", sessionId: this.sessionId }
8934
9863
  });
8935
9864
  this.videoTrack = new werift.MediaStreamTrack({ kind: "video" });
8936
- this.pc.addTransceiver(this.videoTrack, { direction: "sendonly" });
9865
+ const videoTransceiver = this.pc.addTransceiver(this.videoTrack, { direction: "sendonly" });
9866
+ this.videoSender = videoTransceiver.sender;
8937
9867
  }
8938
9868
  if (!this.audioTrack) {
8939
9869
  this.logger.warn("No audio transceiver found in offer, adding one", {
8940
9870
  meta: { phase: "session", sessionId: this.sessionId }
8941
9871
  });
8942
9872
  this.audioTrack = new werift.MediaStreamTrack({ kind: "audio" });
8943
- this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
9873
+ const audioTransceiver = this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
9874
+ this.audioSender = audioTransceiver.sender;
8944
9875
  }
8945
9876
  const answerDesc = await this.pc.createAnswer();
8946
- await this.pc.setLocalDescription(answerDesc);
9877
+ void this.pc.setLocalDescription(answerDesc).catch((e) => {
9878
+ this.logger.warn("setLocalDescription failed", { meta: { phase: "session", sessionId: this.sessionId, error: index.errMsg(e) } });
9879
+ });
9880
+ const finalSdp = this.pc?.localDescription?.sdp ?? answerDesc.sdp;
8947
9881
  this.state = "connecting";
8948
- this.logger.info("WHEP answer created", { meta: { phase: "session", sessionId: this.sessionId } });
9882
+ const answerVideoCodec = detectNegotiatedVideoCodec(finalSdp);
9883
+ if (answerVideoCodec) {
9884
+ this.negotiatedCodec = answerVideoCodec;
9885
+ }
9886
+ this.logger.info("Codec negotiated (client-offer)", {
9887
+ meta: { phase: "session", sessionId: this.sessionId, source: this.sourceCodec, negotiated: this.negotiatedCodec, needsTranscode: this.needsTranscode }
9888
+ });
9889
+ if (this.debug) {
9890
+ const gatheredCandidates = finalSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
9891
+ this.logger.info("WHEP answer created", {
9892
+ meta: { phase: "session", sessionId: this.sessionId, candidateCount: gatheredCandidates.length, candidates: gatheredCandidates }
9893
+ });
9894
+ }
9895
+ void this.startFeedingWhenDtlsReady();
9896
+ return { sdp: finalSdp, type: "answer" };
9897
+ }
9898
+ /**
9899
+ * Wait for the video sender's DTLS transport to connect, then start
9900
+ * feeding. Used by the client-offer (`handleOffer`) path, which must
9901
+ * return its SDP answer before the browser performs the DTLS handshake —
9902
+ * so feeding can only safely begin once the transport is up. Mirrors the
9903
+ * inline DTLS wait `handleAnswer` does for the server-offer path. Bounded
9904
+ * (10s) so a never-connecting session doesn't leak the wait forever.
9905
+ */
9906
+ async startFeedingWhenDtlsReady() {
9907
+ const dtlsTransport = this.videoSender?.dtlsTransport;
9908
+ if (dtlsTransport && dtlsTransport.state !== "connected") {
9909
+ const deadline = Date.now() + 1e4;
9910
+ while (dtlsTransport.state !== "connected" && Date.now() < deadline && !this.closed) {
9911
+ await new Promise((r) => setTimeout(r, 100));
9912
+ }
9913
+ this.logger.debug("client-offer DTLS wait complete", {
9914
+ meta: { phase: "session", sessionId: this.sessionId, state: dtlsTransport.state }
9915
+ });
9916
+ }
9917
+ if (this.closed) return;
8949
9918
  this.startFeedingFrames();
8950
- return { sdp: answerDesc.sdp, type: "answer" };
8951
9919
  }
8952
- /** Add ICE candidate. */
9920
+ /** Add a remote (client) ICE candidate — trickle ICE client→server. */
8953
9921
  async addIceCandidate(candidate) {
8954
9922
  if (!this.pc) throw new Error("Call createOffer() first");
8955
9923
  const werift = await loadWerift();
8956
9924
  await this.pc.addIceCandidate(new werift.RTCIceCandidate(candidate));
8957
9925
  }
9926
+ /**
9927
+ * Snapshot of the server's locally-gathered ICE candidates for trickle
9928
+ * polling (server→client). The client polls this after receiving the bare
9929
+ * answer and adds each candidate; `done` flips true when gathering finishes
9930
+ * so the client can stop polling.
9931
+ */
9932
+ getIceCandidatesSnapshot() {
9933
+ return { candidates: [...this.localIceCandidates], done: this.iceGatheringComplete };
9934
+ }
8958
9935
  /**
8959
9936
  * Detach the frame source (for connection pooling).
8960
9937
  * The session stays alive (ICE/DTLS connected) but stops feeding frames.
@@ -9062,46 +10039,111 @@ class AdaptiveSession {
9062
10039
  codecInfo
9063
10040
  );
9064
10041
  }
10042
+ /**
10043
+ * Seed the H.264 repacketizer's SPS/PPS from SDP-published parameter sets
10044
+ * so the STAP-A codec packet can precede the first IDR even when the
10045
+ * camera doesn't emit param sets in-band. H.264 sibling of
10046
+ * `seedH265CodecInfoFromSdp`. No-op on non-H.264 sessions.
10047
+ */
10048
+ seedH264CodecInfoFromSdp(parameterSets) {
10049
+ if (this.negotiatedCodec !== "H264") return;
10050
+ if (!parameterSets.length) return;
10051
+ this.ensureH264Repacketizer();
10052
+ const rep = this.h264Repacketizer;
10053
+ for (const ps of parameterSets) {
10054
+ const nalType = ps[0] & 31;
10055
+ if (nalType === 7) rep.updateSps(Buffer.from(ps));
10056
+ else if (nalType === 8) rep.updatePps(Buffer.from(ps));
10057
+ }
10058
+ if (this.debug) {
10059
+ this.logger.info("seeded H.264 repacketizer codecInfo from SDP", {
10060
+ meta: {
10061
+ phase: "session",
10062
+ sessionId: this.sessionId,
10063
+ psCount: parameterSets.length,
10064
+ hasSps: !!rep.codecInfo?.sps,
10065
+ hasPps: !!rep.codecInfo?.pps
10066
+ }
10067
+ });
10068
+ }
10069
+ }
10070
+ ensureH264Repacketizer() {
10071
+ if (this.h264Repacketizer) return;
10072
+ this.h264Repacketizer = new H264Repacketizer(
10073
+ console,
10074
+ AdaptiveSession.H264_REPACKETIZER_MTU
10075
+ );
10076
+ }
9065
10077
  /**
9066
10078
  * Forward a source RTP video packet (raw on-wire bytes) through the
9067
- * H.265 repacketizer to the browser. Used by the broker's H.265
9068
- * direct-RTP subscription — bypasses the AnnexB→writeVideoNals
9069
- * path entirely.
10079
+ * codec's repacketizer to the browser. Used by the broker's RTSP
10080
+ * direct-RTP subscription for BOTH H.265 and H.264 — bypasses the
10081
+ * AnnexB→writeVideoNals path entirely so the native RTP layout (which
10082
+ * iOS's strict depacketizer requires) is preserved.
9070
10083
  *
9071
10084
  * Drops everything until the SDP answer has been negotiated
9072
- * (`negotiatedCodec`/`videoSender` populated) and silently no-ops on
9073
- * non-H.265 sessions.
10085
+ * (`negotiatedCodec`/`videoSender` populated).
9074
10086
  */
9075
10087
  forwardSourceRtpVideo(rtpData) {
9076
10088
  if (this.closed) return;
9077
- if (this.negotiatedCodec !== "H265") return;
10089
+ const codec = this.negotiatedCodec;
10090
+ if (codec !== "H265" && codec !== "H264") return;
9078
10091
  if (!this.videoSender || !_werift) return;
10092
+ if (this.videoSender.dtlsTransport?.state !== "connected") return;
10093
+ if (!this.rtpBootstrapDone) {
10094
+ this.rtpBootstrapDone = true;
10095
+ const bootstrap = this.rtpBootstrap?.() ?? [];
10096
+ if (bootstrap.length > 0) {
10097
+ let sent = 0;
10098
+ for (const b of bootstrap) {
10099
+ if (this.sendSourceRtpPacket(b, codec)) sent++;
10100
+ }
10101
+ this.logger.info("replayed source-RTP pre-buffer (instant start)", {
10102
+ meta: { phase: "session", sessionId: this.sessionId, codec, packets: bootstrap.length, sent }
10103
+ });
10104
+ return;
10105
+ }
10106
+ }
10107
+ this.sendSourceRtpPacket(rtpData, codec);
10108
+ }
10109
+ /**
10110
+ * Repacketize one source RTP packet and send it on the video sender.
10111
+ * Shared by the live forward path and the pre-buffer bootstrap replay.
10112
+ * Returns true when at least one output packet was sent.
10113
+ */
10114
+ sendSourceRtpPacket(rtpData, codec) {
10115
+ if (!this.videoSender || !_werift) return false;
9079
10116
  const werift = _werift;
9080
- this.ensureH265Repacketizer();
9081
- const rep = this.h265Repacketizer;
9082
10117
  let srcPkt;
9083
10118
  try {
9084
10119
  srcPkt = werift.RtpPacket.deSerialize(rtpData);
9085
10120
  } catch (err) {
9086
- this.logger.warn("H265 RTP deserialize failed", {
9087
- meta: { phase: "session", sessionId: this.sessionId, error: index.errMsg(err), len: rtpData.length }
10121
+ this.logger.warn("source RTP deserialize failed", {
10122
+ meta: { phase: "session", sessionId: this.sessionId, codec, error: index.errMsg(err), len: rtpData.length }
9088
10123
  });
9089
- return;
10124
+ return false;
9090
10125
  }
9091
10126
  if (this.sourceVideoSsrc === null) {
9092
10127
  this.sourceVideoSsrc = srcPkt.header.ssrc;
9093
10128
  }
9094
10129
  const senderCodec = this.videoSender.codec;
9095
- const pt = senderCodec?.payloadType ?? 97;
10130
+ const pt = senderCodec?.payloadType ?? (codec === "H265" ? 97 : 96);
9096
10131
  let outPkts;
9097
10132
  try {
9098
- outPkts = rep.repacketize(srcPkt);
10133
+ if (codec === "H265") {
10134
+ this.ensureH265Repacketizer();
10135
+ outPkts = this.h265Repacketizer.repacketize(srcPkt);
10136
+ } else {
10137
+ this.ensureH264Repacketizer();
10138
+ outPkts = this.h264Repacketizer.repacketize(srcPkt);
10139
+ }
9099
10140
  } catch (err) {
9100
- this.logger.warn("H265 repacketize failed", {
9101
- meta: { phase: "session", sessionId: this.sessionId, error: index.errMsg(err) }
10141
+ this.logger.warn("repacketize failed", {
10142
+ meta: { phase: "session", sessionId: this.sessionId, codec, error: index.errMsg(err) }
9102
10143
  });
9103
- return;
10144
+ return false;
9104
10145
  }
10146
+ let sent = false;
9105
10147
  for (const pkt of outPkts) {
9106
10148
  pkt.header.payloadType = pt;
9107
10149
  if (!this.videoRtpSynced) {
@@ -9120,19 +10162,21 @@ class AdaptiveSession {
9120
10162
  try {
9121
10163
  this.videoSender.sendRtp(pkt);
9122
10164
  this.rtpPacketsSent++;
10165
+ sent = true;
9123
10166
  if (this.debug && (this.rtpPacketsSent === 1 || this.rtpPacketsSent % 500 === 0)) {
9124
- this.logger.info("H265 source-RTP forwarded", {
9125
- meta: { phase: "session", sessionId: this.sessionId, count: this.rtpPacketsSent }
10167
+ this.logger.info("source-RTP forwarded", {
10168
+ meta: { phase: "session", sessionId: this.sessionId, codec, count: this.rtpPacketsSent }
9126
10169
  });
9127
10170
  }
9128
10171
  } catch (err) {
9129
10172
  if (this.rtpPacketsSent <= 10) {
9130
- this.logger.error("sendRtp (h265 forward) error", {
9131
- meta: { phase: "session", sessionId: this.sessionId, error: index.errMsg(err) }
10173
+ this.logger.error("sendRtp (source forward) error", {
10174
+ meta: { phase: "session", sessionId: this.sessionId, codec, error: index.errMsg(err) }
9132
10175
  });
9133
10176
  }
9134
10177
  }
9135
10178
  }
10179
+ return sent;
9136
10180
  }
9137
10181
  // -----------------------------------------------------------------------
9138
10182
  // PLI handling — resend cached keyframe on picture loss
@@ -9141,6 +10185,14 @@ class AdaptiveSession {
9141
10185
  const sender = this.videoSender;
9142
10186
  if (!sender) return;
9143
10187
  const onPli = () => this.handlePli();
10188
+ if (sender.onRtcp) {
10189
+ sender.onRtcp.subscribe((rtcp) => {
10190
+ const reports = rtcp?.reports;
10191
+ if (!reports || reports.length === 0) return;
10192
+ const mine = reports.find((r) => r.ssrc === sender.ssrc) ?? reports[0];
10193
+ if (mine) this.lastRr = mine;
10194
+ });
10195
+ }
9144
10196
  if (sender.onPictureLossIndication) {
9145
10197
  sender.onPictureLossIndication.subscribe(onPli);
9146
10198
  this.logger.debug("PLI listener attached (onPictureLossIndication)", {
@@ -9179,7 +10231,7 @@ class AdaptiveSession {
9179
10231
  totalBytes: this.lastKeyframeNals.reduce((s, n) => s + n.length, 0)
9180
10232
  }
9181
10233
  });
9182
- this.writeVideoNals(this.lastKeyframeNals, this.lastKeyframeRtpTs, this.negotiatedCodec);
10234
+ this.writeVideoNals(this.lastKeyframeNals, this.sendRtpTimestamp(), this.negotiatedCodec);
9183
10235
  }
9184
10236
  // -----------------------------------------------------------------------
9185
10237
  // Frame feeding
@@ -9204,7 +10256,6 @@ class AdaptiveSession {
9204
10256
  startDirectFeed(signal) {
9205
10257
  void (async () => {
9206
10258
  let gotKeyframe = false;
9207
- let videoTimestampBase = null;
9208
10259
  let audioTimestampBase = null;
9209
10260
  let frameCount = 0;
9210
10261
  try {
@@ -9257,10 +10308,7 @@ class AdaptiveSession {
9257
10308
  meta: { phase: "session", sessionId: this.sessionId, frameCount, size: annexB.length, ice: iceState }
9258
10309
  });
9259
10310
  }
9260
- if (videoTimestampBase === null) videoTimestampBase = frame.timestampMicros;
9261
- const rtpTs = Math.floor(
9262
- (frame.timestampMicros - videoTimestampBase) * 9e4 / 1e6
9263
- ) >>> 0;
10311
+ const rtpTs = this.sendRtpTimestamp();
9264
10312
  const isH265 = frame.codec === "H265";
9265
10313
  const allNals = splitAnnexBToNals(annexB);
9266
10314
  const nals = allNals.filter((n) => {
@@ -9318,7 +10366,25 @@ class AdaptiveSession {
9318
10366
  let auNals = nals;
9319
10367
  for (const n of nals) {
9320
10368
  const nalType = n[0] & 31;
9321
- if (nalType === 7) this.lastSps = Buffer.from(n);
10369
+ if (nalType === 7) {
10370
+ this.lastSps = Buffer.from(n);
10371
+ if (!this.profileLogged && n.length >= 4) {
10372
+ this.profileLogged = true;
10373
+ const plid = `${n[1].toString(16).padStart(2, "0")}${n[2].toString(16).padStart(2, "0")}${n[3].toString(16).padStart(2, "0")}`;
10374
+ this.logger.info("camera H.264 SPS profile-level-id", {
10375
+ meta: {
10376
+ phase: "session",
10377
+ sessionId: this.sessionId,
10378
+ deviceId: this.deviceId,
10379
+ actualProfileLevelId: plid,
10380
+ advertisedProfileLevelId: "42e01f",
10381
+ match: plid === "42e01f",
10382
+ profileIdc: n[1],
10383
+ levelIdc: n[3]
10384
+ }
10385
+ });
10386
+ }
10387
+ }
9322
10388
  if (nalType === 8) this.lastPps = Buffer.from(n);
9323
10389
  }
9324
10390
  if (isH264IdrAccessUnit(annexB)) {
@@ -9365,22 +10431,25 @@ class AdaptiveSession {
9365
10431
  }
9366
10432
  })();
9367
10433
  }
9368
- /** Max RTP payload size (MTU 1200 to stay under typical network MTU). */
9369
10434
  /**
9370
- * Transcode feed — source is H.265 but browser only supports H.264.
9371
- * Pipes raw H.265 Annex-B to ffmpeg stdin, reads H.264 from stdout.
10435
+ * Transcode feed — re-encode the source to Constrained Baseline H.264:
10436
+ * H.265→H.264 (browser lacks HEVC) or H.264 Main/High→Baseline (iOS only
10437
+ * decodes Baseline). Pipes the source Annex-B to ffmpeg stdin, reads
10438
+ * Baseline H.264 from stdout, and re-frames it into access units so each
10439
+ * picture is sent with one RTP timestamp (see the stdout handler).
9372
10440
  */
9373
10441
  startTranscodeFeed(signal) {
9374
10442
  const { spawn } = require("node:child_process");
9375
- this.logger.info("Starting H.265→H.264 transcode feed", {
9376
- meta: { phase: "session", sessionId: this.sessionId }
10443
+ const inputFormat = transcodeInputFormat(this.sourceCodec);
10444
+ this.logger.info(`Starting ${this.sourceCodec}→H.264 Baseline transcode feed`, {
10445
+ meta: { phase: "session", sessionId: this.sessionId, sourceCodec: this.sourceCodec, inputFormat }
9377
10446
  });
9378
10447
  const ff = spawn("ffmpeg", [
9379
10448
  "-hide_banner",
9380
10449
  "-loglevel",
9381
10450
  "error",
9382
10451
  "-f",
9383
- "hevc",
10452
+ inputFormat,
9384
10453
  "-i",
9385
10454
  "pipe:0",
9386
10455
  "-c:v",
@@ -9411,18 +10480,24 @@ class AdaptiveSession {
9411
10480
  if (msg.length > 0) this.logger.warn("Transcode ffmpeg stderr", { meta: { sessionId: this.sessionId, msg } });
9412
10481
  });
9413
10482
  let pendingBuf = Buffer.alloc(0);
9414
- let rtpTs = 0;
9415
- const FRAME_INTERVAL_90K = 3e3;
10483
+ let carryNals = [];
9416
10484
  ff.stdout.on("data", (chunk) => {
9417
10485
  pendingBuf = Buffer.concat([pendingBuf, chunk]);
9418
10486
  const lastSc = findLastStartCode(pendingBuf);
9419
10487
  if (lastSc <= 0) return;
9420
10488
  const complete = pendingBuf.subarray(0, lastSc);
9421
10489
  pendingBuf = Buffer.from(pendingBuf.subarray(lastSc));
9422
- const completedNals = splitAnnexBToNals(complete);
9423
- if (completedNals.length === 0) return;
9424
- rtpTs = rtpTs + FRAME_INTERVAL_90K >>> 0;
9425
- this.writeVideoNals(completedNals, rtpTs, "H264");
10490
+ const nals = carryNals.concat(splitAnnexBToNals(complete));
10491
+ const accessUnits = groupNalsIntoAccessUnits(nals);
10492
+ if (accessUnits.length <= 1) {
10493
+ carryNals = nals;
10494
+ return;
10495
+ }
10496
+ carryNals = accessUnits[accessUnits.length - 1];
10497
+ for (let i = 0; i < accessUnits.length - 1; i++) {
10498
+ const auNals = accessUnits[i].filter((n) => (n[0] & 31) !== 6);
10499
+ if (auNals.length > 0) this.writeVideoNals(auNals, this.sendRtpTimestamp(), "H264");
10500
+ }
9426
10501
  });
9427
10502
  void (async () => {
9428
10503
  try {
@@ -9447,9 +10522,36 @@ class AdaptiveSession {
9447
10522
  }
9448
10523
  })();
9449
10524
  }
9450
- static MAX_RTP_PAYLOAD = 1200;
10525
+ // 1100 (not 1200): keep the full wire packet under the Tailscale/WireGuard
10526
+ // overlay MTU (1280). See H265_REPACKETIZER_MTU for the rationale — at 1200
10527
+ // a keyframe's RTP packets are ~1266 B and silently drop over the overlay,
10528
+ // causing a never-decoding PLI storm that works fine on a 1500-MTU LAN.
10529
+ static MAX_RTP_PAYLOAD = 1100;
9451
10530
  rtpPacketsSent = 0;
9452
10531
  rtpLogCounter = 0;
10532
+ /**
10533
+ * RTP timestamp (90 kHz) derived from the hub's MONOTONIC clock at the
10534
+ * moment of sending — NOT the camera capture time.
10535
+ *
10536
+ * Forwarding live media with capture-time RTP timestamps creates a cadence
10537
+ * mismatch vs the actual send time: the pipeline/decoder adds variable
10538
+ * latency between capture (`frame.timestampMicros`) and the instant we push
10539
+ * the packets out. A remote receiver measures that mismatch as high RTCP
10540
+ * jitter (~80 ms observed over Tailscale, with ZERO packet loss) — its
10541
+ * jitter buffer can't lock onto a stable clock, so frames are discarded and
10542
+ * it PLI-storms. A ~0-RTT LAN absorbs it; a real remote path does not.
10543
+ *
10544
+ * Stamping off send time makes the RTP-timestamp cadence equal the actual
10545
+ * transmit cadence, so arrival cadence matches and the receiver's jitter
10546
+ * collapses to true network jitter. Same effect ffmpeg/libwebrtc achieve by
10547
+ * pacing output. All video send paths (live feed + PLI keyframe resend)
10548
+ * share this one clock so timestamps stay monotonic.
10549
+ */
10550
+ sendRtpTimestamp() {
10551
+ const now = performance.now();
10552
+ if (this.videoRtpClockBaseMs === null) this.videoRtpClockBaseMs = now;
10553
+ return Math.floor((now - this.videoRtpClockBaseMs) * 90) >>> 0;
10554
+ }
9453
10555
  writeVideoNals(nals, rtpTs, codec) {
9454
10556
  if (!this.videoSender || !_werift) {
9455
10557
  if (this.rtpLogCounter === 0) {
@@ -9594,40 +10696,52 @@ class AdaptiveSession {
9594
10696
  // RTCP stats collection
9595
10697
  // -----------------------------------------------------------------------
9596
10698
  startStatsCollection() {
9597
- if (this.statsTimer || !this.onStats) return;
10699
+ if (this.statsTimer) return;
9598
10700
  this.statsTimer = setInterval(() => {
9599
10701
  if (!this.pc || this.closed) return;
9600
10702
  this.collectStats();
9601
10703
  }, 3e3);
9602
10704
  }
9603
10705
  collectStats() {
9604
- if (!this.pc || !this.onStats) return;
9605
- try {
9606
- const senders = this.pc.getSenders?.() ?? [];
9607
- for (const sender of senders) {
9608
- const track = sender.track;
9609
- if (!track || track.kind !== "video") continue;
9610
- const report = sender.lastReceiverReport ?? sender.rtcpReport;
9611
- if (!report) continue;
9612
- const fractionLost = report.fractionLost ?? 0;
9613
- const packetsLost = report.packetsLost ?? report.cumulativeLost ?? 0;
9614
- const jitter = report.jitter ?? 0;
9615
- const rtt = report.roundTripTime ?? report.rtt ?? 0;
9616
- const packetLoss = fractionLost / 256;
9617
- this.onStats({
9618
- sessionId: this.sessionId,
9619
- packetLoss,
9620
- jitterMs: jitter,
9621
- rttMs: rtt * 1e3,
9622
- // seconds → ms
9623
- packetsReceived: 0,
9624
- // Not available from sender side
9625
- packetsLost,
9626
- timestamp: Date.now()
9627
- });
9628
- return;
10706
+ if (!this.pc) return;
10707
+ const report = this.lastRr ?? void 0;
10708
+ const fractionLost = report?.fractionLost ?? 0;
10709
+ const packetsLost = report?.packetsLost ?? report?.cumulativeLost ?? 0;
10710
+ const jitter = report?.jitter ?? 0;
10711
+ const rtt = report?.roundTripTime ?? report?.rtt ?? 0;
10712
+ const nominated = this.readNominatedPair();
10713
+ this.logger.info("webrtc media diag", {
10714
+ meta: {
10715
+ phase: "session",
10716
+ sessionId: this.sessionId,
10717
+ deviceId: this.deviceId,
10718
+ rtpPacketsSent: this.rtpPacketsSent,
10719
+ dtls: this.videoSender?.dtlsTransport?.state ?? "unknown",
10720
+ ice: this.state,
10721
+ forceRelayOnly: this.forceRelayOnly,
10722
+ hasReceiverReport: !!report,
10723
+ fractionLostPct: report ? Math.round(fractionLost / 256 * 100) : -1,
10724
+ packetsLost,
10725
+ // video RTP clock is 90 kHz → jitter ticks / 90 = milliseconds.
10726
+ jitterMs: report ? Math.round(jitter / 90) : -1,
10727
+ rttMs: Math.round(rtt * 1e3),
10728
+ selectedLocalType: nominated?.localType ?? "none",
10729
+ selectedLocalAddr: nominated?.localAddr ?? "none",
10730
+ selectedRemoteType: nominated?.remoteType ?? "none",
10731
+ selectedRemoteAddr: nominated?.remoteAddr ?? "none"
9629
10732
  }
9630
- } catch {
10733
+ });
10734
+ if (report && this.onStats) {
10735
+ this.onStats({
10736
+ sessionId: this.sessionId,
10737
+ packetLoss: fractionLost / 256,
10738
+ // Fraction lost is 0–255
10739
+ jitterMs: jitter,
10740
+ rttMs: rtt * 1e3,
10741
+ packetsReceived: 0,
10742
+ packetsLost,
10743
+ timestamp: Date.now()
10744
+ });
9631
10745
  }
9632
10746
  }
9633
10747
  }
@@ -9637,6 +10751,49 @@ function findLastStartCode(buf) {
9637
10751
  }
9638
10752
  return -1;
9639
10753
  }
10754
+ function resolveBrokerTranscodeToBaseline(sessionCodec, sourceProfileLevelId) {
10755
+ if (sessionCodec !== "H264") return false;
10756
+ if (!sourceProfileLevelId) return false;
10757
+ return !isBaselineProfileLevelId(sourceProfileLevelId);
10758
+ }
10759
+ function h264ProfileIdc(profileLevelId) {
10760
+ if (profileLevelId.length < 2) return 0;
10761
+ const idc = Number.parseInt(profileLevelId.slice(0, 2), 16);
10762
+ return Number.isNaN(idc) ? 0 : idc;
10763
+ }
10764
+ function extractOfferedH264ProfileLevelIds(sdp) {
10765
+ const ids = [];
10766
+ const re = /profile-level-id=([0-9a-fA-F]{6})/g;
10767
+ let m;
10768
+ while ((m = re.exec(sdp)) !== null) ids.push(m[1].toLowerCase());
10769
+ return ids;
10770
+ }
10771
+ function resolveClientOfferTranscodeToBaseline(sessionCodec, sourceProfileLevelId, offeredProfileLevelIds) {
10772
+ if (sessionCodec !== "H264") return false;
10773
+ if (!sourceProfileLevelId) return false;
10774
+ if (isBaselineProfileLevelId(sourceProfileLevelId)) return false;
10775
+ const sourceIdc = h264ProfileIdc(sourceProfileLevelId);
10776
+ const maxOfferedIdc = offeredProfileLevelIds.reduce(
10777
+ (max, plid) => Math.max(max, h264ProfileIdc(plid)),
10778
+ 0
10779
+ );
10780
+ return maxOfferedIdc < sourceIdc;
10781
+ }
10782
+ function deriveH264ProfileLevelId(sdpParameterSets, preBuffer) {
10783
+ if (sdpParameterSets) {
10784
+ for (const ps of sdpParameterSets) {
10785
+ if (ps.length >= 4 && (ps[0] & 31) === 7) {
10786
+ return Buffer.from([ps[1], ps[2], ps[3]]).toString("hex");
10787
+ }
10788
+ }
10789
+ }
10790
+ for (const pkt of preBuffer) {
10791
+ if (pkt.type !== "video") continue;
10792
+ const { profileLevelId } = extractH264ParamSets(convertH264ToAnnexB(pkt.data));
10793
+ if (profileLevelId) return profileLevelId;
10794
+ }
10795
+ return void 0;
10796
+ }
9640
10797
  const LABEL_DEFAULTS = {
9641
10798
  high: { pixels: 1920 * 1080, bitrateKbps: 4e3 },
9642
10799
  mid: { pixels: 1280 * 720, bitrateKbps: 1200 },
@@ -9733,14 +10890,19 @@ class BrokerWebrtcServer {
9733
10890
  async createSession(streamId, hints = {}, opts = {}) {
9734
10891
  const setup = await this.setupSessionForBroker(streamId, hints, opts);
9735
10892
  try {
10893
+ if (opts.relayOnly === true) {
10894
+ setup.session.setForceRelayOnly(true);
10895
+ }
9736
10896
  const offer = await setup.session.createOffer();
9737
10897
  setup.sessionLogger.info("WebRTC session created", {
9738
10898
  meta: {
9739
10899
  sessionId: setup.sessionId,
10900
+ deviceId: setup.deviceId,
9740
10901
  brokerId: setup.brokerId,
9741
10902
  iceServers: setup.iceServerCount,
9742
10903
  codec: setup.sessionCodec,
9743
- useH265Repacketizer: setup.useH265Repacketizer
10904
+ useRtpRepacketizer: setup.useRtpRepacketizer,
10905
+ relayOnly: opts.relayOnly === true
9744
10906
  }
9745
10907
  });
9746
10908
  return { sessionId: setup.sessionId, sdpOffer: offer.sdp };
@@ -9764,16 +10926,21 @@ class BrokerWebrtcServer {
9764
10926
  * RTC controller / Alexa handler closes the session the same way.
9765
10927
  */
9766
10928
  async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
9767
- const setup = await this.setupSessionForBroker(streamId, hints, opts);
10929
+ const setup = await this.setupSessionForBroker(streamId, hints, { ...opts, clientOfferSdp });
9768
10930
  try {
10931
+ if (opts.relayOnly === true) {
10932
+ setup.session.setForceRelayOnly(true);
10933
+ }
9769
10934
  const answer = await setup.session.handleOffer({ sdp: clientOfferSdp, type: "offer" });
9770
10935
  setup.sessionLogger.info("WebRTC session created (client-offer)", {
9771
10936
  meta: {
9772
10937
  sessionId: setup.sessionId,
10938
+ deviceId: setup.deviceId,
9773
10939
  brokerId: setup.brokerId,
9774
10940
  iceServers: setup.iceServerCount,
9775
10941
  codec: setup.sessionCodec,
9776
- useH265Repacketizer: setup.useH265Repacketizer
10942
+ useRtpRepacketizer: setup.useRtpRepacketizer,
10943
+ relayOnly: opts.relayOnly === true
9777
10944
  }
9778
10945
  });
9779
10946
  return { sessionId: setup.sessionId, sdpAnswer: answer.sdp };
@@ -9803,20 +10970,21 @@ class BrokerWebrtcServer {
9803
10970
  const broker = this.brokers.get(brokerId);
9804
10971
  if (!broker) throw new Error(`No broker for stream "${streamId}" (resolved: "${brokerId}")`);
9805
10972
  const slashIdx = brokerId.indexOf("/");
9806
- const deviceTags = slashIdx > 0 ? {
9807
- deviceId: Number.parseInt(brokerId.slice(0, slashIdx), 10),
9808
- camStreamId: brokerId.slice(slashIdx + 1)
9809
- } : { deviceId: -1, camStreamId: brokerId };
10973
+ const deviceId = deviceIdFromBrokerId(brokerId);
10974
+ const deviceTags = {
10975
+ deviceId,
10976
+ camStreamId: slashIdx > 0 ? brokerId.slice(slashIdx + 1) : brokerId
10977
+ };
9810
10978
  const sessionLogger = this.logger.withTags(deviceTags);
9811
10979
  const brokerCodec = broker.getStats().codec ?? "h264";
9812
10980
  const isHevc = brokerCodec === "h265" || brokerCodec === "hevc";
9813
10981
  const sessionCodec = isHevc ? "H265" : "H264";
9814
10982
  const sourceType = broker.getSourceType();
9815
10983
  const isRtp = broker.isRtpSource();
9816
- const useH265Repacketizer = sessionCodec === "H265" && isRtp;
10984
+ const useRtpRepacketizer = isRtp;
9817
10985
  sessionLogger.info(
9818
- `WebRTC session: codec=${sessionCodec} brokerCodec=${brokerCodec} sourceType=${sourceType ?? "null"} isRtp=${isRtp} repacketizer=${useH265Repacketizer}`,
9819
- { meta: { brokerId, sessionCodec, brokerCodec, sourceType, isRtp, useH265Repacketizer } }
10986
+ `WebRTC session: codec=${sessionCodec} brokerCodec=${brokerCodec} sourceType=${sourceType ?? "null"} isRtp=${isRtp} repacketizer=${useRtpRepacketizer}`,
10987
+ { meta: { brokerId, sessionCodec, brokerCodec, sourceType, isRtp, useRtpRepacketizer } }
9820
10988
  );
9821
10989
  const { source, pushFrame, close: closeSource } = createPushFrameSource();
9822
10990
  const pendingParamNals = [];
@@ -9824,7 +10992,7 @@ class BrokerWebrtcServer {
9824
10992
  let seenRealPacket = false;
9825
10993
  const unsubBroker = broker.onEncodedData((packet) => {
9826
10994
  if (packet.type === "video") {
9827
- if (useH265Repacketizer) return;
10995
+ if (useRtpRepacketizer) return;
9828
10996
  if (!packet.isPlaceholder && !seenRealPacket) {
9829
10997
  seenRealPacket = true;
9830
10998
  pendingParamNals.length = 0;
@@ -9883,7 +11051,7 @@ class BrokerWebrtcServer {
9883
11051
  const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
9884
11052
  for (const pkt of preBuffer) {
9885
11053
  if (pkt.type === "video") {
9886
- if (useH265Repacketizer) continue;
11054
+ if (useRtpRepacketizer) continue;
9887
11055
  const annexB = convertH264ToAnnexB(pkt.data);
9888
11056
  const nalTypeInfo = detectFirstNalType(annexB, isHevc);
9889
11057
  if (nalTypeInfo.isParamSet) {
@@ -9915,40 +11083,68 @@ class BrokerWebrtcServer {
9915
11083
  }
9916
11084
  const sessionId = requestedSessionId ?? crypto.randomUUID();
9917
11085
  const iceServers = await this.resolveIceServers();
11086
+ const sourceProfileLevelId = sessionCodec === "H264" ? deriveH264ProfileLevelId(broker.getSdpParameterSets(), broker.getPreBuffer()) : void 0;
11087
+ const offeredProfileLevelIds = opts.clientOfferSdp ? extractOfferedH264ProfileLevelIds(opts.clientOfferSdp) : void 0;
11088
+ const transcodeToBaseline = offeredProfileLevelIds ? resolveClientOfferTranscodeToBaseline(sessionCodec, sourceProfileLevelId, offeredProfileLevelIds) : resolveBrokerTranscodeToBaseline(sessionCodec, sourceProfileLevelId);
11089
+ if (transcodeToBaseline) {
11090
+ sessionLogger.info(
11091
+ "WebRTC: source H.264 is non-Baseline and the client cannot decode it — re-encoding egress to Constrained Baseline",
11092
+ { meta: { brokerId, sessionId, sourceProfileLevelId, offeredProfileLevelIds, clientOffer: opts.clientOfferSdp !== void 0 } }
11093
+ );
11094
+ } else if (offeredProfileLevelIds && sourceProfileLevelId && !isBaselineProfileLevelId(sourceProfileLevelId)) {
11095
+ sessionLogger.info(
11096
+ "WebRTC: client offered a profile that decodes the non-Baseline H.264 source — passthrough (no re-encode)",
11097
+ { meta: { brokerId, sessionId, sourceProfileLevelId, offeredProfileLevelIds } }
11098
+ );
11099
+ }
9918
11100
  const session = new AdaptiveSession({
9919
11101
  sessionId,
9920
11102
  source,
9921
11103
  sourceCodec: sessionCodec,
11104
+ transcodeToBaseline,
9922
11105
  iceConfig: {
9923
11106
  iceServers,
9924
11107
  portRange: this.icePortRange,
9925
11108
  additionalHostAddresses: this.iceAdditionalHostAddresses
9926
11109
  },
9927
- logger: this.logger,
11110
+ // Device-tagged logger so every WebRTC session lifecycle line
11111
+ // (ICE state, candidate pairs, nominated pair, feed progress)
11112
+ // carries `deviceId` — operator log filters scoped to one device
11113
+ // no longer drop session logs as `dev=?`.
11114
+ logger: sessionLogger,
11115
+ deviceId,
11116
+ // Source-RTP pre-buffer accessor for instant start on the repacketizer
11117
+ // path — the session replays the current GOP on its first forwarded
11118
+ // packet. Only meaningful for RTP sources (returns [] otherwise).
11119
+ rtpBootstrap: useRtpRepacketizer ? (() => broker.getRtpPreBuffer()) : void 0,
9928
11120
  debug: opts.streamingDebug === true
9929
11121
  });
11122
+ const seedFromSdp = (ps) => {
11123
+ if (sessionCodec === "H265") session.seedH265CodecInfoFromSdp(ps);
11124
+ else session.seedH264CodecInfoFromSdp(ps);
11125
+ };
9930
11126
  let unsubRtp = null;
9931
11127
  let unsubSdpParams = null;
9932
- if (useH265Repacketizer) {
11128
+ if (useRtpRepacketizer) {
9933
11129
  const sdpPs = broker.getSdpParameterSets();
9934
11130
  if (sdpPs && sdpPs.length > 0) {
9935
- session.seedH265CodecInfoFromSdp(sdpPs);
9936
- sessionLogger.info("H.265 session seeded from SDP at create-time", {
9937
- meta: { sessionId, brokerId, psCount: sdpPs.length }
11131
+ seedFromSdp(sdpPs);
11132
+ sessionLogger.info("RTP session seeded from SDP at create-time", {
11133
+ meta: { sessionId, brokerId, codec: sessionCodec, psCount: sdpPs.length }
9938
11134
  });
9939
11135
  } else {
9940
- sessionLogger.info("H.265 session deferred: SDP params not ready, subscribing for late delivery", {
9941
- meta: { sessionId, brokerId }
11136
+ sessionLogger.info("RTP session deferred: SDP params not ready, subscribing for late delivery", {
11137
+ meta: { sessionId, brokerId, codec: sessionCodec }
9942
11138
  });
9943
11139
  unsubSdpParams = broker.onSdpParameterSets((ps) => {
9944
11140
  try {
9945
- session.seedH265CodecInfoFromSdp(ps);
9946
- sessionLogger.info("H.265 session seeded from SDP late delivery", {
9947
- meta: { sessionId, brokerId, psCount: ps.length }
11141
+ seedFromSdp(ps);
11142
+ sessionLogger.info("RTP session seeded from SDP late delivery", {
11143
+ meta: { sessionId, brokerId, codec: sessionCodec, psCount: ps.length }
9948
11144
  });
9949
11145
  } catch (err) {
9950
- sessionLogger.warn("seedH265CodecInfoFromSdp threw", {
9951
- meta: { sessionId, error: index.errMsg(err) }
11146
+ sessionLogger.warn("seedCodecInfoFromSdp threw", {
11147
+ meta: { sessionId, codec: sessionCodec, error: index.errMsg(err) }
9952
11148
  });
9953
11149
  }
9954
11150
  });
@@ -9957,15 +11153,15 @@ class BrokerWebrtcServer {
9957
11153
  unsubRtp = broker.onVideoRtp((rtpData) => {
9958
11154
  if (!firstRtpForwarded) {
9959
11155
  firstRtpForwarded = true;
9960
- sessionLogger.info("H.265 session: first source RTP forwarded to repacketizer", {
9961
- meta: { sessionId, brokerId, bytes: rtpData.length }
11156
+ sessionLogger.info("RTP session: first source RTP forwarded to repacketizer", {
11157
+ meta: { sessionId, brokerId, codec: sessionCodec, bytes: rtpData.length }
9962
11158
  });
9963
11159
  }
9964
11160
  try {
9965
11161
  session.forwardSourceRtpVideo(rtpData);
9966
11162
  } catch (err) {
9967
11163
  sessionLogger.warn("forwardSourceRtpVideo threw", {
9968
- meta: { sessionId, error: index.errMsg(err) }
11164
+ meta: { sessionId, codec: sessionCodec, error: index.errMsg(err) }
9969
11165
  });
9970
11166
  }
9971
11167
  });
@@ -9977,9 +11173,10 @@ class BrokerWebrtcServer {
9977
11173
  sessionId,
9978
11174
  session,
9979
11175
  sessionLogger,
11176
+ deviceId,
9980
11177
  brokerId,
9981
11178
  sessionCodec,
9982
- useH265Repacketizer,
11179
+ useRtpRepacketizer,
9983
11180
  iceServerCount: iceServers.length
9984
11181
  };
9985
11182
  }
@@ -9988,13 +11185,29 @@ class BrokerWebrtcServer {
9988
11185
  if (!entry) throw new Error(`Session not found: ${sessionId}`);
9989
11186
  await entry.session.handleAnswer({ sdp: sdpAnswer, type: "answer" });
9990
11187
  }
11188
+ /** Trickle ICE — add a remote (client) candidate to a live session. */
11189
+ async addIceCandidate(sessionId, candidate) {
11190
+ const entry = this.sessions.get(sessionId);
11191
+ if (!entry) return;
11192
+ await entry.session.addIceCandidate(candidate).catch((err) => {
11193
+ this.logger.warn("addIceCandidate threw", { meta: { sessionId, error: index.errMsg(err) } });
11194
+ });
11195
+ }
11196
+ /** Trickle ICE — the server's gathered candidates for a session (poll). */
11197
+ getIceCandidates(sessionId) {
11198
+ const entry = this.sessions.get(sessionId);
11199
+ if (!entry) return { candidates: [], done: true };
11200
+ return entry.session.getIceCandidatesSnapshot();
11201
+ }
9991
11202
  async closeSession(sessionId) {
9992
11203
  const entry = this.sessions.get(sessionId);
9993
11204
  if (!entry) return;
9994
11205
  this.cleanupSession(sessionId);
9995
11206
  await entry.session.close().catch(() => {
9996
11207
  });
9997
- this.logger.info("WebRTC session closed", { meta: { sessionId } });
11208
+ this.logger.info("WebRTC session closed", {
11209
+ meta: { sessionId, deviceId: deviceIdFromBrokerId(entry.brokerId) }
11210
+ });
9998
11211
  }
9999
11212
  async stop() {
10000
11213
  if (this.stopped) return;
@@ -10090,6 +11303,12 @@ class BrokerWebrtcServer {
10090
11303
  return this.staticIceServers ?? [];
10091
11304
  }
10092
11305
  }
11306
+ function deviceIdFromBrokerId(brokerId) {
11307
+ const slashIdx = brokerId.indexOf("/");
11308
+ if (slashIdx <= 0) return -1;
11309
+ const parsed = Number.parseInt(brokerId.slice(0, slashIdx), 10);
11310
+ return Number.isNaN(parsed) ? -1 : parsed;
11311
+ }
10093
11312
  function detectFirstNalType(annexB, isHevc) {
10094
11313
  for (let i = 0; i < annexB.length - 4; i++) {
10095
11314
  if (annexB[i] === 0 && annexB[i + 1] === 0 && annexB[i + 2] === 0 && annexB[i + 3] === 1 && i + 4 < annexB.length) {
@@ -10260,7 +11479,10 @@ class WebrtcSessionProvider {
10260
11479
  async createSession(input) {
10261
11480
  const streamId = this.resolveTargetToStreamId(input.deviceId, input.target);
10262
11481
  const streamingDebug = typeof this.manager.isStreamingDebug === "function" ? this.manager.isStreamingDebug(input.deviceId) : false;
10263
- return this.webrtcServer.createSession(streamId, input.hints, { streamingDebug });
11482
+ return this.webrtcServer.createSession(streamId, input.hints, {
11483
+ streamingDebug,
11484
+ relayOnly: input.relayOnly === true
11485
+ });
10264
11486
  }
10265
11487
  async handleOffer(input) {
10266
11488
  const target = input.target ?? { kind: "adaptive" };
@@ -10268,12 +11490,23 @@ class WebrtcSessionProvider {
10268
11490
  const streamingDebug = typeof this.manager.isStreamingDebug === "function" ? this.manager.isStreamingDebug(input.deviceId) : false;
10269
11491
  return this.webrtcServer.handleOffer(streamId, input.sdpOffer, void 0, {
10270
11492
  streamingDebug,
10271
- sessionId: input.sessionId
11493
+ sessionId: input.sessionId,
11494
+ relayOnly: input.relayOnly === true
10272
11495
  });
10273
11496
  }
10274
11497
  async handleAnswer(input) {
10275
11498
  await this.webrtcServer.handleAnswer(input.sessionId, input.sdpAnswer);
10276
11499
  }
11500
+ async addIceCandidate(input) {
11501
+ await this.webrtcServer.addIceCandidate(input.sessionId, {
11502
+ candidate: input.candidate,
11503
+ sdpMid: input.sdpMid ?? null,
11504
+ sdpMLineIndex: input.sdpMLineIndex ?? null
11505
+ });
11506
+ }
11507
+ async getIceCandidates(input) {
11508
+ return this.webrtcServer.getIceCandidates(input.sessionId);
11509
+ }
10277
11510
  async closeSession(input) {
10278
11511
  await this.webrtcServer.closeSession(input.sessionId);
10279
11512
  }
@@ -10303,6 +11536,7 @@ const BROKER_METRICS_HEARTBEAT_MS = 3e4;
10303
11536
  class StreamBrokerAddon extends index.BaseAddon {
10304
11537
  brokerManager = null;
10305
11538
  metricsSnapshotTimer = null;
11539
+ catalogReconcileTimer = null;
10306
11540
  /**
10307
11541
  * Snapshot-equality cache for the broker-metrics emit. Each
10308
11542
  * broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
@@ -10323,7 +11557,8 @@ class StreamBrokerAddon extends index.BaseAddon {
10323
11557
  rtspPort: 8554,
10324
11558
  maxDecodeFps: 5,
10325
11559
  initialReconnectDelayMs: 1e3,
10326
- maxReconnectDelayMs: 3e4
11560
+ maxReconnectDelayMs: 3e4,
11561
+ catalogReconcileIntervalSec: 30
10327
11562
  });
10328
11563
  }
10329
11564
  async onInitialize() {
@@ -10502,6 +11737,38 @@ class StreamBrokerAddon extends index.BaseAddon {
10502
11737
  });
10503
11738
  }
10504
11739
  });
11740
+ const reconcileAll = (reason) => {
11741
+ void this.brokerManager?.reconcileAllCatalogs().catch((err) => {
11742
+ this.ctx.logger.warn("catalog reconcile failed", { meta: { reason, error: index.errMsg(err) } });
11743
+ });
11744
+ };
11745
+ const reconcileDevice = (deviceId, reason) => {
11746
+ void this.brokerManager?.reconcileDeviceCatalog(deviceId).catch((err) => {
11747
+ this.ctx.logger.warn("device catalog reconcile failed", {
11748
+ tags: { deviceId },
11749
+ meta: { reason, error: index.errMsg(err) }
11750
+ });
11751
+ });
11752
+ };
11753
+ reconcileAll("startup");
11754
+ const reconcileMs = Math.max(5, this.config.catalogReconcileIntervalSec) * 1e3;
11755
+ this.catalogReconcileTimer = setInterval(() => reconcileAll("poll"), reconcileMs);
11756
+ this.subscribe({ category: index.EventCategory.DeviceRegistered }, (event) => {
11757
+ const deviceId = event.data.deviceId;
11758
+ if (typeof deviceId === "number") reconcileDevice(deviceId, "device-registered");
11759
+ });
11760
+ this.subscribe({ category: index.EventCategory.DeviceUnregistered }, (event) => {
11761
+ const deviceId = event.data.deviceId;
11762
+ if (typeof deviceId === "number") {
11763
+ void this.brokerManager?.retractDevice(deviceId).catch((err) => {
11764
+ this.ctx.logger.warn("device retract failed", { tags: { deviceId }, meta: { error: index.errMsg(err) } });
11765
+ });
11766
+ }
11767
+ });
11768
+ this.subscribe({ category: index.EventCategory.StreamParamsChanged }, (event) => {
11769
+ const deviceId = event.data.deviceId;
11770
+ if (typeof deviceId === "number") reconcileDevice(deviceId, "stream-params-changed");
11771
+ });
10505
11772
  const cameraStreamsProvider = new CameraStreamsProvider(this.brokerManager);
10506
11773
  const turnApi = this.ctx.api;
10507
11774
  const webrtcServer = new BrokerWebrtcServer({
@@ -10519,6 +11786,9 @@ class StreamBrokerAddon extends index.BaseAddon {
10519
11786
  ...s.credential ? { credential: s.credential } : {}
10520
11787
  });
10521
11788
  }
11789
+ this.ctx.logger.info("broker getIceServers resolved", {
11790
+ meta: { count: out.length, urls: out.map((s) => Array.isArray(s.urls) ? s.urls[0] : s.urls) }
11791
+ });
10522
11792
  return out;
10523
11793
  } catch (err) {
10524
11794
  this.ctx.logger.warn("turnProvider.getTurnServers failed — session will start without TURN", {
@@ -10578,6 +11848,10 @@ class StreamBrokerAddon extends index.BaseAddon {
10578
11848
  clearInterval(this.metricsSnapshotTimer);
10579
11849
  this.metricsSnapshotTimer = null;
10580
11850
  }
11851
+ if (this.catalogReconcileTimer) {
11852
+ clearInterval(this.catalogReconcileTimer);
11853
+ this.catalogReconcileTimer = null;
11854
+ }
10581
11855
  await this.brokerManager?.destroyAll();
10582
11856
  this.brokerManager = null;
10583
11857
  }
@@ -10660,6 +11934,17 @@ class StreamBrokerAddon extends index.BaseAddon {
10660
11934
  max: 30,
10661
11935
  step: 1,
10662
11936
  default: 5
11937
+ },
11938
+ {
11939
+ type: "number",
11940
+ key: "catalogReconcileIntervalSec",
11941
+ label: "Catalog Reconcile Interval",
11942
+ description: "How often the broker re-pulls every camera’s stream catalog (reconcile backstop)",
11943
+ min: 5,
11944
+ max: 300,
11945
+ step: 5,
11946
+ default: 30,
11947
+ unit: "s"
10663
11948
  }
10664
11949
  ]
10665
11950
  },