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