@camstack/addon-pipeline 0.1.18 → 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-DLHaHm6u.js → index-BbPPvoCx.js} +414 -45
  14. package/dist/index-BbPPvoCx.js.map +1 -0
  15. package/dist/{index-asZs8U_s.mjs → index-Bmlkm0Fd.mjs} +414 -45
  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-3TxRVJ5L.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-De6APW25.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-CUXiTSWS.mjs → index-Dy2V7VOm.mjs} +3775 -3279
  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-DLHaHm6u.js.map +0 -1
  49. package/dist/index-asZs8U_s.mjs.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,4 +1,4 @@
1
- import { R as RingBuffer, e as errMsg, J as maskUrlCredentials, E as EventCategory, K as CAM_PROFILE_ORDER, L as DeviceFeature, B as BaseAddon, M as asJsonObject, N as streamBrokerCapability, O as cameraStreamsCapability, P as webrtcSessionCapability, Q as addonWidgetsSourceCapability, f as createEvent } from "../index-asZs8U_s.mjs";
1
+ import { R as RingBuffer, e as errMsg, J as maskUrlCredentials, E as EventCategory, K as CAM_PROFILE_ORDER, D as DeviceType, L as DeviceFeature, B as BaseAddon, M as asJsonObject, N as streamBrokerCapability, O as cameraStreamsCapability, P as webrtcSessionCapability, Q as addonWidgetsSourceCapability, f as createEvent } from "../index-Bmlkm0Fd.mjs";
2
2
  import * as crypto from "node:crypto";
3
3
  import crypto__default, { randomUUID, createHash, randomBytes } from "node:crypto";
4
4
  import * as net from "node:net";
@@ -7,9 +7,10 @@ import { Socket } from "net";
7
7
  import { once } from "events";
8
8
  import { spawn } from "node:child_process";
9
9
  import { lookup } from "node:dns/promises";
10
+ import * as os from "node:os";
11
+ import { networkInterfaces } from "node:os";
10
12
  import * as fs from "node:fs";
11
13
  import * as path from "node:path";
12
- import * as os from "node:os";
13
14
  import { EventEmitter } from "node:events";
