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