14
15
  class DecoderSessionProxy {
15
16
  constructor(api, sessionId) {
@@ -36,14 +37,28 @@ class DecoderSessionProxy {
36
37
  * Mirrors `startPolling` but drains `pullHandles` — the decoder has
37
38
  * already written the pixels into a shared-memory ring, so what crosses
38
39
  * the cap boundary is the tiny serialisable handle. Runs until
39
- * `stopPolling` or `destroy`.
40
+ * `stopPolling`, `destroy`, or the session is destroyed externally.
41
+ *
42
+ * When the decoder reports the session is gone (`unknown sessionId` /
43
+ * `Service … not found`) the loop exits gracefully — those errors are
44
+ * expected during decoder restart/shutdown and must not be re-thrown to
45
+ * the caller's `.catch()` handler, which would otherwise spin forever.
40
46
  */
41
47
  async startHandlePolling(onHandle) {
42
48
  this.polling = true;
43
49
  while (this.polling) {
44
- const handles = await this.api.pullHandles({ sessionId: this.sessionId, maxCount: 4 });
45
- for (const handle of handles) onHandle(handle);
46
- if (handles.length === 0) await new Promise((r) => setTimeout(r, 1));
50
+ try {
51
+ const handles = await this.api.pullHandles({ sessionId: this.sessionId, maxCount: 4 });
52
+ for (const handle of handles) onHandle(handle);
53
+ if (handles.length === 0) await new Promise((r) => setTimeout(r, 1));
54
+ } catch (err) {
55
+ this.polling = false;
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ if (!msg.includes("unknown sessionId") && !msg.includes("Service") && !msg.includes("not found")) {
58
+ throw err;
59
+ }
60
+ return;
61
+ }
47
62
  }
48
63
  }
49
64
  stopPolling() {
@@ -157,6 +172,8 @@ class FrameHandlePlane {
157
172
  pullHandles(subscriptionId, maxCount) {
158
173
  const subscription = this.subscriptions.get(subscriptionId);
159
174
  if (!subscription) return [];
175
+ const stale = subscription.queue.size - maxCount;
176
+ if (stale > 0) subscription.queue.drain(stale);
160
177
  const handles = subscription.queue.drain(maxCount);
161
178
  subscription.framesDelivered += handles.length;
162
179
  return handles;
@@ -210,9 +227,18 @@ class FrameHandlePlane {
210
227
  for (const session of this.sessions.values()) {
211
228
  if (!session.proxy) continue;
212
229
  session.proxy.pushPacket(packet).catch((err) => {
230
+ const msg = errMsg(err);
213
231
  this.logger?.warn("frame-handle plane: decoder push error", {
214
- meta: { format: session.format, error: errMsg(err) }
232
+ meta: { format: session.format, error: msg }
215
233
  });
234
+ if (msg.includes("unknown sessionId") || msg.includes("Service") || msg.includes("not found")) {
235
+ this.logger?.info("frame-handle plane: invalidating stale proxy", {
236
+ meta: { format: session.format }
237
+ });
238
+ void this.destroySession(session).catch(() => {
239
+ });
240
+ this.sessions.delete(session.format);
241
+ }
216
242
  });
217
243
  }
218
244
  }
@@ -3757,6 +3783,26 @@ class StreamBroker {
3757
3783
  /** Tracking flags set synchronously by the RTP depacketizer callback. */
3758
3784
  _lastNalKeyframe = false;
3759
3785
  _lastNalParamSet = false;
3786
+ /**
3787
+ * Source-RTP pre-buffer: a ring of raw source RTP packets trimmed to
3788
+ * begin at the most recent keyframe access unit — i.e. the current GOP so
3789
+ * far. A late-joining WebRTC viewer on the repacketizer path replays this
3790
+ * on connect (`getRtpPreBuffer`) so its decoder initialises immediately
3791
+ * from a keyframe instead of waiting for the camera's next IDR (which can
3792
+ * be many seconds away on a long-GOP 4K stream). This is the RTP-level
3793
+ * counterpart to the AnnexB `preBuffer`, kept separately because the
3794
+ * repacketizer path needs the original RTP wire packets, not AnnexB.
3795
+ * Only populated for RTP sources (`isRtpSource()`).
3796
+ */
3797
+ rtpRing = [];
3798
+ rtpRingCurAuStart = 0;
3799
+ rtpRingPrevMarker = true;
3800
+ rtpRingHasKeyframe = false;
3801
+ rtpRingBytes = 0;
3802
+ /** Memory ceiling for the RTP ring. A GOP that exceeds this (pathological
3803
+ * long-GOP high-bitrate stream) drops the bootstrap rather than balloon
3804
+ * memory — late joiners fall back to waiting for the next keyframe. */
3805
+ static RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
3760
3806
  /** Stream stats tracking */
3761
3807
  totalBytes = 0;
3762
3808
  bytesInWindow = 0;
@@ -4175,7 +4221,7 @@ class StreamBroker {
4175
4221
  this.lastKeyframeMs = now;
4176
4222
  }
4177
4223
  }
4178
- if (packet.type === "video") {
4224
+ if (packet.type === "video" && !this.isRtpSource()) {
4179
4225
  this.preBuffer.push(packet);
4180
4226
  }
4181
4227
  for (const cb of this.encodedCallbacks) {
@@ -4321,6 +4367,46 @@ class StreamBroker {
4321
4367
  getSdpParameterSets() {
4322
4368
  return this.sdpParameterSets;
4323
4369
  }
4370
+ /**
4371
+ * Append a source RTP packet to the pre-buffer ring, trimming it to begin
4372
+ * at the most recent keyframe access unit. Access-unit boundaries are
4373
+ * detected via the RTP marker bit (set on the last packet of a frame);
4374
+ * keyframe-ness comes from the depacketizer flags set just before this
4375
+ * call. The ring therefore always holds `[current keyframe AU start .. now]`.
4376
+ */
4377
+ captureRtpForPreBuffer(rtpData) {
4378
+ const marker = rtpData.length > 1 && (rtpData[1] & 128) !== 0;
4379
+ const auStart = this.rtpRingPrevMarker || this.rtpRing.length === 0;
4380
+ if (auStart) this.rtpRingCurAuStart = this.rtpRing.length;
4381
+ this.rtpRing.push(rtpData);
4382
+ this.rtpRingBytes += rtpData.length;
4383
+ if (this._lastNalParamSet || this._lastNalKeyframe) {
4384
+ if (this.rtpRingCurAuStart > 0) {
4385
+ const dropped = this.rtpRing.splice(0, this.rtpRingCurAuStart);
4386
+ for (const b of dropped) this.rtpRingBytes -= b.length;
4387
+ this.rtpRingCurAuStart = 0;
4388
+ }
4389
+ this.rtpRingHasKeyframe = true;
4390
+ }
4391
+ this.rtpRingPrevMarker = marker;
4392
+ if (this.rtpRingBytes > StreamBroker.RTP_RING_MAX_BYTES) {
4393
+ this.rtpRing = [];
4394
+ this.rtpRingBytes = 0;
4395
+ this.rtpRingCurAuStart = 0;
4396
+ this.rtpRingPrevMarker = true;
4397
+ this.rtpRingHasKeyframe = false;
4398
+ }
4399
+ }
4400
+ /**
4401
+ * Snapshot of the source-RTP pre-buffer (the current GOP from its
4402
+ * keyframe) for a late-joining repacketizer-path viewer to replay on
4403
+ * connect — instant decoder start. Empty until a keyframe has been seen
4404
+ * (or after a cap overflow), in which case the viewer falls back to
4405
+ * waiting for the camera's next keyframe.
4406
+ */
4407
+ getRtpPreBuffer() {
4408
+ return this.rtpRingHasKeyframe ? this.rtpRing.slice() : [];
4409
+ }
4324
4410
  getSourceType() {
4325
4411
  return this.source?.type ?? null;
4326
4412
  }
@@ -4716,6 +4802,7 @@ class StreamBroker {
4716
4802
  this._lastNalKeyframe,
4717
4803
  this._lastNalParamSet
4718
4804
  );
4805
+ this.captureRtpForPreBuffer(rtpData);
4719
4806
  if (this.rtpVideoCallbacks.size > 0) {
4720
4807
  for (const cb of this.rtpVideoCallbacks) {
4721
4808
  try {
@@ -6218,6 +6305,14 @@ const STREAM_HEALTH_POLL_MS = 15e3;
6218
6305
  function brokerIdFor(deviceId, camStreamId) {
6219
6306
  return `${deviceId}/${camStreamId}`;
6220
6307
  }
6308
+ function parseBrokerId(brokerId) {
6309
+ const slash = brokerId.indexOf("/");
6310
+ if (slash <= 0) return null;
6311
+ const deviceId = Number(brokerId.slice(0, slash));
6312
+ const camStreamId = brokerId.slice(slash + 1);
6313
+ if (!Number.isInteger(deviceId) || deviceId < 0 || camStreamId.length === 0) return null;
6314
+ return { deviceId, camStreamId };
6315
+ }
6221
6316
  class StreamBrokerManager {
6222
6317
  /**
6223
6318
  * brokers keyed by brokerId = `${deviceId}/${camStreamId}`.
@@ -6641,6 +6736,114 @@ class StreamBrokerManager {
6641
6736
  this.emitCamStreamsChanged(deviceId);
6642
6737
  return { success: true };
6643
6738
  }
6739
+ // ── Catalog reconcile (PULL) ─────────────────────────────────────────
6740
+ //
6741
+ // The broker is the authority for its own cam-stream registry: it PULLS
6742
+ // each camera's `stream-catalog` (via DeviceProxy) and reconciles, rather
6743
+ // than relying on providers to push. This makes the broker self-heal after
6744
+ // a restart — it re-derives the full registry from the providers. Driven by
6745
+ // the host addon on start + a configurable poll + device / stream-params
6746
+ // events.
6747
+ /**
6748
+ * Race a catalog pull against a timeout so a wedged provider handler
6749
+ * can't leave the reconcile pending forever. Rejects (caught upstream →
6750
+ * retry next tick) if the provider hasn't answered in time.
6751
+ */
6752
+ async withCatalogTimeout(promise, deviceId) {
6753
+ let timer;
6754
+ const timeout = new Promise((_resolve, reject) => {
6755
+ timer = setTimeout(
6756
+ () => reject(new Error(`stream-catalog.getCatalog timed out for device ${String(deviceId)}`)),
6757
+ 12e3
6758
+ );
6759
+ });
6760
+ try {
6761
+ return await Promise.race([promise, timeout]);
6762
+ } finally {
6763
+ if (timer !== void 0) clearTimeout(timer);
6764
+ }
6765
+ }
6766
+ /**
6767
+ * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
6768
+ * every descriptor, retract any cam-stream no longer in the catalog. A pull
6769
+ * that throws (provider mid-restart, no `stream-catalog` cap) is skipped —
6770
+ * the poll retries. An EMPTY catalog is also skipped rather than tearing
6771
+ * down (a provider probe can transiently return none); genuine removal flows
6772
+ * through the device-unregistered path.
6773
+ */
6774
+ async reconcileDeviceCatalog(deviceId) {
6775
+ if (!this.api) return;
6776
+ let descriptors;
6777
+ try {
6778
+ descriptors = await this.withCatalogTimeout(
6779
+ this.api.streamCatalog.getCatalog.query({ deviceId }),
6780
+ deviceId
6781
+ ) ?? [];
6782
+ } catch (err) {
6783
+ this.logger.debug("reconcileDeviceCatalog: stream-catalog pull failed — will retry", {
6784
+ tags: { deviceId },
6785
+ meta: { error: errMsg(err) }
6786
+ });
6787
+ return;
6788
+ }
6789
+ if (descriptors.length === 0) return;
6790
+ const keep = new Set(descriptors.map((d) => d.camStreamId));
6791
+ for (const d of descriptors) {
6792
+ await this.publishCameraStream({ deviceId, ...d });
6793
+ }
6794
+ const existing = this.cameraStreams.get(deviceId);
6795
+ if (existing) {
6796
+ for (const camStreamId of [...existing.keys()]) {
6797
+ if (!keep.has(camStreamId)) {
6798
+ await this.retractCameraStream({ deviceId, camStreamId });
6799
+ }
6800
+ }
6801
+ }
6802
+ }
6803
+ /**
6804
+ * Enumerate every camera and reconcile its catalog. The reconcile backstop
6805
+ * (poll cadence) and the start-up fetch. Per-device failures are isolated so
6806
+ * one unreachable camera can't block the rest.
6807
+ */
6808
+ async reconcileAllCatalogs() {
6809
+ if (!this.api) return;
6810
+ let devices;
6811
+ try {
6812
+ devices = await this.api.deviceManager.listAll.query({});
6813
+ } catch (err) {
6814
+ this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", {
6815
+ meta: { error: errMsg(err) }
6816
+ });
6817
+ return;
6818
+ }
6819
+ const cameras = devices.filter((d) => d.isCamera || d.type === DeviceType.Camera);
6820
+ await Promise.allSettled(cameras.map((d) => this.reconcileDeviceCatalog(d.id)));
6821
+ }
6822
+ /**
6823
+ * Drop a device's cam-streams entirely — fired on `device.unregistered`.
6824
+ */
6825
+ async retractDevice(deviceId) {
6826
+ const existing = this.cameraStreams.get(deviceId);
6827
+ if (!existing) return;
6828
+ for (const camStreamId of [...existing.keys()]) {
6829
+ await this.retractCameraStream({ deviceId, camStreamId });
6830
+ }
6831
+ }
6832
+ /**
6833
+ * Lazily (re)create the broker instance for a `brokerId` if its cam-stream
6834
+ * is known. The audio/frame subscribe paths call this so a consumer's
6835
+ * retry RESURRECTS the broker after a restart — the video path already
6836
+ * `ensureBroker`s, but audio/frame subscribers previously assumed an
6837
+ * existing instance and failed forever once the process respawned.
6838
+ * No-op when the brokerId is malformed or its cam-stream isn't (yet)
6839
+ * reconciled into the registry (`ensureBroker` itself guards on that).
6840
+ */
6841
+ async ensureBrokerForId(brokerId) {
6842
+ if (this.brokers.has(brokerId)) return;
6843
+ const parsed = parseBrokerId(brokerId);
6844
+ if (!parsed) return;
6845
+ await this.ensureBroker(parsed.deviceId, parsed.camStreamId);
6846
+ }
6644
6847
  // ── Cap methods: profile assignment ─────────────────────────────────
6645
6848
  async assignProfile(input) {
6646
6849
  const { deviceId, profile, camStreamId } = input;
@@ -6736,6 +6939,7 @@ class StreamBrokerManager {
6736
6939
  * `FrameRingReader`.
6737
6940
  */
6738
6941
  async subscribeFrames(input) {
6942
+ await this.ensureBrokerForId(input.brokerId);
6739
6943
  const broker = this.brokers.get(input.brokerId);
6740
6944
  if (!broker) {
6741
6945
  throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
@@ -6794,6 +6998,7 @@ class StreamBrokerManager {
6794
6998
  * so their bytes travel inline on the RPC wire — no shared memory.
6795
6999
  */
6796
7000
  async subscribeAudioChunks(input) {
7001
+ await this.ensureBrokerForId(input.brokerId);
6797
7002
  const broker = this.brokers.get(input.brokerId);
6798
7003
  if (!broker) {
6799
7004
  throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
@@ -7911,6 +8116,58 @@ function isH264IdrAccessUnit(annexB) {
7911
8116
  }
7912
8117
  return hasSps;
7913
8118
  }
8119
+ function extractH264ParamSets(annexB) {
8120
+ const nals = splitAnnexBToNals(annexB);
8121
+ let sps;
8122
+ let pps;
8123
+ let profileLevelId;
8124
+ for (const nal of nals) {
8125
+ if (nal.length < 1) continue;
8126
+ const nalType = nal[0] & 31;
8127
+ if (nalType === 7) {
8128
+ sps = nal;
8129
+ if (nal.length >= 4) {
8130
+ profileLevelId = Buffer.from([nal[1], nal[2], nal[3]]).toString(
8131
+ "hex"
8132
+ );
8133
+ }
8134
+ } else if (nalType === 8) {
8135
+ pps = nal;
8136
+ }
8137
+ }
8138
+ const out = {};
8139
+ if (sps) out.sps = sps;
8140
+ if (pps) out.pps = pps;
8141
+ if (profileLevelId) out.profileLevelId = profileLevelId;
8142
+ return out;
8143
+ }
8144
+ function isBaselineProfileLevelId(profileLevelId) {
8145
+ return profileLevelId.length >= 2 && profileLevelId.slice(0, 2).toLowerCase() === "42";
8146
+ }
8147
+ function groupNalsIntoAccessUnits(nals) {
8148
+ const accessUnits = [];
8149
+ let current = [];
8150
+ let currentHasVcl = false;
8151
+ const isVcl = (type) => type >= 1 && type <= 5;
8152
+ const startsNewAccessUnit = (type, nal) => {
8153
+ if (isVcl(type)) return nal.length >= 2 && (nal[1] & 128) === 128;
8154
+ return type === 9 || type === 7 || type === 8 || type === 6;
8155
+ };
8156
+ for (const nal of nals) {
8157
+ if (nal.length < 1) continue;
8158
+ const type = nal[0] & 31;
8159
+ if (currentHasVcl && startsNewAccessUnit(type, nal)) {
8160
+ accessUnits.push(current);
8161
+ current = [];
8162
+ currentHasVcl = false;
8163
+ }
8164
+ if (type === 9) continue;
8165
+ current.push(nal);
8166
+ if (isVcl(type)) currentHasVcl = true;
8167
+ }
8168
+ if (current.length > 0) accessUnits.push(current);
8169
+ return accessUnits;
8170
+ }
7914
8171
  function tryConvertWithLengthReader(data, readLen) {
7915
8172
  const result = [];
7916
8173
  let offset = 0;
@@ -8026,14 +8283,20 @@ async function resolveMdnsCandidatesInSdp(sdp, logger, sessionTag) {
8026
8283
  const hosts = /* @__PURE__ */ new Set();
8027
8284
  for (const match of sdp.matchAll(mdnsHostRe)) hosts.add(match[1]);
8028
8285
  if (hosts.size === 0) return sdp;
8286
+ const MDNS_LOOKUP_TIMEOUT_MS = 400;
8029
8287
  const replacements = await Promise.all(
8030
8288
  [...hosts].map(async (host) => {
8031
8289
  try {
8032
- const { address } = await lookup(host, { family: 4 });
8290
+ const address = await Promise.race([
8291
+ lookup(host, { family: 4 }).then((r) => r.address),
8292
+ new Promise(
8293
+ (_, reject) => setTimeout(() => reject(new Error(`mDNS lookup timed out after ${MDNS_LOOKUP_TIMEOUT_MS}ms`)), MDNS_LOOKUP_TIMEOUT_MS).unref?.()
8294
+ )
8295
+ ]);
8033
8296
  logger.info("mDNS resolve succeeded", { meta: { sessionTag, host, address } });
8034
8297
  return [host, address];
8035
8298
  } catch (err) {
8036
- logger.warn("mDNS resolve failed", { meta: { sessionTag, host, error: errMsg(err) } });
8299
+ logger.warn("mDNS resolve failed (dropping host candidate, srflx/relay still used)", { meta: { sessionTag, host, error: errMsg(err) } });
8037
8300
  return [host, null];
8038
8301
  }
8039
8302
  })
@@ -8124,17 +8387,17 @@ const NAL_TYPE_IDR_N_LP = 20;
8124
8387
  const NAL_TYPE_CRA_NUT = 21;
8125
8388
  const NAL_TYPE_RSV_IRAP_VCL23 = 23;
8126
8389
  const NAL_TYPE_VPS = 32;
8127
- const NAL_TYPE_SPS = 33;
8128
- const NAL_TYPE_PPS = 34;
8390
+ const NAL_TYPE_SPS$1 = 33;
8391
+ const NAL_TYPE_PPS$1 = 34;
8129
8392
  const NAL_TYPE_AUD = 35;
8130
8393
  const NAL_TYPE_SEI_PREFIX = 39;
8131
8394
  const NAL_TYPE_SEI_SUFFIX = 40;
8132
8395
  const NAL_TYPE_AP = 48;
8133
8396
  const NAL_TYPE_FU = 49;
8134
- const NAL_HEADER_SIZE = 2;
8397
+ const NAL_HEADER_SIZE$1 = 2;
8135
8398
  const FU_HEADER_SIZE = 3;
8136
- const LENGTH_FIELD_SIZE = 2;
8137
- const AP_HEADER_SIZE = NAL_HEADER_SIZE + LENGTH_FIELD_SIZE;
8399
+ const LENGTH_FIELD_SIZE$1 = 2;
8400
+ const AP_HEADER_SIZE = NAL_HEADER_SIZE$1 + LENGTH_FIELD_SIZE$1;
8138
8401
  function getNalType(data) {
8139
8402
  return (data[0] & 126) >> 1;
8140
8403
  }
@@ -8147,12 +8410,12 @@ function isKeyFrame(nalType) {
8147
8410
  function depacketizeAP(data) {
8148
8411
  const ret = [];
8149
8412
  let lastPos;
8150
- let pos = NAL_HEADER_SIZE;
8413
+ let pos = NAL_HEADER_SIZE$1;
8151
8414
  while (pos < data.length) {
8152
8415
  if (lastPos !== void 0)
8153
8416
  ret.push(data.subarray(lastPos, pos));
8154
8417
  const naluSize = data.readUInt16BE(pos);
8155
- pos += LENGTH_FIELD_SIZE;
8418
+ pos += LENGTH_FIELD_SIZE$1;
8156
8419
  lastPos = pos;
8157
8420
  pos += naluSize;
8158
8421
  }
@@ -8272,7 +8535,7 @@ class H265Repacketizer {
8272
8535
  const fuHeaderEnd = noEnd ? fuHeaderMiddle : Buffer.from([...fuNalHeader, nalType | 64]);
8273
8536
  let fuHeader = fuHeaderStart;
8274
8537
  const packages = [];
8275
- let offset = NAL_HEADER_SIZE;
8538
+ let offset = NAL_HEADER_SIZE$1;
8276
8539
  while (offset < data.length) {
8277
8540
  let payload;
8278
8541
  const packageSize = Math.min(this.fuMax, data.length - offset);
@@ -8297,9 +8560,9 @@ class H265Repacketizer {
8297
8560
  apHeader[0] = NAL_TYPE_AP << 1;
8298
8561
  apHeader[1] = 1;
8299
8562
  const payload = [apHeader];
8300
- while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize && counter < 9) {
8563
+ while (datas.length && datas[0].length + LENGTH_FIELD_SIZE$1 <= availableSize && counter < 9) {
8301
8564
  const nalu = datas.shift();
8302
- availableSize -= LENGTH_FIELD_SIZE + nalu.length;
8565
+ availableSize -= LENGTH_FIELD_SIZE$1 + nalu.length;
8303
8566
  counter += 1;
8304
8567
  const lengthField = Buffer.alloc(2);
8305
8568
  lengthField.writeUInt16BE(nalu.length, 0);
@@ -8355,7 +8618,7 @@ class H265Repacketizer {
8355
8618
  originalFragments.unshift(originalNalHeader);
8356
8619
  return Buffer.concat(originalFragments);
8357
8620
  };
8358
- if (originalNalType === NAL_TYPE_VPS || originalNalType === NAL_TYPE_SPS) {
8621
+ if (originalNalType === NAL_TYPE_VPS || originalNalType === NAL_TYPE_SPS$1) {
8359
8622
  const defragmented = getDefragmentedPendingFu();
8360
8623
  const splits = splitH265NaluStartCode(defragmented);
8361
8624
  while (splits.length) {
@@ -8363,9 +8626,9 @@ class H265Repacketizer {
8363
8626
  const splitNaluType = getNalType(split);
8364
8627
  if (splitNaluType === NAL_TYPE_VPS) {
8365
8628
  this.updateVps(split);
8366
- } else if (splitNaluType === NAL_TYPE_SPS) {
8629
+ } else if (splitNaluType === NAL_TYPE_SPS$1) {
8367
8630
  this.updateSps(split);
8368
- } else if (splitNaluType === NAL_TYPE_PPS) {
8631
+ } else if (splitNaluType === NAL_TYPE_PPS$1) {
8369
8632
  this.updatePps(split);
8370
8633
  } else {
8371
8634
  if (isKeyFrame(splitNaluType)) {
@@ -8500,7 +8763,7 @@ class H265Repacketizer {
8500
8763
  this.pendingFU.push(packet);
8501
8764
  if (isFuEnd) {
8502
8765
  this.flushPendingFU(ret);
8503
- } else if (this.pendingFU.reduce((p, c) => p + c.payload.length - FU_HEADER_SIZE, NAL_HEADER_SIZE) > this.maxPacketSize) {
8766
+ } else if (this.pendingFU.reduce((p, c) => p + c.payload.length - FU_HEADER_SIZE, NAL_HEADER_SIZE$1) > this.maxPacketSize) {
8504
8767
  const last = this.pendingFU[this.pendingFU.length - 1].clone();
8505
8768
  const partial = [];
8506
8769
  this.flushPendingFU(partial);
@@ -8521,10 +8784,10 @@ class H265Repacketizer {
8521
8784
  if (nalType2 === NAL_TYPE_VPS) {
8522
8785
  hasVps = true;
8523
8786
  this.updateVps(payload);
8524
- } else if (nalType2 === NAL_TYPE_SPS) {
8787
+ } else if (nalType2 === NAL_TYPE_SPS$1) {
8525
8788
  hasSps = true;
8526
8789
  this.updateSps(payload);
8527
- } else if (nalType2 === NAL_TYPE_PPS) {
8790
+ } else if (nalType2 === NAL_TYPE_PPS$1) {
8528
8791
  hasPps = true;
8529
8792
  this.updatePps(payload);
8530
8793
  } else if (nalType2 === NAL_TYPE_SEI_PREFIX) {
@@ -8565,11 +8828,11 @@ class H265Repacketizer {
8565
8828
  this.extraPackets--;
8566
8829
  this.updateVps(packet.payload);
8567
8830
  return;
8568
- } else if (nalType === NAL_TYPE_SPS) {
8831
+ } else if (nalType === NAL_TYPE_SPS$1) {
8569
8832
  this.extraPackets--;
8570
8833
  this.updateSps(packet.payload);
8571
8834
  return;
8572
- } else if (nalType === NAL_TYPE_PPS) {
8835
+ } else if (nalType === NAL_TYPE_PPS$1) {
8573
8836
  this.extraPackets--;
8574
8837
  this.updatePps(packet.payload);
8575
8838
  return;
@@ -8597,138 +8860,742 @@ class H265Repacketizer {
8597
8860
  return;
8598
8861
  }
8599
8862
  }
8600
- let _werift;
8601
- async function loadWerift() {
8602
- if (_werift) return _werift;
8603
- try {
8604
- const moduleName = "werift";
8605
- _werift = await Function("m", "return import(m)")(moduleName);
8606
- return _werift;
8607
- } catch {
8608
- throw new Error(
8609
- "The 'werift' package is required for WebRTC support but is not installed. Install it with: npm install werift"
8610
- );
8863
+ const NAL_TYPE_STAP_A = 24;
8864
+ const NAL_TYPE_FU_A = 28;
8865
+ const NAL_TYPE_IDR = 5;
8866
+ const NAL_TYPE_SEI = 6;
8867
+ const NAL_TYPE_SPS = 7;
8868
+ const NAL_TYPE_PPS = 8;
8869
+ const NAL_HEADER_SIZE = 1;
8870
+ const FU_A_HEADER_SIZE = 2;
8871
+ const LENGTH_FIELD_SIZE = 2;
8872
+ const STAP_A_HEADER_SIZE = NAL_HEADER_SIZE + LENGTH_FIELD_SIZE;
8873
+ function depacketizeStapA(data) {
8874
+ const ret = [];
8875
+ let lastPos;
8876
+ let pos = NAL_HEADER_SIZE;
8877
+ while (pos < data.length) {
8878
+ if (lastPos !== void 0)
8879
+ ret.push(data.subarray(lastPos, pos));
8880
+ const naluSize = data.readUInt16BE(pos);
8881
+ pos += LENGTH_FIELD_SIZE;
8882
+ lastPos = pos;
8883
+ pos += naluSize;
8611
8884
  }
8885
+ ret.push(data.subarray(lastPos));
8886
+ return ret;
8612
8887
  }
8613
- class AdaptiveSession {
8614
- sessionId;
8615
- source;
8616
- logger;
8617
- intercom;
8618
- iceConfig;
8619
- onStats;
8620
- debug;
8621
- sourceCodec;
8622
- /** Codec actually negotiated with the browser after SDP answer. */
8623
- negotiatedCodec = "H264";
8624
- /** True when source is H.265 but browser negotiated H.264 — needs transcode. */
8625
- get needsTranscode() {
8626
- return this.sourceCodec === "H265" && this.negotiatedCodec === "H264";
8888
+ function splitH264NaluStartCode(data) {
8889
+ const ret = [];
8890
+ let previous = 0;
8891
+ let offset = 0;
8892
+ const maybeAddSlice = () => {
8893
+ const slice = data.subarray(previous, offset);
8894
+ if (slice.length)
8895
+ ret.push(slice);
8896
+ offset += 4;
8897
+ previous = offset;
8898
+ };
8899
+ while (offset < data.length - 4) {
8900
+ const startCode = data.readUInt32BE(offset);
8901
+ if (startCode === 1) {
8902
+ maybeAddSlice();
8903
+ } else {
8904
+ offset++;
8905
+ }
8627
8906
  }
8628
- _firstKeyFrame;
8629
- /**
8630
- * Last seen SPS and PPS NALs. Many cameras send SPS/PPS only once
8631
- * at stream start (not inline with every IDR). We cache them so
8632
- * PLI-triggered keyframe re-sends include the parameter sets the
8633
- * decoder needs to re-initialise.
8634
- */
8635
- lastSps = null;
8636
- lastPps = null;
8637
- /** H.265 VPS (Video Parameter Set) — required before every IRAP for decoder init. */
8638
- lastVps = null;
8639
- /** Whether replaceRTP has been called on the video sender to sync SSRC/seq. */
8640
- videoRtpSynced = false;
8641
- createdAt;
8642
- state = "new";
8643
- pc = null;
8644
- videoTrack = null;
8645
- audioTrack = null;
8646
- /** Transceiver senders for direct sendRtp (more reliable than track.writeRtp) */
8647
- videoSender = null;
8648
- audioSender = null;
8649
- feedAbort = null;
8650
- closed = false;
8651
- statsTimer = null;
8652
- /**
8653
- * Notification hook invoked exactly once when `close()` finishes. The
8654
- * adaptive server wires this up after `createSession()` so the ICE
8655
- * disconnect/failed path (see the `iceConnectionStateChange` handler
8656
- * that calls `this.close()` on its own) reaches `cam.sessions.delete`
8657
- * + `scheduleCameraAutoStop`. Without this callback the server kept
8658
- * stale entries in `cam.sessions`, the auto-stop guard (`size > 0`)
8659
- * never fired, ffmpeg stayed up, and the RTSP client toward the
8660
- * broker leaked forever (`rtspClients: 2` on idle brokers).
8661
- */
8662
- onClosed = null;
8663
- /** RTP sequence number counter (must increment per packet). */
8664
- videoSeqNum = 0;
8665
- audioSeqNum = 0;
8666
- /**
8667
- * Cached last keyframe NALs (SPS + PPS + IDR slices) in Annex-B format,
8668
- * split and ready for RTP packetization. When the browser sends a PLI
8669
- * (Picture Loss Indication) because it lost reference frames, we
8670
- * immediately re-send this stored keyframe so the decoder can recover
8671
- * without waiting for the next natural keyframe from the encoder.
8672
- */
8673
- lastKeyframeNals = null;
8674
- lastKeyframeRtpTs = 0;
8675
- /** Throttle: minimum interval between PLI-triggered keyframe re-sends (ms). */
8676
- static PLI_RESEND_COOLDOWN_MS = 500;
8677
- lastPliResendAt = 0;
8678
- /**
8679
- * Per-session H.265 RTP repacketizer (lazy-init). The H.265 path
8680
- * forwards source RTP from the broker through this repacketizer so
8681
- * Chrome's HEVC depacketizer sees an RTP shape it actually accepts —
8682
- * the prior depacketize → AnnexB → re-packetize-from-scratch path
8683
- * produced `framesAssembledFromMultiplePackets = 0` regardless of
8684
- * codec metadata being correct. See `forwardSourceRtpVideo`.
8685
- */
8686
- h265Repacketizer = null;
8687
- /** RTP MTU for the repacketizer — leave headroom for SRTP auth tag. */
8688
- static H265_REPACKETIZER_MTU = 1180;
8689
- /** Source SSRC — captured on first source RTP, used to populate the
8690
- * outbound packets so werift's send pipeline doesn't reject them. */
8691
- sourceVideoSsrc = null;
8692
- constructor(options) {
8693
- this.sessionId = options.sessionId;
8694
- this.source = options.source;
8695
- this.logger = options.logger;
8696
- this.intercom = options.intercom;
8697
- this.iceConfig = options.iceConfig;
8698
- this.onStats = options.onStats;
8699
- this.debug = options.debug ?? false;
8700
- this.sourceCodec = options.sourceCodec ?? "H264";
8701
- this.createdAt = Date.now();
8907
+ offset = data.length;
8908
+ maybeAddSlice();
8909
+ return ret;
8910
+ }
8911
+ class H264Repacketizer {
8912
+ constructor(console2, maxPacketSize, codecInfo, jitterBuffer = new JitterBuffer(console2, 4)) {
8913
+ this.console = console2;
8914
+ this.maxPacketSize = maxPacketSize;
8915
+ this.codecInfo = codecInfo;
8916
+ this.jitterBuffer = jitterBuffer;
8917
+ this.setMaxPacketSize(maxPacketSize);
8702
8918
  }
8703
- /** Build PeerConnection options including H.264 codec config. */
8704
- async buildPcOptions() {
8705
- const werift = await loadWerift();
8706
- const iceServers = [];
8707
- for (const entry of this.iceConfig?.iceServers ?? []) {
8708
- const urlList = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
8709
- for (const url of urlList) {
8710
- iceServers.push({
8711
- urls: url,
8712
- ...entry.username !== void 0 ? { username: entry.username } : {},
8713
- ...entry.credential !== void 0 ? { credential: entry.credential } : {}
8714
- });
8715
- }
8919
+ extraPackets = 0;
8920
+ fuaMax;
8921
+ pendingFuA;
8922
+ // the stapa packet that will be sent before an idr frame.
8923
+ stapa;
8924
+ fuaMin;
8925
+ setMaxPacketSize(maxPacketSize) {
8926
+ this.maxPacketSize = maxPacketSize;
8927
+ this.fuaMax = maxPacketSize - FU_A_HEADER_SIZE;
8928
+ this.fuaMin = Math.round(maxPacketSize * 0.8);
8929
+ }
8930
+ ensureCodecInfo() {
8931
+ if (!this.codecInfo) {
8932
+ this.codecInfo = {};
8716
8933
  }
8717
- const rtcpFeedback = [
8718
- { type: "transport-cc" },
8719
- { type: "ccm", parameter: "fir" },
8720
- { type: "nack" },
8721
- { type: "nack", parameter: "pli" },
8722
- { type: "goog-remb" }
8723
- ];
8724
- const h264Codec = new werift.RTCRtpCodecParameters({
8725
- mimeType: "video/H264",
8726
- clockRate: 9e4,
8727
- payloadType: 96,
8728
- parameters: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
8729
- rtcpFeedback
8730
- });
8731
- const h265Codec = new werift.RTCRtpCodecParameters({
8934
+ return this.codecInfo;
8935
+ }
8936
+ updateSps(sps) {
8937
+ this.ensureCodecInfo().sps = sps;
8938
+ }
8939
+ updatePps(pps) {
8940
+ this.ensureCodecInfo().pps = pps;
8941
+ }
8942
+ updateSei(sei) {
8943
+ this.ensureCodecInfo().sei = sei;
8944
+ }
8945
+ shouldFilter(_nalType) {
8946
+ return false;
8947
+ }
8948
+ // a fragmentation unit (fua) is a NAL unit broken into multiple fragments.
8949
+ // https://datatracker.ietf.org/doc/html/rfc6184#section-5.8
8950
+ packetizeFuA(data, noStart, noEnd) {
8951
+ const initialNalType = data[0] & 31;
8952
+ if (initialNalType === NAL_TYPE_FU_A) {
8953
+ const fnri2 = data[0] & (128 | 96);
8954
+ const originalNalType = data[1] & 31;
8955
+ const isFuStart = !!(data[1] & 128);
8956
+ const isFuEnd = !!(data[1] & 64);
8957
+ const isFuMiddle = !isFuStart && !isFuEnd;
8958
+ const originalNalHeader = Buffer.from([fnri2 | originalNalType]);
8959
+ data = Buffer.concat([originalNalHeader, data.subarray(FU_A_HEADER_SIZE)]);
8960
+ if (isFuStart) {
8961
+ noEnd = true;
8962
+ } else if (isFuEnd) {
8963
+ noStart = true;
8964
+ } else if (isFuMiddle) {
8965
+ noStart = true;
8966
+ noEnd = true;
8967
+ }
8968
+ }
8969
+ const fnri = data[0] & (128 | 96);
8970
+ const nalType = data[0] & 31;
8971
+ const fuIndicator = fnri | NAL_TYPE_FU_A;
8972
+ const fuHeaderMiddle = Buffer.from([fuIndicator, nalType]);
8973
+ const fuHeaderStart = noStart ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 128]);
8974
+ const fuHeaderEnd = noEnd ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 64]);
8975
+ let fuHeader = fuHeaderStart;
8976
+ const packages = [];
8977
+ let offset = NAL_HEADER_SIZE;
8978
+ while (offset < data.length) {
8979
+ const packageSize = Math.min(this.fuaMax, data.length - offset);
8980
+ const payload = data.subarray(offset, offset + packageSize);
8981
+ offset += packageSize;
8982
+ if (offset === data.length) {
8983
+ fuHeader = fuHeaderEnd;
8984
+ }
8985
+ packages.push(Buffer.concat([fuHeader, payload]));
8986
+ fuHeader = fuHeaderMiddle;
8987
+ }
8988
+ return packages;
8989
+ }
8990
+ // https://datatracker.ietf.org/doc/html/rfc6184#section-5.7.1
8991
+ packetizeOneStapA(datas) {
8992
+ const payload = [];
8993
+ if (!datas.length)
8994
+ throw new Error("packetizeOneStapA requires at least one NAL");
8995
+ let counter = 0;
8996
+ let availableSize = this.maxPacketSize - STAP_A_HEADER_SIZE;
8997
+ const stapHeader = NAL_TYPE_STAP_A;
8998
+ while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize && counter < 9) {
8999
+ const nalu = datas.shift();
9000
+ availableSize -= LENGTH_FIELD_SIZE + nalu.length;
9001
+ counter += 1;
9002
+ const packed = Buffer.alloc(2);
9003
+ packed.writeUInt16BE(nalu.length, 0);
9004
+ payload.push(packed, nalu);
9005
+ }
9006
+ if (counter === 0)
9007
+ return datas.shift();
9008
+ if (counter === 1) {
9009
+ return payload[1];
9010
+ }
9011
+ payload.unshift(Buffer.from([stapHeader]));
9012
+ return Buffer.concat(payload);
9013
+ }
9014
+ packetizeStapA(datas) {
9015
+ const ret = [];
9016
+ while (datas.length) {
9017
+ const nalu = this.packetizeOneStapA(datas);
9018
+ if (nalu.length < this.maxPacketSize) {
9019
+ ret.push(nalu);
9020
+ continue;
9021
+ }
9022
+ const fuas = this.packetizeFuA(nalu);
9023
+ ret.push(...fuas);
9024
+ }
9025
+ return ret;
9026
+ }
9027
+ createPacket(rtp, data, marker) {
9028
+ const ret = rtp.clone();
9029
+ ret.header.sequenceNumber = (rtp.header.sequenceNumber + this.extraPackets + 65536) % 65536;
9030
+ ret.header.marker = marker;
9031
+ ret.header.padding = false;
9032
+ ret.payload = data;
9033
+ if (data.length > this.maxPacketSize)
9034
+ this.console.warn("packet exceeded max packet size. this may be a bug.");
9035
+ return ret;
9036
+ }
9037
+ flushPendingFuA(ret) {
9038
+ const pending = this.pendingFuA;
9039
+ if (!pending || pending.length === 0)
9040
+ return;
9041
+ const first = pending[0];
9042
+ const last = pending[pending.length - 1];
9043
+ const originalNalType = first.payload[1] & 31;
9044
+ const hasFuStart = !!(first.payload[1] & 128);
9045
+ const hasFuEnd = !!(last.payload[1] & 64);
9046
+ const fnri = first.payload[0] & (128 | 96);
9047
+ const originalNalHeader = Buffer.from([fnri | originalNalType]);
9048
+ const getDefragmentedPendingFua = () => {
9049
+ const originalFragments = pending.map((packet) => packet.payload.subarray(FU_A_HEADER_SIZE));
9050
+ originalFragments.unshift(originalNalHeader);
9051
+ return Buffer.concat(originalFragments);
9052
+ };
9053
+ if (originalNalType === NAL_TYPE_SPS) {
9054
+ const defragmented = getDefragmentedPendingFua();
9055
+ const splits = splitH264NaluStartCode(defragmented);
9056
+ while (splits.length) {
9057
+ const split = splits.shift();
9058
+ const splitNaluType = split[0] & 31;
9059
+ if (splitNaluType === NAL_TYPE_SPS) {
9060
+ this.updateSps(split);
9061
+ } else if (splitNaluType === NAL_TYPE_PPS) {
9062
+ this.updatePps(split);
9063
+ } else {
9064
+ if (splitNaluType === NAL_TYPE_IDR)
9065
+ this.maybeSendStapACodecInfo(first, ret);
9066
+ this.fragment(first, ret, {
9067
+ payload: split,
9068
+ noStart: !hasFuStart,
9069
+ noEnd: !hasFuEnd,
9070
+ marker: last.header.marker
9071
+ });
9072
+ }
9073
+ }
9074
+ } else {
9075
+ while (pending.length) {
9076
+ const fua = pending[0];
9077
+ if (fua.payload.length > this.maxPacketSize || fua.payload.length < this.fuaMin)
9078
+ break;
9079
+ pending.shift();
9080
+ ret.push(this.createPacket(fua, fua.payload, fua.header.marker));
9081
+ }
9082
+ if (!pending.length) {
9083
+ this.pendingFuA = void 0;
9084
+ return;
9085
+ }
9086
+ const refragFirst = pending[0];
9087
+ const refragLast = pending[pending.length - 1];
9088
+ const refragHasFuStart = !!(refragFirst.payload[1] & 128);
9089
+ const refragHasFuEnd = !!(refragLast.payload[1] & 64);
9090
+ const defragmented = getDefragmentedPendingFua();
9091
+ this.fragment(refragFirst, ret, {
9092
+ payload: defragmented,
9093
+ noStart: !refragHasFuStart,
9094
+ noEnd: !refragHasFuEnd,
9095
+ marker: refragLast.header.marker
9096
+ });
9097
+ }
9098
+ this.extraPackets -= pending.length - 1;
9099
+ this.pendingFuA = void 0;
9100
+ }
9101
+ createRtpPackets(packet, nalus, ret, hadMarker = packet.header.marker) {
9102
+ nalus.forEach((packetized, index) => {
9103
+ if (index !== 0)
9104
+ this.extraPackets++;
9105
+ const marker = hadMarker && index === nalus.length - 1;
9106
+ ret.push(this.createPacket(packet, packetized, marker));
9107
+ });
9108
+ }
9109
+ maybeSendStapACodecInfo(packet, ret) {
9110
+ if (this.stapa) {
9111
+ this.stapa = void 0;
9112
+ return;
9113
+ }
9114
+ if (!this.codecInfo?.sps || !this.codecInfo?.pps)
9115
+ return;
9116
+ const agg = [this.codecInfo.sps, this.codecInfo.pps];
9117
+ if (this.codecInfo?.sei)
9118
+ agg.push(this.codecInfo.sei);
9119
+ const aggregates = this.packetizeStapA(agg);
9120
+ if (aggregates.length !== 1) {
9121
+ this.console.error("expected only 1 packet for sps/pps stapa");
9122
+ return;
9123
+ }
9124
+ this.createRtpPackets(packet, aggregates, ret, false);
9125
+ this.extraPackets++;
9126
+ }
9127
+ // given the packet, fragment it into multiple packets as needed.
9128
+ // a fragment of a payload may be provided via fuaOptions.
9129
+ fragment(packet, ret, fuaOptions = {
9130
+ payload: packet.payload,
9131
+ noStart: false,
9132
+ noEnd: false,
9133
+ marker: packet.header.marker
9134
+ }) {
9135
+ const { payload, noStart, noEnd, marker } = fuaOptions;
9136
+ if (payload.length > this.maxPacketSize || noStart || noEnd) {
9137
+ const fragments = this.packetizeFuA(payload, noStart, noEnd);
9138
+ this.createRtpPackets(packet, fragments, ret, marker);
9139
+ } else {
9140
+ ret.push(this.createPacket(packet, payload, marker));
9141
+ }
9142
+ }
9143
+ repacketize(packet) {
9144
+ const ret = [];
9145
+ for (const dejittered of this.jitterBuffer.queue(packet)) {
9146
+ this.repacketizeOne(dejittered, ret);
9147
+ }
9148
+ return ret;
9149
+ }
9150
+ repacketizeOne(packet, ret) {
9151
+ if (!packet.payload.length) {
9152
+ this.flushPendingFuA(ret);
9153
+ this.extraPackets--;
9154
+ return;
9155
+ }
9156
+ const nalType = packet.payload[0] & 31;
9157
+ if (this.pendingFuA && this.pendingFuA[0].header.timestamp !== packet.header.timestamp) {
9158
+ this.flushPendingFuA(ret);
9159
+ }
9160
+ if (nalType === NAL_TYPE_FU_A) {
9161
+ const data = packet.payload;
9162
+ const originalNalType = data[1] & 31;
9163
+ if (this.shouldFilter(originalNalType)) {
9164
+ this.extraPackets--;
9165
+ return;
9166
+ }
9167
+ const isFuStart = !!(data[1] & 128);
9168
+ if (isFuStart) {
9169
+ if (this.pendingFuA)
9170
+ this.console.error("fua restarted. skipping refragmentation of previous fua.", originalNalType);
9171
+ this.pendingFuA = void 0;
9172
+ if (originalNalType === NAL_TYPE_IDR) {
9173
+ this.maybeSendStapACodecInfo(packet, ret);
9174
+ }
9175
+ } else {
9176
+ if (this.pendingFuA) {
9177
+ const last = this.pendingFuA[this.pendingFuA.length - 1];
9178
+ if (!isNextSequenceNumber(last.header.sequenceNumber, packet.header.sequenceNumber)) {
9179
+ this.console.error("fua packet missing. skipping refragmentation.", originalNalType);
9180
+ return;
9181
+ }
9182
+ }
9183
+ }
9184
+ if (!this.pendingFuA)
9185
+ this.pendingFuA = [];
9186
+ this.pendingFuA.push(packet);
9187
+ const isFuEnd = !!(packet.payload[1] & 64);
9188
+ if (isFuEnd) {
9189
+ this.flushPendingFuA(ret);
9190
+ } else if (this.pendingFuA.reduce((p, c) => p + c.payload.length - FU_A_HEADER_SIZE, NAL_HEADER_SIZE) > this.maxPacketSize) {
9191
+ const last = this.pendingFuA[this.pendingFuA.length - 1].clone();
9192
+ const partial = [];
9193
+ this.flushPendingFuA(partial);
9194
+ const retain = partial.pop();
9195
+ if (retain)
9196
+ last.payload = retain.payload;
9197
+ this.pendingFuA = [last];
9198
+ ret.push(...partial);
9199
+ }
9200
+ } else if (nalType === NAL_TYPE_STAP_A) {
9201
+ this.flushPendingFuA(ret);
9202
+ let hasSps = false;
9203
+ let hasPps = false;
9204
+ const depacketized = depacketizeStapA(packet.payload);
9205
+ depacketized.forEach((payload) => {
9206
+ const stapNalType = payload[0] & 31;
9207
+ if (stapNalType === NAL_TYPE_SPS) {
9208
+ hasSps = true;
9209
+ this.updateSps(payload);
9210
+ } else if (stapNalType === NAL_TYPE_PPS) {
9211
+ hasPps = true;
9212
+ this.updatePps(payload);
9213
+ } else if (stapNalType === NAL_TYPE_SEI) {
9214
+ this.updateSei(payload);
9215
+ }
9216
+ });
9217
+ if (hasSps && hasPps)
9218
+ this.stapa = packet;
9219
+ const stapa = this.packetizeStapA(depacketized);
9220
+ this.createRtpPackets(packet, stapa, ret);
9221
+ } else if (nalType >= 1 && nalType < 24) {
9222
+ this.flushPendingFuA(ret);
9223
+ if (this.shouldFilter(nalType)) {
9224
+ this.extraPackets--;
9225
+ return;
9226
+ }
9227
+ if (nalType === NAL_TYPE_SPS) {
9228
+ this.extraPackets--;
9229
+ this.updateSps(packet.payload);
9230
+ return;
9231
+ } else if (nalType === NAL_TYPE_PPS) {
9232
+ this.extraPackets--;
9233
+ this.updatePps(packet.payload);
9234
+ return;
9235
+ } else if (nalType === NAL_TYPE_SEI) {
9236
+ this.extraPackets--;
9237
+ this.updateSei(packet.payload);
9238
+ return;
9239
+ }
9240
+ if (nalType === NAL_TYPE_IDR) {
9241
+ this.maybeSendStapACodecInfo(packet, ret);
9242
+ }
9243
+ this.fragment(packet, ret);
9244
+ } else {
9245
+ this.console.error("unknown nal unit type " + nalType);
9246
+ this.extraPackets--;
9247
+ }
9248
+ }
9249
+ }
9250
+ let _werift;
9251
+ async function loadWerift() {
9252
+ if (_werift) return _werift;
9253
+ try {
9254
+ const moduleName = "werift";
9255
+ _werift = await Function("m", "return import(m)")(moduleName);
9256
+ return _werift;
9257
+ } catch {
9258
+ throw new Error(
9259
+ "The 'werift' package is required for WebRTC support but is not installed. Install it with: npm install werift"
9260
+ );
9261
+ }
9262
+ }
9263
+ function isTailscaleIpv4(address) {
9264
+ const parts = address.split(".");
9265
+ if (parts.length !== 4) return false;
9266
+ const a = Number.parseInt(parts[0] ?? "", 10);
9267
+ const b = Number.parseInt(parts[1] ?? "", 10);
9268
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
9269
+ return a === 100 && b >= 64 && b <= 127;
9270
+ }
9271
+ function isTailscaleIpv6(address) {
9272
+ const pct = address.indexOf("%");
9273
+ const clean = (pct === -1 ? address : address.slice(0, pct)).toLowerCase();
9274
+ return clean.startsWith("fd7a:");
9275
+ }
9276
+ function getTailscaleHostAddresses(interfaces = networkInterfaces()) {
9277
+ const found = /* @__PURE__ */ new Set();
9278
+ for (const entries of Object.values(interfaces)) {
9279
+ if (!entries) continue;
9280
+ for (const entry of entries) {
9281
+ if (entry.internal) continue;
9282
+ const fam = entry.family;
9283
+ const isV4 = fam === "IPv4" || fam === 4;
9284
+ const isV6 = fam === "IPv6" || fam === 6;
9285
+ if (isV4 && isTailscaleIpv4(entry.address)) found.add(entry.address);
9286
+ else if (isV6 && isTailscaleIpv6(entry.address)) {
9287
+ const pct = entry.address.indexOf("%");
9288
+ found.add(pct === -1 ? entry.address : entry.address.slice(0, pct));
9289
+ }
9290
+ }
9291
+ }
9292
+ return [...found];
9293
+ }
9294
+ function resolveVideoTranscode(input) {
9295
+ const { sourceCodec, negotiatedCodec, transcodeToBaseline } = input;
9296
+ if (sourceCodec === "H265" && negotiatedCodec === "H264") return true;
9297
+ if (sourceCodec === "H264" && negotiatedCodec === "H264" && transcodeToBaseline) return true;
9298
+ return false;
9299
+ }
9300
+ function detectNegotiatedVideoCodec(sdp) {
9301
+ const m = sdp.match(/a=rtpmap:\d+ (H264|H265)\/90000/i)?.[1]?.toUpperCase();
9302
+ return m === "H264" || m === "H265" ? m : void 0;
9303
+ }
9304
+ function transcodeInputFormat(sourceCodec) {
9305
+ return sourceCodec === "H265" ? "hevc" : "h264";
9306
+ }
9307
+ class AdaptiveSession {
9308
+ sessionId;
9309
+ deviceId;
9310
+ source;
9311
+ logger;
9312
+ intercom;
9313
+ iceConfig;
9314
+ /** Force TURN-relay-only ICE — set for client-offer (Alexa) sessions whose
9315
+ * peer is a cloud media server unreachable via host/srflx behind NAT. */
9316
+ forceRelayOnly = false;
9317
+ /** Latest RTCP Receiver Report from the remote viewer (jitter/loss),
9318
+ * captured via sender.onRtcp. null until the first RR arrives. */
9319
+ lastRr = null;
9320
+ /** Monotonic base (ms, performance.now) for send-time RTP timestamps. */
9321
+ videoRtpClockBaseMs = null;
9322
+ /** Log the camera's actual H.264 profile-level-id once per session. */
9323
+ profileLogged = false;
9324
+ onStats;
9325
+ debug;
9326
+ sourceCodec;
9327
+ /**
9328
+ * H.264 source profile is non-Baseline (Main/High) → egress must be
9329
+ * re-encoded to Constrained Baseline for iOS. Set by the broker from the
9330
+ * camera SPS; ignored for H.265 sources. Default false.
9331
+ */
9332
+ transcodeToBaseline;
9333
+ /** Codec actually negotiated with the browser after SDP answer. */
9334
+ negotiatedCodec = "H264";
9335
+ /**
9336
+ * True when the egress can't be the camera's native bytes: H.265 source
9337
+ * with an H.264-only browser, or an H.264 Main/High source that must be
9338
+ * forced to Constrained Baseline for iOS. See `resolveVideoTranscode`.
9339
+ */
9340
+ get needsTranscode() {
9341
+ return resolveVideoTranscode({
9342
+ sourceCodec: this.sourceCodec,
9343
+ negotiatedCodec: this.negotiatedCodec,
9344
+ transcodeToBaseline: this.transcodeToBaseline
9345
+ });
9346
+ }
9347
+ _firstKeyFrame;
9348
+ /**
9349
+ * Last seen SPS and PPS NALs. Many cameras send SPS/PPS only once
9350
+ * at stream start (not inline with every IDR). We cache them so
9351
+ * PLI-triggered keyframe re-sends include the parameter sets the
9352
+ * decoder needs to re-initialise.
9353
+ */
9354
+ lastSps = null;
9355
+ lastPps = null;
9356
+ /** H.265 VPS (Video Parameter Set) — required before every IRAP for decoder init. */
9357
+ lastVps = null;
9358
+ /** Whether replaceRTP has been called on the video sender to sync SSRC/seq. */
9359
+ videoRtpSynced = false;
9360
+ createdAt;
9361
+ state = "new";
9362
+ pc = null;
9363
+ videoTrack = null;
9364
+ audioTrack = null;
9365
+ /** Transceiver senders for direct sendRtp (more reliable than track.writeRtp) */
9366
+ videoSender = null;
9367
+ audioSender = null;
9368
+ feedAbort = null;
9369
+ closed = false;
9370
+ statsTimer = null;
9371
+ /**
9372
+ * Notification hook invoked exactly once when `close()` finishes. The
9373
+ * adaptive server wires this up after `createSession()` so the ICE
9374
+ * disconnect/failed path (see the `iceConnectionStateChange` handler
9375
+ * that calls `this.close()` on its own) reaches `cam.sessions.delete`
9376
+ * + `scheduleCameraAutoStop`. Without this callback the server kept
9377
+ * stale entries in `cam.sessions`, the auto-stop guard (`size > 0`)
9378
+ * never fired, ffmpeg stayed up, and the RTSP client toward the
9379
+ * broker leaked forever (`rtspClients: 2` on idle brokers).
9380
+ */
9381
+ onClosed = null;
9382
+ /** RTP sequence number counter (must increment per packet). */
9383
+ videoSeqNum = 0;
9384
+ audioSeqNum = 0;
9385
+ /**
9386
+ * Cached last keyframe NALs (SPS + PPS + IDR slices) in Annex-B format,
9387
+ * split and ready for RTP packetization. When the browser sends a PLI
9388
+ * (Picture Loss Indication) because it lost reference frames, we
9389
+ * immediately re-send this stored keyframe so the decoder can recover
9390
+ * without waiting for the next natural keyframe from the encoder.
9391
+ */
9392
+ lastKeyframeNals = null;
9393
+ lastKeyframeRtpTs = 0;
9394
+ /** Throttle: minimum interval between PLI-triggered keyframe re-sends (ms). */
9395
+ static PLI_RESEND_COOLDOWN_MS = 500;
9396
+ lastPliResendAt = 0;
9397
+ /**
9398
+ * Per-session H.265 RTP repacketizer (lazy-init). The H.265 path
9399
+ * forwards source RTP from the broker through this repacketizer so
9400
+ * Chrome's HEVC depacketizer sees an RTP shape it actually accepts —
9401
+ * the prior depacketize → AnnexB → re-packetize-from-scratch path
9402
+ * produced `framesAssembledFromMultiplePackets = 0` regardless of
9403
+ * codec metadata being correct. See `forwardSourceRtpVideo`.
9404
+ */
9405
+ h265Repacketizer = null;
9406
+ /** RTP MTU for the repacketizer. 1100 keeps the full wire packet
9407
+ * (payload + RTP hdr + hdr-extensions + SRTP/GCM tag + UDP + IPv4/IPv6)
9408
+ * comfortably under the Tailscale/WireGuard overlay MTU (1280) — at the
9409
+ * old 1180/1200 a packet is ~1266 B, which fits a plain 1500-MTU LAN but
9410
+ * is at/over the 1280 overlay limit, so keyframe packets silently drop
9411
+ * over Tailscale → the keyframe never reassembles → endless PLI storm
9412
+ * (LAN-OK / remote-fail). 1100 leaves ~90 B of headroom for IPv6. */
9413
+ static H265_REPACKETIZER_MTU = 1100;
9414
+ /**
9415
+ * Per-session H.264 RTP repacketizer (lazy-init). H.264 RTSP sources
9416
+ * forward source RTP through this — same Tailscale-safe MTU and the same
9417
+ * reason as H.265: iOS (WebKit) rejects the `writeVideoNals` synthesized
9418
+ * RTP shape for Main/High passthrough; the repacketizer preserves the
9419
+ * native RTP layout (STAP-A SPS/PPS + FU-A). See `forwardSourceRtpVideo`.
9420
+ */
9421
+ h264Repacketizer = null;
9422
+ static H264_REPACKETIZER_MTU = 1100;
9423
+ /** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
9424
+ rtpBootstrap;
9425
+ rtpBootstrapDone = false;
9426
+ /**
9427
+ * Trickle ICE: the server's locally-gathered candidates, buffered as werift
9428
+ * surfaces them via `onIceCandidate`. The client polls `getIceCandidates`
9429
+ * and adds each. Lets `handleOffer` return the answer IMMEDIATELY (no
9430
+ * gathering wait) — candidates flow afterwards → ~0s connect.
9431
+ */
9432
+ localIceCandidates = [];
9433
+ iceGatheringComplete = false;
9434
+ /** Source SSRC — captured on first source RTP, used to populate the
9435
+ * outbound packets so werift's send pipeline doesn't reject them. */
9436
+ sourceVideoSsrc = null;
9437
+ constructor(options) {
9438
+ this.sessionId = options.sessionId;
9439
+ this.deviceId = options.deviceId;
9440
+ this.source = options.source;
9441
+ this.logger = options.logger;
9442
+ this.intercom = options.intercom;
9443
+ this.iceConfig = options.iceConfig;
9444
+ this.onStats = options.onStats;
9445
+ this.debug = options.debug ?? false;
9446
+ this.sourceCodec = options.sourceCodec ?? "H264";
9447
+ this.transcodeToBaseline = options.transcodeToBaseline ?? false;
9448
+ this.rtpBootstrap = options.rtpBootstrap;
9449
+ this.createdAt = Date.now();
9450
+ }
9451
+ /**
9452
+ * Force TURN-relay-only ICE for this session. MUST be called before
9453
+ * `createOffer()` (which builds the PeerConnection via
9454
+ * `buildPcOptions`) — once the PC exists the policy is fixed.
9455
+ *
9456
+ * Set by the broker for remote (non-LAN) viewers: with the patched
9457
+ * werift, `iceTransportPolicy:'relay'` produces a genuinely relay-only
9458
+ * SDP, so a CGNAT client (which can only offer a relay candidate) gets
9459
+ * a clean relay↔relay media path instead of werift nominating a dead
9460
+ * host/hairpin-srflx pair. The Alexa/WHEP `handleOffer` path sets this
9461
+ * internally; this setter is the server-offer (browser live-view) path.
9462
+ */
9463
+ setForceRelayOnly(value) {
9464
+ this.forceRelayOnly = value;
9465
+ }
9466
+ /**
9467
+ * Wait for ICE gathering to yield a usable candidate, then return — do NOT
9468
+ * block on full gathering "complete". The direct LAN/Tailscale path only
9469
+ * needs a host candidate (present in <1s); waiting for STUN/TURN gathering
9470
+ * to finish cost many seconds of dead startup latency before the offer/
9471
+ * answer could be sent. srflx/relay keep gathering into the local SDP; we
9472
+ * just don't wait on them. A small floor (600ms) lets a fast srflx also
9473
+ * land in the SDP. Hard cap (2.5s) bounds a stalled agent — e.g. a
9474
+ * remote-only peer that genuinely needs a slow relay.
9475
+ */
9476
+ async waitForIceGatheringFast() {
9477
+ if (!this.pc) return;
9478
+ if (this.pc.iceGatheringState === "complete") return;
9479
+ await new Promise((resolve) => {
9480
+ let settled = false;
9481
+ let poll;
9482
+ const finish = () => {
9483
+ if (settled) return;
9484
+ settled = true;
9485
+ if (poll) clearInterval(poll);
9486
+ resolve();
9487
+ };
9488
+ this.pc?.iceGatheringStateChange.subscribe((state) => {
9489
+ if (state === "complete") finish();
9490
+ });
9491
+ const start = Date.now();
9492
+ poll = setInterval(() => {
9493
+ const sdp = this.pc?.localDescription?.sdp ?? "";
9494
+ if (/ typ host/.test(sdp) && Date.now() - start >= 600) finish();
9495
+ }, 100);
9496
+ setTimeout(finish, 2500);
9497
+ });
9498
+ }
9499
+ /**
9500
+ * Read the nominated (selected) ICE candidate pair off the live
9501
+ * werift connection, via the video sender's DTLS → ICE transport.
9502
+ * Returns null until ICE has nominated a pair (or if werift hasn't
9503
+ * wired the transport chain yet). Used by the media diagnostic to
9504
+ * confirm — on a remote retest — whether werift converged on a
9505
+ * relay↔relay pair.
9506
+ */
9507
+ readNominatedPair() {
9508
+ const conn = this.videoSender?.dtlsTransport?.iceTransport?.connection;
9509
+ const pair = conn?.nominated;
9510
+ if (!pair) return null;
9511
+ const lc = pair.localCandidate;
9512
+ const rc = pair.remoteCandidate;
9513
+ return {
9514
+ localType: lc.type,
9515
+ localAddr: `${lc.host}:${lc.port}`,
9516
+ remoteType: rc.type,
9517
+ remoteAddr: `${rc.host}:${rc.port}`
9518
+ };
9519
+ }
9520
+ /**
9521
+ * Log the nominated ICE candidate pair once ICE connects. werift may
9522
+ * populate `connection.nominated` a tick AFTER the "connected" event
9523
+ * fires, so retry briefly before giving up. This is the decisive
9524
+ * one-shot line for a remote retest: a `relay/…` ↔ `relay/…` pair
9525
+ * means the relay-only fix took effect; anything else (or null) on a
9526
+ * remote session is the dead-pair symptom.
9527
+ */
9528
+ logNominatedPair(attempt = 0) {
9529
+ if (this.closed) return;
9530
+ const pair = this.readNominatedPair();
9531
+ if (!pair) {
9532
+ if (attempt < 5) {
9533
+ setTimeout(() => this.logNominatedPair(attempt + 1), 200);
9534
+ return;
9535
+ }
9536
+ this.logger.info("ICE nominated pair", {
9537
+ meta: {
9538
+ phase: "session",
9539
+ sessionId: this.sessionId,
9540
+ deviceId: this.deviceId,
9541
+ forceRelayOnly: this.forceRelayOnly,
9542
+ nominated: "none"
9543
+ }
9544
+ });
9545
+ return;
9546
+ }
9547
+ this.logger.info("ICE nominated pair", {
9548
+ meta: {
9549
+ phase: "session",
9550
+ sessionId: this.sessionId,
9551
+ deviceId: this.deviceId,
9552
+ forceRelayOnly: this.forceRelayOnly,
9553
+ selectedLocalType: pair.localType,
9554
+ selectedLocalAddr: pair.localAddr,
9555
+ selectedRemoteType: pair.remoteType,
9556
+ selectedRemoteAddr: pair.remoteAddr
9557
+ }
9558
+ });
9559
+ }
9560
+ /** Build PeerConnection options including H.264 codec config. */
9561
+ async buildPcOptions() {
9562
+ const werift = await loadWerift();
9563
+ const iceServers = [];
9564
+ for (const entry of this.iceConfig?.iceServers ?? []) {
9565
+ const urlList = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
9566
+ for (const url of urlList) {
9567
+ iceServers.push({
9568
+ urls: url,
9569
+ ...entry.username !== void 0 ? { username: entry.username } : {},
9570
+ ...entry.credential !== void 0 ? { credential: entry.credential } : {}
9571
+ });
9572
+ }
9573
+ }
9574
+ const rtcpFeedback = [
9575
+ { type: "transport-cc" },
9576
+ { type: "ccm", parameter: "fir" },
9577
+ { type: "nack" },
9578
+ { type: "nack", parameter: "pli" },
9579
+ { type: "goog-remb" }
9580
+ ];
9581
+ const h264Codec = new werift.RTCRtpCodecParameters({
9582
+ mimeType: "video/H264",
9583
+ clockRate: 9e4,
9584
+ payloadType: 96,
9585
+ // Constrained Baseline 3.1. iOS Safari WebRTC ONLY negotiates Constrained
9586
+ // Baseline for H.264 RECEIVE — offering Main (4d0033) or High (640034)
9587
+ // makes werift's handleAnswer throw "negotiate codecs failed" because iOS
9588
+ // answers Baseline and werift can't reconcile. So Baseline is the only
9589
+ // value that negotiates with iOS; the catch is iOS then decodes ONLY
9590
+ // Baseline, so the CAMERA must emit Baseline for the picture to render
9591
+ // (set the camera/substream to a low/baseline profile). Desktop Chrome
9592
+ // decodes any profile regardless. For a Main/High camera that can't be
9593
+ // set to Baseline, the only werift-passthrough route to iOS is H.265
9594
+ // (iOS negotiates + HW-decodes HEVC natively); H.264 needs transcoding.
9595
+ parameters: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
9596
+ rtcpFeedback
9597
+ });
9598
+ const h265Codec = new werift.RTCRtpCodecParameters({
8732
9599
  mimeType: "video/H265",
8733
9600
  clockRate: 9e4,
8734
9601
  payloadType: 97,
@@ -8749,13 +9616,20 @@ class AdaptiveSession {
8749
9616
  })
8750
9617
  ]
8751
9618
  },
8752
- // RTP header extensions required for BUNDLE demuxing and congestion control.
8753
- // Without sdes:mid, browsers cannot demux incoming RTP on a BUNDLE'd connection.
9619
+ // RTP header extensions KEEP `sdes:mid` only (required for BUNDLE
9620
+ // demux; without it browsers can't route incoming RTP to the right
9621
+ // m-line). DROP `transport-wide-cc` + `abs-send-time`: werift emits
9622
+ // unreliable timing values on those, which a Chrome receiver feeds into
9623
+ // its jitter buffer. On a ~zero-jitter LAN the bad timing is harmless,
9624
+ // but over a real remote path (Tailscale/4G jitter+RTT) the jitter
9625
+ // buffer discards frames as "too late" → never decodes → PLI storm,
9626
+ // even though every packet arrives (browser sends RR, sends NO NACK).
9627
+ // Dropping them lets Chrome time playout off its own receive clock.
9628
+ // (Distinct from the earlier mistaken full-disable that also removed
9629
+ // sdes:mid and regressed LAN demux.)
8754
9630
  headerExtensions: {
8755
9631
  video: [
8756
- { uri: "urn:ietf:params:rtp-hdrext:sdes:mid" },
8757
- { uri: "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01" },
8758
- { uri: "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" }
9632
+ { uri: "urn:ietf:params:rtp-hdrext:sdes:mid" }
8759
9633
  ],
8760
9634
  audio: [
8761
9635
  { uri: "urn:ietf:params:rtp-hdrext:sdes:mid" }
@@ -8763,9 +9637,51 @@ class AdaptiveSession {
8763
9637
  }
8764
9638
  };
8765
9639
  if (iceServers.length > 0) pcOptions.iceServers = iceServers;
9640
+ if (this.forceRelayOnly) pcOptions.iceTransportPolicy = "relay";
9641
+ if (this.debug) {
9642
+ const loggedPairs = /* @__PURE__ */ new Set();
9643
+ pcOptions.iceFilterCandidatePair = (pair) => {
9644
+ try {
9645
+ const lc = pair.localCandidate;
9646
+ const rc = pair.remoteCandidate;
9647
+ const key = `${lc?.type}:${lc?.host}:${lc?.port}|${rc?.type}:${rc?.host}:${rc?.port}`;
9648
+ if (!loggedPairs.has(key)) {
9649
+ loggedPairs.add(key);
9650
+ this.logger.info("ICE candidate pair", {
9651
+ meta: {
9652
+ phase: "session",
9653
+ sessionId: this.sessionId,
9654
+ local: `${lc?.type ?? "?"}/${lc?.host ?? "?"}:${lc?.port ?? "?"}`,
9655
+ remote: `${rc?.type ?? "?"}/${rc?.host ?? "?"}:${rc?.port ?? "?"}`,
9656
+ proto: pair?.protocol?.type
9657
+ }
9658
+ });
9659
+ }
9660
+ } catch {
9661
+ }
9662
+ return true;
9663
+ };
9664
+ }
8766
9665
  if (this.iceConfig?.portRange) pcOptions.icePortRange = this.iceConfig.portRange;
8767
- if (this.iceConfig?.additionalHostAddresses?.length) {
8768
- pcOptions.iceAdditionalHostAddresses = [...this.iceConfig.additionalHostAddresses];
9666
+ const tailscaleHosts = getTailscaleHostAddresses();
9667
+ const mergedHostAddrs = [
9668
+ .../* @__PURE__ */ new Set([
9669
+ ...this.iceConfig?.additionalHostAddresses ?? [],
9670
+ ...tailscaleHosts
9671
+ ])
9672
+ ];
9673
+ if (mergedHostAddrs.length > 0) {
9674
+ pcOptions.iceAdditionalHostAddresses = mergedHostAddrs;
9675
+ if (tailscaleHosts.length > 0) {
9676
+ this.logger.info("offering Tailscale host candidate(s)", {
9677
+ meta: {
9678
+ phase: "session",
9679
+ sessionId: this.sessionId,
9680
+ tailscaleHosts: tailscaleHosts.join(","),
9681
+ additionalHostAddresses: mergedHostAddrs.join(",")
9682
+ }
9683
+ });
9684
+ }
8769
9685
  }
8770
9686
  return { werift, pcOptions };
8771
9687
  }
@@ -8774,9 +9690,10 @@ class AdaptiveSession {
8774
9690
  const { werift, pcOptions } = await this.buildPcOptions();
8775
9691
  this.pc = new werift.RTCPeerConnection(pcOptions);
8776
9692
  this.pc.iceConnectionStateChange.subscribe((state) => {
8777
- this.logger.info("ICE state changed", { meta: { phase: "session", sessionId: this.sessionId, state } });
9693
+ this.logger.info("ICE state changed", { meta: { phase: "session", sessionId: this.sessionId, deviceId: this.deviceId, state, forceRelayOnly: this.forceRelayOnly } });
8778
9694
  if (state === "connected") {
8779
9695
  this.state = "connected";
9696
+ this.logNominatedPair();
8780
9697
  this.startStatsCollection();
8781
9698
  } else if (state === "disconnected" || state === "failed" || state === "closed") {
8782
9699
  this.state = state === "disconnected" ? "disconnected" : "closed";
@@ -8809,16 +9726,7 @@ class AdaptiveSession {
8809
9726
  }
8810
9727
  const offer = await this.pc.createOffer();
8811
9728
  await this.pc.setLocalDescription(offer);
8812
- await new Promise((resolve) => {
8813
- if (this.pc?.iceGatheringState === "complete") {
8814
- resolve();
8815
- return;
8816
- }
8817
- this.pc?.iceGatheringStateChange.subscribe((state) => {
8818
- if (state === "complete") resolve();
8819
- });
8820
- setTimeout(resolve, 5e3);
8821
- });
9729
+ await this.waitForIceGatheringFast();
8822
9730
  let finalSdp = this.pc.localDescription?.sdp ?? offer.sdp;
8823
9731
  finalSdp = finalSdp.replace(/a=setup:active\r?\n/g, "a=setup:actpass\r\n");
8824
9732
  this.state = "connecting";
@@ -8843,8 +9751,8 @@ class AdaptiveSession {
8843
9751
  const resolvedSdp = await resolveMdnsCandidatesInSdp(answer.sdp, this.logger, `session:${this.sessionId}`);
8844
9752
  const desc = new werift.RTCSessionDescription(resolvedSdp, answer.type);
8845
9753
  await this.pc.setRemoteDescription(desc);
8846
- const answerVideoCodec = resolvedSdp.match(/a=rtpmap:\d+ (H264|H265)\/90000/i)?.[1]?.toUpperCase();
8847
- if (answerVideoCodec === "H264" || answerVideoCodec === "H265") {
9754
+ const answerVideoCodec = detectNegotiatedVideoCodec(resolvedSdp);
9755
+ if (answerVideoCodec) {
8848
9756
  this.negotiatedCodec = answerVideoCodec;
8849
9757
  }
8850
9758
  this.logger.info("Codec negotiated", {
@@ -8884,27 +9792,49 @@ class AdaptiveSession {
8884
9792
  const { werift, pcOptions } = await this.buildPcOptions();
8885
9793
  this.pc = new werift.RTCPeerConnection(pcOptions);
8886
9794
  this.pc.iceConnectionStateChange.subscribe((state) => {
8887
- this.logger.debug("ICE state", { meta: { phase: "session", sessionId: this.sessionId, state } });
9795
+ this.logger.info("ICE state", { meta: { phase: "session", sessionId: this.sessionId, deviceId: this.deviceId, state, forceRelayOnly: this.forceRelayOnly } });
8888
9796
  if (state === "connected") {
8889
9797
  this.state = "connected";
9798
+ this.logNominatedPair();
8890
9799
  this.startStatsCollection();
8891
9800
  } else if (state === "disconnected" || state === "failed" || state === "closed") {
8892
9801
  this.state = state === "disconnected" ? "disconnected" : "closed";
8893
9802
  void this.close();
8894
9803
  }
8895
9804
  });
9805
+ this.pc.onIceCandidate.subscribe((c) => {
9806
+ if (!c || !c.candidate) {
9807
+ this.iceGatheringComplete = true;
9808
+ return;
9809
+ }
9810
+ this.localIceCandidates.push({
9811
+ candidate: c.candidate,
9812
+ sdpMid: c.sdpMid ?? null,
9813
+ sdpMLineIndex: c.sdpMLineIndex ?? null
9814
+ });
9815
+ });
8896
9816
  const resolvedOfferSdp = await resolveMdnsCandidatesInSdp(clientOffer.sdp, this.logger, `session:${this.sessionId}`);
9817
+ const offerCandidates = resolvedOfferSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
9818
+ if (this.debug) {
9819
+ this.logger.info("client offer ICE candidates", {
9820
+ meta: { phase: "session", sessionId: this.sessionId, count: offerCandidates.length, candidates: offerCandidates }
9821
+ });
9822
+ }
8897
9823
  const remoteDesc = new werift.RTCSessionDescription(resolvedOfferSdp, clientOffer.type);
8898
9824
  await this.pc.setRemoteDescription(remoteDesc);
8899
9825
  const transceivers = this.pc.getTransceivers();
8900
9826
  for (const t of transceivers) {
8901
9827
  const kind = t.receiver?.track?.kind ?? t.kind;
8902
9828
  if (kind === "video" && !this.videoTrack) {
9829
+ t.setDirection("sendonly");
8903
9830
  this.videoTrack = new werift.MediaStreamTrack({ kind: "video" });
8904
9831
  await t.sender.replaceTrack(this.videoTrack);
9832
+ this.videoSender = t.sender;
8905
9833
  } else if (kind === "audio" && !this.audioTrack) {
9834
+ t.setDirection("sendonly");
8906
9835
  this.audioTrack = new werift.MediaStreamTrack({ kind: "audio" });
8907
9836
  await t.sender.replaceTrack(this.audioTrack);
9837
+ this.audioSender = t.sender;
8908
9838
  }
8909
9839
  }
8910
9840
  if (!this.videoTrack) {
@@ -8912,28 +9842,76 @@ class AdaptiveSession {
8912
9842
  meta: { phase: "session", sessionId: this.sessionId }
8913
9843
  });
8914
9844
  this.videoTrack = new werift.MediaStreamTrack({ kind: "video" });
8915
- this.pc.addTransceiver(this.videoTrack, { direction: "sendonly" });
9845
+ const videoTransceiver = this.pc.addTransceiver(this.videoTrack, { direction: "sendonly" });
9846
+ this.videoSender = videoTransceiver.sender;
8916
9847
  }
8917
9848
  if (!this.audioTrack) {
8918
9849
  this.logger.warn("No audio transceiver found in offer, adding one", {
8919
9850
  meta: { phase: "session", sessionId: this.sessionId }
8920
9851
  });
8921
9852
  this.audioTrack = new werift.MediaStreamTrack({ kind: "audio" });
8922
- this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
9853
+ const audioTransceiver = this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
9854
+ this.audioSender = audioTransceiver.sender;
8923
9855
  }
8924
9856
  const answerDesc = await this.pc.createAnswer();
8925
- await this.pc.setLocalDescription(answerDesc);
9857
+ void this.pc.setLocalDescription(answerDesc).catch((e) => {
9858
+ this.logger.warn("setLocalDescription failed", { meta: { phase: "session", sessionId: this.sessionId, error: errMsg(e) } });
9859
+ });
9860
+ const finalSdp = this.pc?.localDescription?.sdp ?? answerDesc.sdp;
8926
9861
  this.state = "connecting";
8927
- this.logger.info("WHEP answer created", { meta: { phase: "session", sessionId: this.sessionId } });
9862
+ const answerVideoCodec = detectNegotiatedVideoCodec(finalSdp);
9863
+ if (answerVideoCodec) {
9864
+ this.negotiatedCodec = answerVideoCodec;
9865
+ }
9866
+ this.logger.info("Codec negotiated (client-offer)", {
9867
+ meta: { phase: "session", sessionId: this.sessionId, source: this.sourceCodec, negotiated: this.negotiatedCodec, needsTranscode: this.needsTranscode }
9868
+ });
9869
+ if (this.debug) {
9870
+ const gatheredCandidates = finalSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
9871
+ this.logger.info("WHEP answer created", {
9872
+ meta: { phase: "session", sessionId: this.sessionId, candidateCount: gatheredCandidates.length, candidates: gatheredCandidates }
9873
+ });
9874
+ }
9875
+ void this.startFeedingWhenDtlsReady();
9876
+ return { sdp: finalSdp, type: "answer" };
9877
+ }
9878
+ /**
9879
+ * Wait for the video sender's DTLS transport to connect, then start
9880
+ * feeding. Used by the client-offer (`handleOffer`) path, which must
9881
+ * return its SDP answer before the browser performs the DTLS handshake —
9882
+ * so feeding can only safely begin once the transport is up. Mirrors the
9883
+ * inline DTLS wait `handleAnswer` does for the server-offer path. Bounded
9884
+ * (10s) so a never-connecting session doesn't leak the wait forever.
9885
+ */
9886
+ async startFeedingWhenDtlsReady() {
9887
+ const dtlsTransport = this.videoSender?.dtlsTransport;
9888
+ if (dtlsTransport && dtlsTransport.state !== "connected") {
9889
+ const deadline = Date.now() + 1e4;
9890
+ while (dtlsTransport.state !== "connected" && Date.now() < deadline && !this.closed) {
9891
+ await new Promise((r) => setTimeout(r, 100));
9892
+ }
9893
+ this.logger.debug("client-offer DTLS wait complete", {
9894
+ meta: { phase: "session", sessionId: this.sessionId, state: dtlsTransport.state }
9895
+ });
9896
+ }
9897
+ if (this.closed) return;
8928
9898
  this.startFeedingFrames();
8929
- return { sdp: answerDesc.sdp, type: "answer" };
8930
9899
  }
8931
- /** Add ICE candidate. */
9900
+ /** Add a remote (client) ICE candidate — trickle ICE client→server. */
8932
9901
  async addIceCandidate(candidate) {
8933
9902
  if (!this.pc) throw new Error("Call createOffer() first");
8934
9903
  const werift = await loadWerift();
8935
9904
  await this.pc.addIceCandidate(new werift.RTCIceCandidate(candidate));
8936
9905
  }
9906
+ /**
9907
+ * Snapshot of the server's locally-gathered ICE candidates for trickle
9908
+ * polling (server→client). The client polls this after receiving the bare
9909
+ * answer and adds each candidate; `done` flips true when gathering finishes
9910
+ * so the client can stop polling.
9911
+ */
9912
+ getIceCandidatesSnapshot() {
9913
+ return { candidates: [...this.localIceCandidates], done: this.iceGatheringComplete };
9914
+ }
8937
9915
  /**
8938
9916
  * Detach the frame source (for connection pooling).
8939
9917
  * The session stays alive (ICE/DTLS connected) but stops feeding frames.
@@ -9041,46 +10019,111 @@ class AdaptiveSession {
9041
10019
  codecInfo
9042
10020
  );
9043
10021
  }
10022
+ /**
10023
+ * Seed the H.264 repacketizer's SPS/PPS from SDP-published parameter sets
10024
+ * so the STAP-A codec packet can precede the first IDR even when the
10025
+ * camera doesn't emit param sets in-band. H.264 sibling of
10026
+ * `seedH265CodecInfoFromSdp`. No-op on non-H.264 sessions.
10027
+ */
10028
+ seedH264CodecInfoFromSdp(parameterSets) {
10029
+ if (this.negotiatedCodec !== "H264") return;
10030
+ if (!parameterSets.length) return;
10031
+ this.ensureH264Repacketizer();
10032
+ const rep = this.h264Repacketizer;
10033
+ for (const ps of parameterSets) {
10034
+ const nalType = ps[0] & 31;
10035
+ if (nalType === 7) rep.updateSps(Buffer.from(ps));
10036
+ else if (nalType === 8) rep.updatePps(Buffer.from(ps));
10037
+ }
10038
+ if (this.debug) {
10039
+ this.logger.info("seeded H.264 repacketizer codecInfo from SDP", {
10040
+ meta: {
10041
+ phase: "session",
10042
+ sessionId: this.sessionId,
10043
+ psCount: parameterSets.length,
10044
+ hasSps: !!rep.codecInfo?.sps,
10045
+ hasPps: !!rep.codecInfo?.pps
10046
+ }
10047
+ });
10048
+ }
10049
+ }
10050
+ ensureH264Repacketizer() {
10051
+ if (this.h264Repacketizer) return;
10052
+ this.h264Repacketizer = new H264Repacketizer(
10053
+ console,
10054
+ AdaptiveSession.H264_REPACKETIZER_MTU
10055
+ );
10056
+ }
9044
10057
  /**
9045
10058
  * Forward a source RTP video packet (raw on-wire bytes) through the
9046
- * H.265 repacketizer to the browser. Used by the broker's H.265
9047
- * direct-RTP subscription — bypasses the AnnexB→writeVideoNals
9048
- * path entirely.
10059
+ * codec's repacketizer to the browser. Used by the broker's RTSP
10060
+ * direct-RTP subscription for BOTH H.265 and H.264 — bypasses the
10061
+ * AnnexB→writeVideoNals path entirely so the native RTP layout (which
10062
+ * iOS's strict depacketizer requires) is preserved.
9049
10063
  *
9050
10064
  * Drops everything until the SDP answer has been negotiated
9051
- * (`negotiatedCodec`/`videoSender` populated) and silently no-ops on
9052
- * non-H.265 sessions.
10065
+ * (`negotiatedCodec`/`videoSender` populated).
9053
10066
  */
9054
10067
  forwardSourceRtpVideo(rtpData) {
9055
10068
  if (this.closed) return;
9056
- if (this.negotiatedCodec !== "H265") return;
10069
+ const codec = this.negotiatedCodec;
10070
+ if (codec !== "H265" && codec !== "H264") return;
9057
10071
  if (!this.videoSender || !_werift) return;
10072
+ if (this.videoSender.dtlsTransport?.state !== "connected") return;
10073
+ if (!this.rtpBootstrapDone) {
10074
+ this.rtpBootstrapDone = true;
10075
+ const bootstrap = this.rtpBootstrap?.() ?? [];
10076
+ if (bootstrap.length > 0) {
10077
+ let sent = 0;
10078
+ for (const b of bootstrap) {
10079
+ if (this.sendSourceRtpPacket(b, codec)) sent++;
10080
+ }
10081
+ this.logger.info("replayed source-RTP pre-buffer (instant start)", {
10082
+ meta: { phase: "session", sessionId: this.sessionId, codec, packets: bootstrap.length, sent }
10083
+ });
10084
+ return;
10085
+ }
10086
+ }
10087
+ this.sendSourceRtpPacket(rtpData, codec);
10088
+ }
10089
+ /**
10090
+ * Repacketize one source RTP packet and send it on the video sender.
10091
+ * Shared by the live forward path and the pre-buffer bootstrap replay.
10092
+ * Returns true when at least one output packet was sent.
10093
+ */
10094
+ sendSourceRtpPacket(rtpData, codec) {
10095
+ if (!this.videoSender || !_werift) return false;
9058
10096
  const werift = _werift;
9059
- this.ensureH265Repacketizer();
9060
- const rep = this.h265Repacketizer;
9061
10097
  let srcPkt;
9062
10098
  try {
9063
10099
  srcPkt = werift.RtpPacket.deSerialize(rtpData);
9064
10100
  } catch (err) {
9065
- this.logger.warn("H265 RTP deserialize failed", {
9066
- meta: { phase: "session", sessionId: this.sessionId, error: errMsg(err), len: rtpData.length }
10101
+ this.logger.warn("source RTP deserialize failed", {
10102
+ meta: { phase: "session", sessionId: this.sessionId, codec, error: errMsg(err), len: rtpData.length }
9067
10103
  });
9068
- return;
10104
+ return false;
9069
10105
  }
9070
10106
  if (this.sourceVideoSsrc === null) {
9071
10107
  this.sourceVideoSsrc = srcPkt.header.ssrc;
9072
10108
  }
9073
10109
  const senderCodec = this.videoSender.codec;
9074
- const pt = senderCodec?.payloadType ?? 97;
10110
+ const pt = senderCodec?.payloadType ?? (codec === "H265" ? 97 : 96);
9075
10111
  let outPkts;
9076
10112
  try {
9077
- outPkts = rep.repacketize(srcPkt);
10113
+ if (codec === "H265") {
10114
+ this.ensureH265Repacketizer();
10115
+ outPkts = this.h265Repacketizer.repacketize(srcPkt);
10116
+ } else {
10117
+ this.ensureH264Repacketizer();
10118
+ outPkts = this.h264Repacketizer.repacketize(srcPkt);
10119
+ }
9078
10120
  } catch (err) {
9079
- this.logger.warn("H265 repacketize failed", {
9080
- meta: { phase: "session", sessionId: this.sessionId, error: errMsg(err) }
10121
+ this.logger.warn("repacketize failed", {
10122
+ meta: { phase: "session", sessionId: this.sessionId, codec, error: errMsg(err) }
9081
10123
  });
9082
- return;
10124
+ return false;
9083
10125
  }
10126
+ let sent = false;
9084
10127
  for (const pkt of outPkts) {
9085
10128
  pkt.header.payloadType = pt;
9086
10129
  if (!this.videoRtpSynced) {
@@ -9099,19 +10142,21 @@ class AdaptiveSession {
9099
10142
  try {
9100
10143
  this.videoSender.sendRtp(pkt);
9101
10144
  this.rtpPacketsSent++;
10145
+ sent = true;
9102
10146
  if (this.debug && (this.rtpPacketsSent === 1 || this.rtpPacketsSent % 500 === 0)) {
9103
- this.logger.info("H265 source-RTP forwarded", {
9104
- meta: { phase: "session", sessionId: this.sessionId, count: this.rtpPacketsSent }
10147
+ this.logger.info("source-RTP forwarded", {
10148
+ meta: { phase: "session", sessionId: this.sessionId, codec, count: this.rtpPacketsSent }
9105
10149
  });
9106
10150
  }
9107
10151
  } catch (err) {
9108
10152
  if (this.rtpPacketsSent <= 10) {
9109
- this.logger.error("sendRtp (h265 forward) error", {
9110
- meta: { phase: "session", sessionId: this.sessionId, error: errMsg(err) }
10153
+ this.logger.error("sendRtp (source forward) error", {
10154
+ meta: { phase: "session", sessionId: this.sessionId, codec, error: errMsg(err) }
9111
10155
  });
9112
10156
  }
9113
10157
  }
9114
10158
  }
10159
+ return sent;
9115
10160
  }
9116
10161
  // -----------------------------------------------------------------------
9117
10162
  // PLI handling — resend cached keyframe on picture loss
@@ -9120,6 +10165,14 @@ class AdaptiveSession {
9120
10165
  const sender = this.videoSender;
9121
10166
  if (!sender) return;
9122
10167
  const onPli = () => this.handlePli();
10168
+ if (sender.onRtcp) {
10169
+ sender.onRtcp.subscribe((rtcp) => {
10170
+ const reports = rtcp?.reports;
10171
+ if (!reports || reports.length === 0) return;
10172
+ const mine = reports.find((r) => r.ssrc === sender.ssrc) ?? reports[0];
10173
+ if (mine) this.lastRr = mine;
10174
+ });
10175
+ }
9123
10176
  if (sender.onPictureLossIndication) {
9124
10177
  sender.onPictureLossIndication.subscribe(onPli);
9125
10178
  this.logger.debug("PLI listener attached (onPictureLossIndication)", {
@@ -9158,7 +10211,7 @@ class AdaptiveSession {
9158
10211
  totalBytes: this.lastKeyframeNals.reduce((s, n) => s + n.length, 0)
9159
10212
  }
9160
10213
  });
9161
- this.writeVideoNals(this.lastKeyframeNals, this.lastKeyframeRtpTs, this.negotiatedCodec);
10214
+ this.writeVideoNals(this.lastKeyframeNals, this.sendRtpTimestamp(), this.negotiatedCodec);
9162
10215
  }
9163
10216
  // -----------------------------------------------------------------------
9164
10217
  // Frame feeding
@@ -9183,7 +10236,6 @@ class AdaptiveSession {
9183
10236
  startDirectFeed(signal) {
9184
10237
  void (async () => {
9185
10238
  let gotKeyframe = false;
9186
- let videoTimestampBase = null;
9187
10239
  let audioTimestampBase = null;
9188
10240
  let frameCount = 0;
9189
10241
  try {
@@ -9236,10 +10288,7 @@ class AdaptiveSession {
9236
10288
  meta: { phase: "session", sessionId: this.sessionId, frameCount, size: annexB.length, ice: iceState }
9237
10289
  });
9238
10290
  }
9239
- if (videoTimestampBase === null) videoTimestampBase = frame.timestampMicros;
9240
- const rtpTs = Math.floor(
9241
- (frame.timestampMicros - videoTimestampBase) * 9e4 / 1e6
9242
- ) >>> 0;
10291
+ const rtpTs = this.sendRtpTimestamp();
9243
10292
  const isH265 = frame.codec === "H265";
9244
10293
  const allNals = splitAnnexBToNals(annexB);
9245
10294
  const nals = allNals.filter((n) => {
@@ -9297,7 +10346,25 @@ class AdaptiveSession {
9297
10346
  let auNals = nals;
9298
10347
  for (const n of nals) {
9299
10348
  const nalType = n[0] & 31;
9300
- if (nalType === 7) this.lastSps = Buffer.from(n);
10349
+ if (nalType === 7) {
10350
+ this.lastSps = Buffer.from(n);
10351
+ if (!this.profileLogged && n.length >= 4) {
10352
+ this.profileLogged = true;
10353
+ const plid = `${n[1].toString(16).padStart(2, "0")}${n[2].toString(16).padStart(2, "0")}${n[3].toString(16).padStart(2, "0")}`;
10354
+ this.logger.info("camera H.264 SPS profile-level-id", {
10355
+ meta: {
10356
+ phase: "session",
10357
+ sessionId: this.sessionId,
10358
+ deviceId: this.deviceId,
10359
+ actualProfileLevelId: plid,
10360
+ advertisedProfileLevelId: "42e01f",
10361
+ match: plid === "42e01f",
10362
+ profileIdc: n[1],
10363
+ levelIdc: n[3]
10364
+ }
10365
+ });
10366
+ }
10367
+ }
9301
10368
  if (nalType === 8) this.lastPps = Buffer.from(n);
9302
10369
  }
9303
10370
  if (isH264IdrAccessUnit(annexB)) {
@@ -9344,22 +10411,25 @@ class AdaptiveSession {
9344
10411
  }
9345
10412
  })();
9346
10413
  }
9347
- /** Max RTP payload size (MTU 1200 to stay under typical network MTU). */
9348
10414
  /**
9349
- * Transcode feed — source is H.265 but browser only supports H.264.
9350
- * Pipes raw H.265 Annex-B to ffmpeg stdin, reads H.264 from stdout.
10415
+ * Transcode feed — re-encode the source to Constrained Baseline H.264:
10416
+ * H.265→H.264 (browser lacks HEVC) or H.264 Main/High→Baseline (iOS only
10417
+ * decodes Baseline). Pipes the source Annex-B to ffmpeg stdin, reads
10418
+ * Baseline H.264 from stdout, and re-frames it into access units so each
10419
+ * picture is sent with one RTP timestamp (see the stdout handler).
9351
10420
  */
9352
10421
  startTranscodeFeed(signal) {
9353
10422
  const { spawn: spawn2 } = require("node:child_process");
9354
- this.logger.info("Starting H.265→H.264 transcode feed", {
9355
- meta: { phase: "session", sessionId: this.sessionId }
10423
+ const inputFormat = transcodeInputFormat(this.sourceCodec);
10424
+ this.logger.info(`Starting ${this.sourceCodec}→H.264 Baseline transcode feed`, {
10425
+ meta: { phase: "session", sessionId: this.sessionId, sourceCodec: this.sourceCodec, inputFormat }
9356
10426
  });
9357
10427
  const ff = spawn2("ffmpeg", [
9358
10428
  "-hide_banner",
9359
10429
  "-loglevel",
9360
10430
  "error",
9361
10431
  "-f",
9362
- "hevc",
10432
+ inputFormat,
9363
10433
  "-i",
9364
10434
  "pipe:0",
9365
10435
  "-c:v",
@@ -9390,18 +10460,24 @@ class AdaptiveSession {
9390
10460
  if (msg.length > 0) this.logger.warn("Transcode ffmpeg stderr", { meta: { sessionId: this.sessionId, msg } });
9391
10461
  });
9392
10462
  let pendingBuf = Buffer.alloc(0);
9393
- let rtpTs = 0;
9394
- const FRAME_INTERVAL_90K = 3e3;
10463
+ let carryNals = [];
9395
10464
  ff.stdout.on("data", (chunk) => {
9396
10465
  pendingBuf = Buffer.concat([pendingBuf, chunk]);
9397
10466
  const lastSc = findLastStartCode(pendingBuf);
9398
10467
  if (lastSc <= 0) return;
9399
10468
  const complete = pendingBuf.subarray(0, lastSc);
9400
10469
  pendingBuf = Buffer.from(pendingBuf.subarray(lastSc));
9401
- const completedNals = splitAnnexBToNals(complete);
9402
- if (completedNals.length === 0) return;
9403
- rtpTs = rtpTs + FRAME_INTERVAL_90K >>> 0;
9404
- this.writeVideoNals(completedNals, rtpTs, "H264");
10470
+ const nals = carryNals.concat(splitAnnexBToNals(complete));
10471
+ const accessUnits = groupNalsIntoAccessUnits(nals);
10472
+ if (accessUnits.length <= 1) {
10473
+ carryNals = nals;
10474
+ return;
10475
+ }
10476
+ carryNals = accessUnits[accessUnits.length - 1];
10477
+ for (let i = 0; i < accessUnits.length - 1; i++) {
10478
+ const auNals = accessUnits[i].filter((n) => (n[0] & 31) !== 6);
10479
+ if (auNals.length > 0) this.writeVideoNals(auNals, this.sendRtpTimestamp(), "H264");
10480
+ }
9405
10481
  });
9406
10482
  void (async () => {
9407
10483
  try {
@@ -9426,9 +10502,36 @@ class AdaptiveSession {
9426
10502
  }
9427
10503
  })();
9428
10504
  }
9429
- static MAX_RTP_PAYLOAD = 1200;
10505
+ // 1100 (not 1200): keep the full wire packet under the Tailscale/WireGuard
10506
+ // overlay MTU (1280). See H265_REPACKETIZER_MTU for the rationale — at 1200
10507
+ // a keyframe's RTP packets are ~1266 B and silently drop over the overlay,
10508
+ // causing a never-decoding PLI storm that works fine on a 1500-MTU LAN.
10509
+ static MAX_RTP_PAYLOAD = 1100;
9430
10510
  rtpPacketsSent = 0;
9431
10511
  rtpLogCounter = 0;
10512
+ /**
10513
+ * RTP timestamp (90 kHz) derived from the hub's MONOTONIC clock at the
10514
+ * moment of sending — NOT the camera capture time.
10515
+ *
10516
+ * Forwarding live media with capture-time RTP timestamps creates a cadence
10517
+ * mismatch vs the actual send time: the pipeline/decoder adds variable
10518
+ * latency between capture (`frame.timestampMicros`) and the instant we push
10519
+ * the packets out. A remote receiver measures that mismatch as high RTCP
10520
+ * jitter (~80 ms observed over Tailscale, with ZERO packet loss) — its
10521
+ * jitter buffer can't lock onto a stable clock, so frames are discarded and
10522
+ * it PLI-storms. A ~0-RTT LAN absorbs it; a real remote path does not.
10523
+ *
10524
+ * Stamping off send time makes the RTP-timestamp cadence equal the actual
10525
+ * transmit cadence, so arrival cadence matches and the receiver's jitter
10526
+ * collapses to true network jitter. Same effect ffmpeg/libwebrtc achieve by
10527
+ * pacing output. All video send paths (live feed + PLI keyframe resend)
10528
+ * share this one clock so timestamps stay monotonic.
10529
+ */
10530
+ sendRtpTimestamp() {
10531
+ const now = performance.now();
10532
+ if (this.videoRtpClockBaseMs === null) this.videoRtpClockBaseMs = now;
10533
+ return Math.floor((now - this.videoRtpClockBaseMs) * 90) >>> 0;
10534
+ }
9432
10535
  writeVideoNals(nals, rtpTs, codec) {
9433
10536
  if (!this.videoSender || !_werift) {
9434
10537
  if (this.rtpLogCounter === 0) {
@@ -9573,40 +10676,52 @@ class AdaptiveSession {
9573
10676
  // RTCP stats collection
9574
10677
  // -----------------------------------------------------------------------
9575
10678
  startStatsCollection() {
9576
- if (this.statsTimer || !this.onStats) return;
10679
+ if (this.statsTimer) return;
9577
10680
  this.statsTimer = setInterval(() => {
9578
10681
  if (!this.pc || this.closed) return;
9579
10682
  this.collectStats();
9580
10683
  }, 3e3);
9581
10684
  }
9582
10685
  collectStats() {
9583
- if (!this.pc || !this.onStats) return;
9584
- try {
9585
- const senders = this.pc.getSenders?.() ?? [];
9586
- for (const sender of senders) {
9587
- const track = sender.track;
9588
- if (!track || track.kind !== "video") continue;
9589
- const report = sender.lastReceiverReport ?? sender.rtcpReport;
9590
- if (!report) continue;
9591
- const fractionLost = report.fractionLost ?? 0;
9592
- const packetsLost = report.packetsLost ?? report.cumulativeLost ?? 0;
9593
- const jitter = report.jitter ?? 0;
9594
- const rtt = report.roundTripTime ?? report.rtt ?? 0;
9595
- const packetLoss = fractionLost / 256;
9596
- this.onStats({
9597
- sessionId: this.sessionId,
9598
- packetLoss,
9599
- jitterMs: jitter,
9600
- rttMs: rtt * 1e3,
9601
- // seconds → ms
9602
- packetsReceived: 0,
9603
- // Not available from sender side
9604
- packetsLost,
9605
- timestamp: Date.now()
9606
- });
9607
- return;
10686
+ if (!this.pc) return;
10687
+ const report = this.lastRr ?? void 0;
10688
+ const fractionLost = report?.fractionLost ?? 0;
10689
+ const packetsLost = report?.packetsLost ?? report?.cumulativeLost ?? 0;
10690
+ const jitter = report?.jitter ?? 0;
10691
+ const rtt = report?.roundTripTime ?? report?.rtt ?? 0;
10692
+ const nominated = this.readNominatedPair();
10693
+ this.logger.info("webrtc media diag", {
10694
+ meta: {
10695
+ phase: "session",
10696
+ sessionId: this.sessionId,
10697
+ deviceId: this.deviceId,
10698
+ rtpPacketsSent: this.rtpPacketsSent,
10699
+ dtls: this.videoSender?.dtlsTransport?.state ?? "unknown",
10700
+ ice: this.state,
10701
+ forceRelayOnly: this.forceRelayOnly,
10702
+ hasReceiverReport: !!report,
10703
+ fractionLostPct: report ? Math.round(fractionLost / 256 * 100) : -1,
10704
+ packetsLost,
10705
+ // video RTP clock is 90 kHz → jitter ticks / 90 = milliseconds.
10706
+ jitterMs: report ? Math.round(jitter / 90) : -1,
10707
+ rttMs: Math.round(rtt * 1e3),
10708
+ selectedLocalType: nominated?.localType ?? "none",
10709
+ selectedLocalAddr: nominated?.localAddr ?? "none",
10710
+ selectedRemoteType: nominated?.remoteType ?? "none",
10711
+ selectedRemoteAddr: nominated?.remoteAddr ?? "none"
9608
10712
  }
9609
- } catch {
10713
+ });
10714
+ if (report && this.onStats) {
10715
+ this.onStats({
10716
+ sessionId: this.sessionId,
10717
+ packetLoss: fractionLost / 256,
10718
+ // Fraction lost is 0–255
10719
+ jitterMs: jitter,
10720
+ rttMs: rtt * 1e3,
10721
+ packetsReceived: 0,
10722
+ packetsLost,
10723
+ timestamp: Date.now()
10724
+ });
9610
10725
  }
9611
10726
  }
9612
10727
  }
@@ -9616,6 +10731,49 @@ function findLastStartCode(buf) {
9616
10731
  }
9617
10732
  return -1;
9618
10733
  }
10734
+ function resolveBrokerTranscodeToBaseline(sessionCodec, sourceProfileLevelId) {
10735
+ if (sessionCodec !== "H264") return false;
10736
+ if (!sourceProfileLevelId) return false;
10737
+ return !isBaselineProfileLevelId(sourceProfileLevelId);
10738
+ }
10739
+ function h264ProfileIdc(profileLevelId) {
10740
+ if (profileLevelId.length < 2) return 0;
10741
+ const idc = Number.parseInt(profileLevelId.slice(0, 2), 16);
10742
+ return Number.isNaN(idc) ? 0 : idc;
10743
+ }
10744
+ function extractOfferedH264ProfileLevelIds(sdp) {
10745
+ const ids = [];
10746
+ const re = /profile-level-id=([0-9a-fA-F]{6})/g;
10747
+ let m;
10748
+ while ((m = re.exec(sdp)) !== null) ids.push(m[1].toLowerCase());
10749
+ return ids;
10750
+ }
10751
+ function resolveClientOfferTranscodeToBaseline(sessionCodec, sourceProfileLevelId, offeredProfileLevelIds) {
10752
+ if (sessionCodec !== "H264") return false;
10753
+ if (!sourceProfileLevelId) return false;
10754
+ if (isBaselineProfileLevelId(sourceProfileLevelId)) return false;
10755
+ const sourceIdc = h264ProfileIdc(sourceProfileLevelId);
10756
+ const maxOfferedIdc = offeredProfileLevelIds.reduce(
10757
+ (max, plid) => Math.max(max, h264ProfileIdc(plid)),
10758
+ 0
10759
+ );
10760
+ return maxOfferedIdc < sourceIdc;
10761
+ }
10762
+ function deriveH264ProfileLevelId(sdpParameterSets, preBuffer) {
10763
+ if (sdpParameterSets) {
10764
+ for (const ps of sdpParameterSets) {
10765
+ if (ps.length >= 4 && (ps[0] & 31) === 7) {
10766
+ return Buffer.from([ps[1], ps[2], ps[3]]).toString("hex");
10767
+ }
10768
+ }
10769
+ }
10770
+ for (const pkt of preBuffer) {
10771
+ if (pkt.type !== "video") continue;
10772
+ const { profileLevelId } = extractH264ParamSets(convertH264ToAnnexB(pkt.data));
10773
+ if (profileLevelId) return profileLevelId;
10774
+ }
10775
+ return void 0;
10776
+ }
9619
10777
  const LABEL_DEFAULTS = {
9620
10778
  high: { pixels: 1920 * 1080, bitrateKbps: 4e3 },
9621
10779
  mid: { pixels: 1280 * 720, bitrateKbps: 1200 },
@@ -9712,14 +10870,19 @@ class BrokerWebrtcServer {
9712
10870
  async createSession(streamId, hints = {}, opts = {}) {
9713
10871
  const setup = await this.setupSessionForBroker(streamId, hints, opts);
9714
10872
  try {
10873
+ if (opts.relayOnly === true) {
10874
+ setup.session.setForceRelayOnly(true);
10875
+ }
9715
10876
  const offer = await setup.session.createOffer();
9716
10877
  setup.sessionLogger.info("WebRTC session created", {
9717
10878
  meta: {
9718
10879
  sessionId: setup.sessionId,
10880
+ deviceId: setup.deviceId,
9719
10881
  brokerId: setup.brokerId,
9720
10882
  iceServers: setup.iceServerCount,
9721
10883
  codec: setup.sessionCodec,
9722
- useH265Repacketizer: setup.useH265Repacketizer
10884
+ useRtpRepacketizer: setup.useRtpRepacketizer,
10885
+ relayOnly: opts.relayOnly === true
9723
10886
  }
9724
10887
  });
9725
10888
  return { sessionId: setup.sessionId, sdpOffer: offer.sdp };
@@ -9743,16 +10906,21 @@ class BrokerWebrtcServer {
9743
10906
  * RTC controller / Alexa handler closes the session the same way.
9744
10907
  */
9745
10908
  async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
9746
- const setup = await this.setupSessionForBroker(streamId, hints, opts);
10909
+ const setup = await this.setupSessionForBroker(streamId, hints, { ...opts, clientOfferSdp });
9747
10910
  try {
10911
+ if (opts.relayOnly === true) {
10912
+ setup.session.setForceRelayOnly(true);
10913
+ }
9748
10914
  const answer = await setup.session.handleOffer({ sdp: clientOfferSdp, type: "offer" });
9749
10915
  setup.sessionLogger.info("WebRTC session created (client-offer)", {
9750
10916
  meta: {
9751
10917
  sessionId: setup.sessionId,
10918
+ deviceId: setup.deviceId,
9752
10919
  brokerId: setup.brokerId,
9753
10920
  iceServers: setup.iceServerCount,
9754
10921
  codec: setup.sessionCodec,
9755
- useH265Repacketizer: setup.useH265Repacketizer
10922
+ useRtpRepacketizer: setup.useRtpRepacketizer,
10923
+ relayOnly: opts.relayOnly === true
9756
10924
  }
9757
10925
  });
9758
10926
  return { sessionId: setup.sessionId, sdpAnswer: answer.sdp };
@@ -9782,20 +10950,21 @@ class BrokerWebrtcServer {
9782
10950
  const broker = this.brokers.get(brokerId);
9783
10951
  if (!broker) throw new Error(`No broker for stream "${streamId}" (resolved: "${brokerId}")`);
9784
10952
  const slashIdx = brokerId.indexOf("/");
9785
- const deviceTags = slashIdx > 0 ? {
9786
- deviceId: Number.parseInt(brokerId.slice(0, slashIdx), 10),
9787
- camStreamId: brokerId.slice(slashIdx + 1)
9788
- } : { deviceId: -1, camStreamId: brokerId };
10953
+ const deviceId = deviceIdFromBrokerId(brokerId);
10954
+ const deviceTags = {
10955
+ deviceId,
10956
+ camStreamId: slashIdx > 0 ? brokerId.slice(slashIdx + 1) : brokerId
10957
+ };
9789
10958
  const sessionLogger = this.logger.withTags(deviceTags);
9790
10959
  const brokerCodec = broker.getStats().codec ?? "h264";
9791
10960
  const isHevc = brokerCodec === "h265" || brokerCodec === "hevc";
9792
10961
  const sessionCodec = isHevc ? "H265" : "H264";
9793
10962
  const sourceType = broker.getSourceType();
9794
10963
  const isRtp = broker.isRtpSource();
9795
- const useH265Repacketizer = sessionCodec === "H265" && isRtp;
10964
+ const useRtpRepacketizer = isRtp;
9796
10965
  sessionLogger.info(
9797
- `WebRTC session: codec=${sessionCodec} brokerCodec=${brokerCodec} sourceType=${sourceType ?? "null"} isRtp=${isRtp} repacketizer=${useH265Repacketizer}`,
9798
- { meta: { brokerId, sessionCodec, brokerCodec, sourceType, isRtp, useH265Repacketizer } }
10966
+ `WebRTC session: codec=${sessionCodec} brokerCodec=${brokerCodec} sourceType=${sourceType ?? "null"} isRtp=${isRtp} repacketizer=${useRtpRepacketizer}`,
10967
+ { meta: { brokerId, sessionCodec, brokerCodec, sourceType, isRtp, useRtpRepacketizer } }
9799
10968
  );
9800
10969
  const { source, pushFrame, close: closeSource } = createPushFrameSource();
9801
10970
  const pendingParamNals = [];
@@ -9803,7 +10972,7 @@ class BrokerWebrtcServer {
9803
10972
  let seenRealPacket = false;
9804
10973
  const unsubBroker = broker.onEncodedData((packet) => {
9805
10974
  if (packet.type === "video") {
9806
- if (useH265Repacketizer) return;
10975
+ if (useRtpRepacketizer) return;
9807
10976
  if (!packet.isPlaceholder && !seenRealPacket) {
9808
10977
  seenRealPacket = true;
9809
10978
  pendingParamNals.length = 0;
@@ -9862,7 +11031,7 @@ class BrokerWebrtcServer {
9862
11031
  const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
9863
11032
  for (const pkt of preBuffer) {
9864
11033
  if (pkt.type === "video") {
9865
- if (useH265Repacketizer) continue;
11034
+ if (useRtpRepacketizer) continue;
9866
11035
  const annexB = convertH264ToAnnexB(pkt.data);
9867
11036
  const nalTypeInfo = detectFirstNalType(annexB, isHevc);
9868
11037
  if (nalTypeInfo.isParamSet) {
@@ -9894,40 +11063,68 @@ class BrokerWebrtcServer {
9894
11063
  }
9895
11064
  const sessionId = requestedSessionId ?? crypto__default.randomUUID();
9896
11065
  const iceServers = await this.resolveIceServers();
11066
+ const sourceProfileLevelId = sessionCodec === "H264" ? deriveH264ProfileLevelId(broker.getSdpParameterSets(), broker.getPreBuffer()) : void 0;
11067
+ const offeredProfileLevelIds = opts.clientOfferSdp ? extractOfferedH264ProfileLevelIds(opts.clientOfferSdp) : void 0;
11068
+ const transcodeToBaseline = offeredProfileLevelIds ? resolveClientOfferTranscodeToBaseline(sessionCodec, sourceProfileLevelId, offeredProfileLevelIds) : resolveBrokerTranscodeToBaseline(sessionCodec, sourceProfileLevelId);
11069
+ if (transcodeToBaseline) {
11070
+ sessionLogger.info(
11071
+ "WebRTC: source H.264 is non-Baseline and the client cannot decode it — re-encoding egress to Constrained Baseline",
11072
+ { meta: { brokerId, sessionId, sourceProfileLevelId, offeredProfileLevelIds, clientOffer: opts.clientOfferSdp !== void 0 } }
11073
+ );
11074
+ } else if (offeredProfileLevelIds && sourceProfileLevelId && !isBaselineProfileLevelId(sourceProfileLevelId)) {
11075
+ sessionLogger.info(
11076
+ "WebRTC: client offered a profile that decodes the non-Baseline H.264 source — passthrough (no re-encode)",
11077
+ { meta: { brokerId, sessionId, sourceProfileLevelId, offeredProfileLevelIds } }
11078
+ );
11079
+ }
9897
11080
  const session = new AdaptiveSession({
9898
11081
  sessionId,
9899
11082
  source,
9900
11083
  sourceCodec: sessionCodec,
11084
+ transcodeToBaseline,
9901
11085
  iceConfig: {
9902
11086
  iceServers,
9903
11087
  portRange: this.icePortRange,
9904
11088
  additionalHostAddresses: this.iceAdditionalHostAddresses
9905
11089
  },
9906
- logger: this.logger,
11090
+ // Device-tagged logger so every WebRTC session lifecycle line
11091
+ // (ICE state, candidate pairs, nominated pair, feed progress)
11092
+ // carries `deviceId` — operator log filters scoped to one device
11093
+ // no longer drop session logs as `dev=?`.
11094
+ logger: sessionLogger,
11095
+ deviceId,
11096
+ // Source-RTP pre-buffer accessor for instant start on the repacketizer
11097
+ // path — the session replays the current GOP on its first forwarded
11098
+ // packet. Only meaningful for RTP sources (returns [] otherwise).
11099
+ rtpBootstrap: useRtpRepacketizer ? (() => broker.getRtpPreBuffer()) : void 0,
9907
11100
  debug: opts.streamingDebug === true
9908
11101
  });
11102
+ const seedFromSdp = (ps) => {
11103
+ if (sessionCodec === "H265") session.seedH265CodecInfoFromSdp(ps);
11104
+ else session.seedH264CodecInfoFromSdp(ps);
11105
+ };
9909
11106
  let unsubRtp = null;
9910
11107
  let unsubSdpParams = null;
9911
- if (useH265Repacketizer) {
11108
+ if (useRtpRepacketizer) {
9912
11109
  const sdpPs = broker.getSdpParameterSets();
9913
11110
  if (sdpPs && sdpPs.length > 0) {
9914
- session.seedH265CodecInfoFromSdp(sdpPs);
9915
- sessionLogger.info("H.265 session seeded from SDP at create-time", {
9916
- meta: { sessionId, brokerId, psCount: sdpPs.length }
11111
+ seedFromSdp(sdpPs);
11112
+ sessionLogger.info("RTP session seeded from SDP at create-time", {
11113
+ meta: { sessionId, brokerId, codec: sessionCodec, psCount: sdpPs.length }
9917
11114
  });
9918
11115
  } else {
9919
- sessionLogger.info("H.265 session deferred: SDP params not ready, subscribing for late delivery", {
9920
- meta: { sessionId, brokerId }
11116
+ sessionLogger.info("RTP session deferred: SDP params not ready, subscribing for late delivery", {
11117
+ meta: { sessionId, brokerId, codec: sessionCodec }
9921
11118
  });
9922
11119
  unsubSdpParams = broker.onSdpParameterSets((ps) => {
9923
11120
  try {
9924
- session.seedH265CodecInfoFromSdp(ps);
9925
- sessionLogger.info("H.265 session seeded from SDP late delivery", {
9926
- meta: { sessionId, brokerId, psCount: ps.length }
11121
+ seedFromSdp(ps);
11122
+ sessionLogger.info("RTP session seeded from SDP late delivery", {
11123
+ meta: { sessionId, brokerId, codec: sessionCodec, psCount: ps.length }
9927
11124
  });
9928
11125
  } catch (err) {
9929
- sessionLogger.warn("seedH265CodecInfoFromSdp threw", {
9930
- meta: { sessionId, error: errMsg(err) }
11126
+ sessionLogger.warn("seedCodecInfoFromSdp threw", {
11127
+ meta: { sessionId, codec: sessionCodec, error: errMsg(err) }
9931
11128
  });
9932
11129
  }
9933
11130
  });
@@ -9936,15 +11133,15 @@ class BrokerWebrtcServer {
9936
11133
  unsubRtp = broker.onVideoRtp((rtpData) => {
9937
11134
  if (!firstRtpForwarded) {
9938
11135
  firstRtpForwarded = true;
9939
- sessionLogger.info("H.265 session: first source RTP forwarded to repacketizer", {
9940
- meta: { sessionId, brokerId, bytes: rtpData.length }
11136
+ sessionLogger.info("RTP session: first source RTP forwarded to repacketizer", {
11137
+ meta: { sessionId, brokerId, codec: sessionCodec, bytes: rtpData.length }
9941
11138
  });
9942
11139
  }
9943
11140
  try {
9944
11141
  session.forwardSourceRtpVideo(rtpData);
9945
11142
  } catch (err) {
9946
11143
  sessionLogger.warn("forwardSourceRtpVideo threw", {
9947
- meta: { sessionId, error: errMsg(err) }
11144
+ meta: { sessionId, codec: sessionCodec, error: errMsg(err) }
9948
11145
  });
9949
11146
  }
9950
11147
  });
@@ -9956,9 +11153,10 @@ class BrokerWebrtcServer {
9956
11153
  sessionId,
9957
11154
  session,
9958
11155
  sessionLogger,
11156
+ deviceId,
9959
11157
  brokerId,
9960
11158
  sessionCodec,
9961
- useH265Repacketizer,
11159
+ useRtpRepacketizer,
9962
11160
  iceServerCount: iceServers.length
9963
11161
  };
9964
11162
  }
@@ -9967,13 +11165,29 @@ class BrokerWebrtcServer {
9967
11165
  if (!entry) throw new Error(`Session not found: ${sessionId}`);
9968
11166
  await entry.session.handleAnswer({ sdp: sdpAnswer, type: "answer" });
9969
11167
  }
11168
+ /** Trickle ICE — add a remote (client) candidate to a live session. */
11169
+ async addIceCandidate(sessionId, candidate) {
11170
+ const entry = this.sessions.get(sessionId);
11171
+ if (!entry) return;
11172
+ await entry.session.addIceCandidate(candidate).catch((err) => {
11173
+ this.logger.warn("addIceCandidate threw", { meta: { sessionId, error: errMsg(err) } });
11174
+ });
11175
+ }
11176
+ /** Trickle ICE — the server's gathered candidates for a session (poll). */
11177
+ getIceCandidates(sessionId) {
11178
+ const entry = this.sessions.get(sessionId);
11179
+ if (!entry) return { candidates: [], done: true };
11180
+ return entry.session.getIceCandidatesSnapshot();
11181
+ }
9970
11182
  async closeSession(sessionId) {
9971
11183
  const entry = this.sessions.get(sessionId);
9972
11184
  if (!entry) return;
9973
11185
  this.cleanupSession(sessionId);
9974
11186
  await entry.session.close().catch(() => {
9975
11187
  });
9976
- this.logger.info("WebRTC session closed", { meta: { sessionId } });
11188
+ this.logger.info("WebRTC session closed", {
11189
+ meta: { sessionId, deviceId: deviceIdFromBrokerId(entry.brokerId) }
11190
+ });
9977
11191
  }
9978
11192
  async stop() {
9979
11193
  if (this.stopped) return;
@@ -10069,6 +11283,12 @@ class BrokerWebrtcServer {
10069
11283
  return this.staticIceServers ?? [];
10070
11284
  }
10071
11285
  }
11286
+ function deviceIdFromBrokerId(brokerId) {
11287
+ const slashIdx = brokerId.indexOf("/");
11288
+ if (slashIdx <= 0) return -1;
11289
+ const parsed = Number.parseInt(brokerId.slice(0, slashIdx), 10);
11290
+ return Number.isNaN(parsed) ? -1 : parsed;
11291
+ }
10072
11292
  function detectFirstNalType(annexB, isHevc) {
10073
11293
  for (let i = 0; i < annexB.length - 4; i++) {
10074
11294
  if (annexB[i] === 0 && annexB[i + 1] === 0 && annexB[i + 2] === 0 && annexB[i + 3] === 1 && i + 4 < annexB.length) {
@@ -10239,7 +11459,10 @@ class WebrtcSessionProvider {
10239
11459
  async createSession(input) {
10240
11460
  const streamId = this.resolveTargetToStreamId(input.deviceId, input.target);
10241
11461
  const streamingDebug = typeof this.manager.isStreamingDebug === "function" ? this.manager.isStreamingDebug(input.deviceId) : false;
10242
- return this.webrtcServer.createSession(streamId, input.hints, { streamingDebug });
11462
+ return this.webrtcServer.createSession(streamId, input.hints, {
11463
+ streamingDebug,
11464
+ relayOnly: input.relayOnly === true
11465
+ });
10243
11466
  }
10244
11467
  async handleOffer(input) {
10245
11468
  const target = input.target ?? { kind: "adaptive" };
@@ -10247,12 +11470,23 @@ class WebrtcSessionProvider {
10247
11470
  const streamingDebug = typeof this.manager.isStreamingDebug === "function" ? this.manager.isStreamingDebug(input.deviceId) : false;
10248
11471
  return this.webrtcServer.handleOffer(streamId, input.sdpOffer, void 0, {
10249
11472
  streamingDebug,
10250
- sessionId: input.sessionId
11473
+ sessionId: input.sessionId,
11474
+ relayOnly: input.relayOnly === true
10251
11475
  });
10252
11476
  }
10253
11477
  async handleAnswer(input) {
10254
11478
  await this.webrtcServer.handleAnswer(input.sessionId, input.sdpAnswer);
10255
11479
  }
11480
+ async addIceCandidate(input) {
11481
+ await this.webrtcServer.addIceCandidate(input.sessionId, {
11482
+ candidate: input.candidate,
11483
+ sdpMid: input.sdpMid ?? null,
11484
+ sdpMLineIndex: input.sdpMLineIndex ?? null
11485
+ });
11486
+ }
11487
+ async getIceCandidates(input) {
11488
+ return this.webrtcServer.getIceCandidates(input.sessionId);
11489
+ }
10256
11490
  async closeSession(input) {
10257
11491
  await this.webrtcServer.closeSession(input.sessionId);
10258
11492
  }
@@ -10282,6 +11516,7 @@ const BROKER_METRICS_HEARTBEAT_MS = 3e4;
10282
11516
  class StreamBrokerAddon extends BaseAddon {
10283
11517
  brokerManager = null;
10284
11518
  metricsSnapshotTimer = null;
11519
+ catalogReconcileTimer = null;
10285
11520
  /**
10286
11521
  * Snapshot-equality cache for the broker-metrics emit. Each
10287
11522
  * broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
@@ -10302,7 +11537,8 @@ class StreamBrokerAddon extends BaseAddon {
10302
11537
  rtspPort: 8554,
10303
11538
  maxDecodeFps: 5,
10304
11539
  initialReconnectDelayMs: 1e3,
10305
- maxReconnectDelayMs: 3e4
11540
+ maxReconnectDelayMs: 3e4,
11541
+ catalogReconcileIntervalSec: 30
10306
11542
  });
10307
11543
  }
10308
11544
  async onInitialize() {
@@ -10481,6 +11717,38 @@ class StreamBrokerAddon extends BaseAddon {
10481
11717
  });
10482
11718
  }
10483
11719
  });
11720
+ const reconcileAll = (reason) => {
11721
+ void this.brokerManager?.reconcileAllCatalogs().catch((err) => {
11722
+ this.ctx.logger.warn("catalog reconcile failed", { meta: { reason, error: errMsg(err) } });
11723
+ });
11724
+ };
11725
+ const reconcileDevice = (deviceId, reason) => {
11726
+ void this.brokerManager?.reconcileDeviceCatalog(deviceId).catch((err) => {
11727
+ this.ctx.logger.warn("device catalog reconcile failed", {
11728
+ tags: { deviceId },
11729
+ meta: { reason, error: errMsg(err) }
11730
+ });
11731
+ });
11732
+ };
11733
+ reconcileAll("startup");
11734
+ const reconcileMs = Math.max(5, this.config.catalogReconcileIntervalSec) * 1e3;
11735
+ this.catalogReconcileTimer = setInterval(() => reconcileAll("poll"), reconcileMs);
11736
+ this.subscribe({ category: EventCategory.DeviceRegistered }, (event) => {
11737
+ const deviceId = event.data.deviceId;
11738
+ if (typeof deviceId === "number") reconcileDevice(deviceId, "device-registered");
11739
+ });
11740
+ this.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
11741
+ const deviceId = event.data.deviceId;
11742
+ if (typeof deviceId === "number") {
11743
+ void this.brokerManager?.retractDevice(deviceId).catch((err) => {
11744
+ this.ctx.logger.warn("device retract failed", { tags: { deviceId }, meta: { error: errMsg(err) } });
11745
+ });
11746
+ }
11747
+ });
11748
+ this.subscribe({ category: EventCategory.StreamParamsChanged }, (event) => {
11749
+ const deviceId = event.data.deviceId;
11750
+ if (typeof deviceId === "number") reconcileDevice(deviceId, "stream-params-changed");
11751
+ });
10484
11752
  const cameraStreamsProvider = new CameraStreamsProvider(this.brokerManager);
10485
11753
  const turnApi = this.ctx.api;
10486
11754
  const webrtcServer = new BrokerWebrtcServer({
@@ -10498,6 +11766,9 @@ class StreamBrokerAddon extends BaseAddon {
10498
11766
  ...s.credential ? { credential: s.credential } : {}
10499
11767
  });
10500
11768
  }
11769
+ this.ctx.logger.info("broker getIceServers resolved", {
11770
+ meta: { count: out.length, urls: out.map((s) => Array.isArray(s.urls) ? s.urls[0] : s.urls) }
11771
+ });
10501
11772
  return out;
10502
11773
  } catch (err) {
10503
11774
  this.ctx.logger.warn("turnProvider.getTurnServers failed — session will start without TURN", {
@@ -10557,6 +11828,10 @@ class StreamBrokerAddon extends BaseAddon {
10557
11828
  clearInterval(this.metricsSnapshotTimer);
10558
11829
  this.metricsSnapshotTimer = null;
10559
11830
  }
11831
+ if (this.catalogReconcileTimer) {
11832
+ clearInterval(this.catalogReconcileTimer);
11833
+ this.catalogReconcileTimer = null;
11834
+ }
10560
11835
  await this.brokerManager?.destroyAll();
10561
11836
  this.brokerManager = null;
10562
11837
  }
@@ -10639,6 +11914,17 @@ class StreamBrokerAddon extends BaseAddon {
10639
11914
  max: 30,
10640
11915
  step: 1,
10641
11916
  default: 5
11917
+ },
11918
+ {
11919
+ type: "number",
11920
+ key: "catalogReconcileIntervalSec",
11921
+ label: "Catalog Reconcile Interval",
11922
+ description: "How often the broker re-pulls every camera’s stream catalog (reconcile backstop)",
11923
+ min: 5,
11924
+ max: 300,
11925
+ step: 5,
11926
+ default: 30,
11927
+ unit: "s"
10642
11928
  }
10643
11929
  ]
10644
11930
  },