@camstack/addon-pipeline 1.2.51 → 1.2.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/{addon-utils-DveoBR6G.js → addon-utils-CQs-AjTJ.js} +177 -177
  2. package/dist/{addon-utils-QuxtOdK1.mjs → addon-utils-CZ2xo67g.mjs} +177 -177
  3. package/dist/audio-analyzer/index.js +2 -2
  4. package/dist/audio-analyzer/index.mjs +2 -2
  5. package/dist/detection-pipeline/index.js +3 -3
  6. package/dist/detection-pipeline/index.mjs +3 -3
  7. package/dist/{dist-Dkvk5ykl.mjs → dist-B55xTTsS.mjs} +16337 -15811
  8. package/dist/{dist-BsBEe_rX.js → dist-D8TX_P_A.js} +16337 -15811
  9. package/dist/{event-loop-stall-monitor-DqJXi0_B.js → event-loop-stall-monitor-Bs6d-u_F.js} +1 -1
  10. package/dist/{event-loop-stall-monitor-BvtjRcal.mjs → event-loop-stall-monitor-Cq_KOlfb.mjs} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +160 -40
  14. package/dist/pipeline-runner/index.mjs +160 -40
  15. package/dist/recorder/index.js +2 -2
  16. package/dist/recorder/index.mjs +2 -2
  17. package/dist/session-decode/decode-worker-child.js +6 -2
  18. package/dist/session-decode/decode-worker-child.mjs +6 -2
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CUZ0nS_r.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C-9q6y1s.mjs} +3 -3
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CAmiVOsA.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CaGsMvou.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-B8O23a6l.mjs → hostInit-DiXYwnXH.mjs} +3 -3
  24. package/dist/stream-broker/index.js +337 -312
  25. package/dist/stream-broker/index.mjs +337 -312
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-B0c3i2km.mjs → worker-protocol-Bol291_n.mjs} +1 -1
  28. package/dist/{worker-protocol-CWhkLkHx.js → worker-protocol-C2uctCrd.js} +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-kAtChdz2.js → MaskShapeCanvas-DI4BY7W2-CXHeTkIT.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DEwkvjzt.js → MotionZonesSettings-NcxxQN8r-De5VKf09.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Brv9VL0h.js → PrivacyMaskSettings-APgPLF7p-Z7dsjOG5.js} +1 -1
  32. package/embed-dist/assets/{index-Dv7fSuDA.js → index-NJB31mpg.js} +12 -12
  33. package/embed-dist/index.html +1 -1
  34. package/package.json +1 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Mv3UeZUE.mjs +0 -26
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Czrsozsg.mjs +0 -26
@@ -1,7 +1,7 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
2
- import { $ as webrtcSessionCapability, At as union, B as maskUrlCredentials, C as addonWidgetsSourceCapability, Ct as discriminatedUnion, D as createHwAccelCache, Dt as object, E as cameraStreamsCapability, Et as number, F as egressTransportFromRequest, J as resolveEgressDecodeHwAccel, Ot as record, P as egressTranscodeSharingKey, St as boolean, Tt as literal, Z as streamBrokerCapability, _t as parseProfileBrokerId, at as isSoftwareDecode, bt as _enum, ct as DeviceFeature, et as errMsg, f as EncodeProfileSchema, ft as isEvent, ht as nodePin, it as invocationFromEncodeProfile, jt as EventCategory, kt as string, lt as DeviceType, mt as makeSourceBrokerId, nt as Fmp4BoxSplitter, ot as BaseAddon, pt as makeProfileBrokerId, rt as buildFfmpegArgs$1, st as CAM_PROFILE_ORDER, tt as AUDIO_PRESETS, ut as createEvent, v as RATE_CONTROL_RELAXED, x as RingBuffer, xt as array, y as RATE_CONTROL_TIGHT } from "../dist-Dkvk5ykl.mjs";
2
+ import { $ as webrtcSessionCapability, At as union, B as maskUrlCredentials, C as addonWidgetsSourceCapability, Ct as discriminatedUnion, D as createHwAccelCache, Dt as object, E as cameraStreamsCapability, Et as number, F as egressTransportFromRequest, J as resolveEgressDecodeHwAccel, Ot as record, P as egressTranscodeSharingKey, St as boolean, Tt as literal, Z as streamBrokerCapability, _t as parseProfileBrokerId, at as isSoftwareDecode, bt as _enum, ct as DeviceFeature, et as errMsg, f as EncodeProfileSchema, ft as isEvent, ht as nodePin, it as invocationFromEncodeProfile, jt as EventCategory, kt as string, lt as DeviceType, mt as makeSourceBrokerId, nt as Fmp4BoxSplitter, ot as BaseAddon, pt as makeProfileBrokerId, rt as buildFfmpegArgs$1, st as CAM_PROFILE_ORDER, tt as AUDIO_PRESETS, ut as createEvent, v as RATE_CONTROL_RELAXED, x as RingBuffer, xt as array, y as RATE_CONTROL_TIGHT } from "../dist-B55xTTsS.mjs";
3
3
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
4
- import { n as createFileDataPlaneHandler } from "../addon-utils-QuxtOdK1.mjs";
4
+ import { n as createFileDataPlaneHandler } from "../addon-utils-CZ2xo67g.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as crypto$1 from "node:crypto";
7
7
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -1040,6 +1040,275 @@ function buildDerivedTranscodeArgs(profile, loopbackUrl, decodeHwAccel = null) {
1040
1040
  }));
1041
1041
  }
1042
1042
  //#endregion
1043
+ //#region src/stream-broker/paced-rtp-replay.ts
1044
+ var PACED_REPLAY_DEFAULTS = {
1045
+ singleShotMax: 64,
1046
+ tickMs: 5,
1047
+ targetDurationMs: 300,
1048
+ minChunkSize: 32,
1049
+ maxChunkSize: 128
1050
+ };
1051
+ var defaultSchedule = (fn, ms) => {
1052
+ const t = setTimeout(fn, ms);
1053
+ return () => clearTimeout(t);
1054
+ };
1055
+ /**
1056
+ * Compute the per-tick chunk size for a burst of `total` packets: enough to
1057
+ * drain within `targetDurationMs` at one chunk per `tickMs`, clamped to
1058
+ * [minChunkSize, maxChunkSize]. Bursts ≤ `singleShotMax` are one chunk.
1059
+ */
1060
+ function computeReplayChunkSize(total, tuning) {
1061
+ if (total <= tuning.singleShotMax) return Math.max(total, 1);
1062
+ const maxTicks = Math.max(1, Math.floor(tuning.targetDurationMs / tuning.tickMs));
1063
+ const ideal = Math.ceil(total / maxTicks);
1064
+ return Math.min(tuning.maxChunkSize, Math.max(tuning.minChunkSize, ideal));
1065
+ }
1066
+ /**
1067
+ * One in-flight paced replay. Create per burst; not reusable after
1068
+ * completion or abort.
1069
+ */
1070
+ var PacedRtpReplay = class {
1071
+ send;
1072
+ onDone;
1073
+ tuning;
1074
+ schedule;
1075
+ now;
1076
+ queue = [];
1077
+ cancelTimer = null;
1078
+ started = false;
1079
+ finished = false;
1080
+ startedAt = 0;
1081
+ chunkSize = 0;
1082
+ initialPackets = 0;
1083
+ enqueuedLive = 0;
1084
+ sentCount = 0;
1085
+ chunkCount = 0;
1086
+ constructor(send, onDone, options) {
1087
+ this.send = send;
1088
+ this.onDone = onDone;
1089
+ this.tuning = {
1090
+ ...PACED_REPLAY_DEFAULTS,
1091
+ ...options?.tuning
1092
+ };
1093
+ this.schedule = options?.schedule ?? defaultSchedule;
1094
+ this.now = options?.now ?? Date.now;
1095
+ }
1096
+ /** True while packets remain to be drained (live packets must be enqueued,
1097
+ * not sent directly, to preserve order). */
1098
+ get active() {
1099
+ return this.started && !this.finished;
1100
+ }
1101
+ /**
1102
+ * Begin the replay: sends the first chunk synchronously, then paces the
1103
+ * rest. Calling `start` more than once is a no-op.
1104
+ */
1105
+ start(initial) {
1106
+ if (this.started) return;
1107
+ this.started = true;
1108
+ this.startedAt = this.now();
1109
+ this.initialPackets = initial.length;
1110
+ this.queue = [...initial];
1111
+ this.chunkSize = computeReplayChunkSize(initial.length, this.tuning);
1112
+ this.drainChunk();
1113
+ }
1114
+ /**
1115
+ * Append a live packet behind the still-queued replay tail. No-op (returns
1116
+ * false) when the replay is not active — the caller must then send the
1117
+ * packet directly.
1118
+ */
1119
+ enqueue(item) {
1120
+ if (!this.active) return false;
1121
+ this.queue.push(item);
1122
+ this.enqueuedLive++;
1123
+ return true;
1124
+ }
1125
+ /** Cancel the replay: pending packets are dropped, `onDone` fires with
1126
+ * `aborted: true`. Safe to call multiple times / before `start`. */
1127
+ abort() {
1128
+ if (this.finished) return;
1129
+ if (!this.started) {
1130
+ this.started = true;
1131
+ this.startedAt = this.now();
1132
+ }
1133
+ this.finish(true);
1134
+ }
1135
+ drainChunk() {
1136
+ if (this.finished) return;
1137
+ this.chunkCount++;
1138
+ const n = Math.min(this.chunkSize, this.queue.length);
1139
+ for (let i = 0; i < n; i++) {
1140
+ if (this.finished) return;
1141
+ if (this.send(this.queue[i])) this.sentCount++;
1142
+ }
1143
+ this.queue = this.queue.slice(n);
1144
+ if (this.queue.length === 0) {
1145
+ this.finish(false);
1146
+ return;
1147
+ }
1148
+ this.cancelTimer = this.schedule(() => {
1149
+ this.cancelTimer = null;
1150
+ this.drainChunk();
1151
+ }, this.tuning.tickMs);
1152
+ }
1153
+ finish(aborted) {
1154
+ if (this.finished) return;
1155
+ this.finished = true;
1156
+ if (this.cancelTimer) {
1157
+ this.cancelTimer();
1158
+ this.cancelTimer = null;
1159
+ }
1160
+ this.queue = [];
1161
+ this.onDone({
1162
+ packets: this.initialPackets,
1163
+ enqueuedLive: this.enqueuedLive,
1164
+ sent: this.sentCount,
1165
+ chunks: this.chunkCount,
1166
+ chunkSize: this.chunkSize,
1167
+ durationMs: this.now() - this.startedAt,
1168
+ aborted
1169
+ });
1170
+ }
1171
+ };
1172
+ //#endregion
1173
+ //#region src/stream-broker/rtsp/annexb-deframer.ts
1174
+ /**
1175
+ * Annex-B NAL unit deframer.
1176
+ *
1177
+ * ffmpeg outputs Annex-B via stdout in arbitrary chunk sizes — a single
1178
+ * `data` event may contain partial NALs, multiple NALs, or NALs split
1179
+ * across events. This class accumulates bytes and emits complete NAL units.
1180
+ *
1181
+ * Usage:
1182
+ * const deframer = new AnnexBDeframer((nal, isKeyframe) => { ... })
1183
+ * proc.stdout.on('data', chunk => deframer.push(chunk))
1184
+ */
1185
+ var AnnexBDeframer = class {
1186
+ onNal;
1187
+ buffer = Buffer.alloc(0);
1188
+ codec;
1189
+ constructor(codec, onNal) {
1190
+ this.onNal = onNal;
1191
+ this.codec = codec;
1192
+ }
1193
+ /** Push raw Annex-B bytes. Complete NAL units are emitted via onNal callback. */
1194
+ push(chunk) {
1195
+ this.buffer = this.buffer.length > 0 ? Buffer.concat([this.buffer, chunk]) : chunk;
1196
+ this.drain();
1197
+ }
1198
+ /** Flush remaining buffer (call on stream end). */
1199
+ flush() {
1200
+ if (this.buffer.length > 0) {
1201
+ const nal = this.stripLeadingStartCode(this.buffer);
1202
+ if (nal.length > 0) this.onNal(nal, this.isKeyframeNal(nal));
1203
+ this.buffer = Buffer.alloc(0);
1204
+ }
1205
+ }
1206
+ /** Extract complete NAL units from the buffer, leaving partial data for next push. */
1207
+ drain() {
1208
+ while (true) {
1209
+ const firstSc = this.findStartCode(0);
1210
+ if (firstSc < 0) return;
1211
+ const nalStart = firstSc + (this.isStartCode4(firstSc) ? 4 : 3);
1212
+ const nextSc = this.findStartCode(nalStart);
1213
+ if (nextSc < 0) {
1214
+ if (firstSc > 0) this.buffer = Buffer.from(this.buffer.subarray(firstSc));
1215
+ return;
1216
+ }
1217
+ const nal = this.buffer.subarray(nalStart, nextSc);
1218
+ if (nal.length > 0) this.onNal(nal, this.isKeyframeNal(nal));
1219
+ this.buffer = Buffer.from(this.buffer.subarray(nextSc));
1220
+ }
1221
+ }
1222
+ /** Find the byte offset of the next start code (00 00 01 or 00 00 00 01) at or after `from`. */
1223
+ findStartCode(from) {
1224
+ const buf = this.buffer;
1225
+ const len = buf.length - 2;
1226
+ for (let i = from; i < len; i++) {
1227
+ if (buf[i + 2] > 1) {
1228
+ i += 2;
1229
+ continue;
1230
+ }
1231
+ if (buf[i] === 0 && buf[i + 1] === 0) {
1232
+ if (buf[i + 2] === 1) return i;
1233
+ if (buf[i + 2] === 0 && i + 3 < buf.length && buf[i + 3] === 1) return i;
1234
+ }
1235
+ }
1236
+ return -1;
1237
+ }
1238
+ /** Check if start code at `pos` is 4-byte (00 00 00 01) vs 3-byte (00 00 01). */
1239
+ isStartCode4(pos) {
1240
+ return pos + 3 < this.buffer.length && this.buffer[pos] === 0 && this.buffer[pos + 1] === 0 && this.buffer[pos + 2] === 0 && this.buffer[pos + 3] === 1;
1241
+ }
1242
+ /** Strip leading start code from a buffer (if present). */
1243
+ stripLeadingStartCode(buf) {
1244
+ if (buf.length >= 4 && buf[0] === 0 && buf[1] === 0 && buf[2] === 0 && buf[3] === 1) return buf.subarray(4);
1245
+ if (buf.length >= 3 && buf[0] === 0 && buf[1] === 0 && buf[2] === 1) return buf.subarray(3);
1246
+ return buf;
1247
+ }
1248
+ /** Check if a NAL unit (without start code) is a keyframe. */
1249
+ isKeyframeNal(nal) {
1250
+ if (nal.length === 0) return false;
1251
+ if (this.codec === "h264") return (nal[0] & 31) === 5;
1252
+ const type = nal[0] >> 1 & 63;
1253
+ return type >= 16 && type <= 21;
1254
+ }
1255
+ };
1256
+ //#endregion
1257
+ //#region src/stream-broker/rtsp/broker-device-id.ts
1258
+ /**
1259
+ * Parse the numeric deviceId out of a canonical brokerId
1260
+ * (`${deviceId}/${camStreamId}` or `${deviceId}/${profile}`). Returns
1261
+ * `undefined` for a malformed id — so every device-scoped log line on the RTSP
1262
+ * plane can carry `tags.deviceId` without risking a bogus tag value.
1263
+ *
1264
+ * Every log line about a device carries `tags: { deviceId }`, always the same
1265
+ * key and always the numeric id: a miss rate, a media gap or a routing fault is
1266
+ * always asked per-camera ("why is 617 worse than 615?"), and a line that omits
1267
+ * the tag cannot answer it.
1268
+ */
1269
+ function deviceIdFromBrokerId$1(brokerId) {
1270
+ const parsed = Number(brokerId.split("/")[0]);
1271
+ return Number.isFinite(parsed) ? parsed : void 0;
1272
+ }
1273
+ /**
1274
+ * Extract the `m=audio` media block from an ffmpeg-generated SDP, normalised
1275
+ * for grafting (port 0, non-colliding payload type, own `a=control`, explicit
1276
+ * connection line). Returns `null` when the SDP has no audio media section.
1277
+ */
1278
+ function extractAudioMediaBlock(ffmpegSdp, options = {}) {
1279
+ const trackId = options.trackId ?? 1;
1280
+ const lines = ffmpegSdp.split(/\r?\n/);
1281
+ const startIdx = lines.findIndex((l) => l.startsWith("m=audio"));
1282
+ if (startIdx < 0) return null;
1283
+ const block = [];
1284
+ for (let i = startIdx; i < lines.length; i++) {
1285
+ const line = lines[i];
1286
+ if (i > startIdx && line.startsWith("m=")) break;
1287
+ if (line.trim().length === 0) continue;
1288
+ if (line.startsWith("a=control:")) continue;
1289
+ block.push(line);
1290
+ }
1291
+ const mParts = block[0].split(/\s+/);
1292
+ const origPt = Number.parseInt(mParts[3] ?? "", 10);
1293
+ if (!Number.isInteger(origPt)) return null;
1294
+ const newPt = origPt >= 96 ? 97 : origPt;
1295
+ const remapped = block.map((line) => {
1296
+ if (line.startsWith("m=audio")) return `m=audio 0 ${mParts.slice(2).join(" ").replace(String(origPt), String(newPt))}`;
1297
+ if (origPt !== newPt && (line.startsWith("a=rtpmap:") || line.startsWith("a=fmtp:"))) return line.replace(`:${origPt} `, `:${newPt} `);
1298
+ return line;
1299
+ });
1300
+ if (!remapped.some((l) => l.startsWith("c="))) remapped.splice(1, 0, "c=IN IP4 0.0.0.0");
1301
+ remapped.push(`a=control:trackID=${trackId}`);
1302
+ return remapped.join("\r\n");
1303
+ }
1304
+ /**
1305
+ * Append a grafted audio media block to a video-only SDP, preserving CRLF
1306
+ * framing and the trailing blank line.
1307
+ */
1308
+ function appendAudioMediaBlock(videoSdp, audioBlock) {
1309
+ return `${videoSdp.replace(/[\r\n]+$/, "")}\r\n${audioBlock}\r\n`;
1310
+ }
1311
+ //#endregion
1043
1312
  //#region src/stream-broker/rtsp/rtsp-types.ts
1044
1313
  /** RTSP method constants. */
1045
1314
  var RTSP_METHODS = [
@@ -1127,129 +1396,45 @@ var RtpPacketizer = class {
1127
1396
  packets.push(this.buildRtpPacket(fragment, timestamp, isLast && marker));
1128
1397
  offset = end;
1129
1398
  }
1130
- return packets;
1131
- }
1132
- /** H.265 FU fragmentation (RFC 7798 Section 4.4.3). */
1133
- fragmentH265FU(nal, timestamp, marker) {
1134
- const nalType = nal[0] >> 1 & 63;
1135
- const tidLayerId = nal[1];
1136
- const payload = nal.subarray(2);
1137
- const maxFragment = RTP_MAX_PAYLOAD - 3;
1138
- const packets = [];
1139
- let offset = 0;
1140
- while (offset < payload.length) {
1141
- const end = Math.min(offset + maxFragment, payload.length);
1142
- const isFirst = offset === 0;
1143
- const isLast = end === payload.length;
1144
- const payloadHdr0 = 98 | nal[0] & 129;
1145
- const payloadHdr1 = tidLayerId;
1146
- const fuHeader = (isFirst ? 128 : 0) | (isLast ? 64 : 0) | nalType;
1147
- const fragment = Buffer.allocUnsafe(3 + (end - offset));
1148
- fragment[0] = payloadHdr0;
1149
- fragment[1] = payloadHdr1;
1150
- fragment[2] = fuHeader;
1151
- payload.copy(fragment, 3, offset, end);
1152
- packets.push(this.buildRtpPacket(fragment, timestamp, isLast && marker));
1153
- offset = end;
1154
- }
1155
- return packets;
1156
- }
1157
- buildRtpPacket(payload, timestamp, marker) {
1158
- const header = Buffer.allocUnsafe(12);
1159
- header[0] = 128;
1160
- header[1] = (marker ? 128 : 0) | this.payloadType & 127;
1161
- header.writeUInt16BE(this.sequenceNumber & 65535, 2);
1162
- this.sequenceNumber = this.sequenceNumber + 1 & 65535;
1163
- header.writeUInt32BE(timestamp >>> 0, 4);
1164
- header.writeUInt32BE(this.ssrc >>> 0, 8);
1165
- return {
1166
- data: Buffer.concat([header, payload]),
1167
- marker
1168
- };
1169
- }
1170
- };
1171
- //#endregion
1172
- //#region src/stream-broker/rtsp/annexb-deframer.ts
1173
- /**
1174
- * Annex-B NAL unit deframer.
1175
- *
1176
- * ffmpeg outputs Annex-B via stdout in arbitrary chunk sizes — a single
1177
- * `data` event may contain partial NALs, multiple NALs, or NALs split
1178
- * across events. This class accumulates bytes and emits complete NAL units.
1179
- *
1180
- * Usage:
1181
- * const deframer = new AnnexBDeframer((nal, isKeyframe) => { ... })
1182
- * proc.stdout.on('data', chunk => deframer.push(chunk))
1183
- */
1184
- var AnnexBDeframer = class {
1185
- onNal;
1186
- buffer = Buffer.alloc(0);
1187
- codec;
1188
- constructor(codec, onNal) {
1189
- this.onNal = onNal;
1190
- this.codec = codec;
1191
- }
1192
- /** Push raw Annex-B bytes. Complete NAL units are emitted via onNal callback. */
1193
- push(chunk) {
1194
- this.buffer = this.buffer.length > 0 ? Buffer.concat([this.buffer, chunk]) : chunk;
1195
- this.drain();
1196
- }
1197
- /** Flush remaining buffer (call on stream end). */
1198
- flush() {
1199
- if (this.buffer.length > 0) {
1200
- const nal = this.stripLeadingStartCode(this.buffer);
1201
- if (nal.length > 0) this.onNal(nal, this.isKeyframeNal(nal));
1202
- this.buffer = Buffer.alloc(0);
1203
- }
1204
- }
1205
- /** Extract complete NAL units from the buffer, leaving partial data for next push. */
1206
- drain() {
1207
- while (true) {
1208
- const firstSc = this.findStartCode(0);
1209
- if (firstSc < 0) return;
1210
- const nalStart = firstSc + (this.isStartCode4(firstSc) ? 4 : 3);
1211
- const nextSc = this.findStartCode(nalStart);
1212
- if (nextSc < 0) {
1213
- if (firstSc > 0) this.buffer = Buffer.from(this.buffer.subarray(firstSc));
1214
- return;
1215
- }
1216
- const nal = this.buffer.subarray(nalStart, nextSc);
1217
- if (nal.length > 0) this.onNal(nal, this.isKeyframeNal(nal));
1218
- this.buffer = Buffer.from(this.buffer.subarray(nextSc));
1219
- }
1399
+ return packets;
1220
1400
  }
1221
- /** Find the byte offset of the next start code (00 00 01 or 00 00 00 01) at or after `from`. */
1222
- findStartCode(from) {
1223
- const buf = this.buffer;
1224
- const len = buf.length - 2;
1225
- for (let i = from; i < len; i++) {
1226
- if (buf[i + 2] > 1) {
1227
- i += 2;
1228
- continue;
1229
- }
1230
- if (buf[i] === 0 && buf[i + 1] === 0) {
1231
- if (buf[i + 2] === 1) return i;
1232
- if (buf[i + 2] === 0 && i + 3 < buf.length && buf[i + 3] === 1) return i;
1233
- }
1401
+ /** H.265 FU fragmentation (RFC 7798 Section 4.4.3). */
1402
+ fragmentH265FU(nal, timestamp, marker) {
1403
+ const nalType = nal[0] >> 1 & 63;
1404
+ const tidLayerId = nal[1];
1405
+ const payload = nal.subarray(2);
1406
+ const maxFragment = RTP_MAX_PAYLOAD - 3;
1407
+ const packets = [];
1408
+ let offset = 0;
1409
+ while (offset < payload.length) {
1410
+ const end = Math.min(offset + maxFragment, payload.length);
1411
+ const isFirst = offset === 0;
1412
+ const isLast = end === payload.length;
1413
+ const payloadHdr0 = 98 | nal[0] & 129;
1414
+ const payloadHdr1 = tidLayerId;
1415
+ const fuHeader = (isFirst ? 128 : 0) | (isLast ? 64 : 0) | nalType;
1416
+ const fragment = Buffer.allocUnsafe(3 + (end - offset));
1417
+ fragment[0] = payloadHdr0;
1418
+ fragment[1] = payloadHdr1;
1419
+ fragment[2] = fuHeader;
1420
+ payload.copy(fragment, 3, offset, end);
1421
+ packets.push(this.buildRtpPacket(fragment, timestamp, isLast && marker));
1422
+ offset = end;
1234
1423
  }
1235
- return -1;
1236
- }
1237
- /** Check if start code at `pos` is 4-byte (00 00 00 01) vs 3-byte (00 00 01). */
1238
- isStartCode4(pos) {
1239
- return pos + 3 < this.buffer.length && this.buffer[pos] === 0 && this.buffer[pos + 1] === 0 && this.buffer[pos + 2] === 0 && this.buffer[pos + 3] === 1;
1240
- }
1241
- /** Strip leading start code from a buffer (if present). */
1242
- stripLeadingStartCode(buf) {
1243
- if (buf.length >= 4 && buf[0] === 0 && buf[1] === 0 && buf[2] === 0 && buf[3] === 1) return buf.subarray(4);
1244
- if (buf.length >= 3 && buf[0] === 0 && buf[1] === 0 && buf[2] === 1) return buf.subarray(3);
1245
- return buf;
1424
+ return packets;
1246
1425
  }
1247
- /** Check if a NAL unit (without start code) is a keyframe. */
1248
- isKeyframeNal(nal) {
1249
- if (nal.length === 0) return false;
1250
- if (this.codec === "h264") return (nal[0] & 31) === 5;
1251
- const type = nal[0] >> 1 & 63;
1252
- return type >= 16 && type <= 21;
1426
+ buildRtpPacket(payload, timestamp, marker) {
1427
+ const header = Buffer.allocUnsafe(12);
1428
+ header[0] = 128;
1429
+ header[1] = (marker ? 128 : 0) | this.payloadType & 127;
1430
+ header.writeUInt16BE(this.sequenceNumber & 65535, 2);
1431
+ this.sequenceNumber = this.sequenceNumber + 1 & 65535;
1432
+ header.writeUInt32BE(timestamp >>> 0, 4);
1433
+ header.writeUInt32BE(this.ssrc >>> 0, 8);
1434
+ return {
1435
+ data: Buffer.concat([header, payload]),
1436
+ marker
1437
+ };
1253
1438
  }
1254
1439
  };
1255
1440
  //#endregion
@@ -1287,191 +1472,6 @@ function buildSdp(options) {
1287
1472
  lines.push("");
1288
1473
  return lines.join("\r\n");
1289
1474
  }
1290
- /**
1291
- * Extract the `m=audio` media block from an ffmpeg-generated SDP, normalised
1292
- * for grafting (port 0, non-colliding payload type, own `a=control`, explicit
1293
- * connection line). Returns `null` when the SDP has no audio media section.
1294
- */
1295
- function extractAudioMediaBlock(ffmpegSdp, options = {}) {
1296
- const trackId = options.trackId ?? 1;
1297
- const lines = ffmpegSdp.split(/\r?\n/);
1298
- const startIdx = lines.findIndex((l) => l.startsWith("m=audio"));
1299
- if (startIdx < 0) return null;
1300
- const block = [];
1301
- for (let i = startIdx; i < lines.length; i++) {
1302
- const line = lines[i];
1303
- if (i > startIdx && line.startsWith("m=")) break;
1304
- if (line.trim().length === 0) continue;
1305
- if (line.startsWith("a=control:")) continue;
1306
- block.push(line);
1307
- }
1308
- const mParts = block[0].split(/\s+/);
1309
- const origPt = Number.parseInt(mParts[3] ?? "", 10);
1310
- if (!Number.isInteger(origPt)) return null;
1311
- const newPt = origPt >= 96 ? 97 : origPt;
1312
- const remapped = block.map((line) => {
1313
- if (line.startsWith("m=audio")) return `m=audio 0 ${mParts.slice(2).join(" ").replace(String(origPt), String(newPt))}`;
1314
- if (origPt !== newPt && (line.startsWith("a=rtpmap:") || line.startsWith("a=fmtp:"))) return line.replace(`:${origPt} `, `:${newPt} `);
1315
- return line;
1316
- });
1317
- if (!remapped.some((l) => l.startsWith("c="))) remapped.splice(1, 0, "c=IN IP4 0.0.0.0");
1318
- remapped.push(`a=control:trackID=${trackId}`);
1319
- return remapped.join("\r\n");
1320
- }
1321
- /**
1322
- * Append a grafted audio media block to a video-only SDP, preserving CRLF
1323
- * framing and the trailing blank line.
1324
- */
1325
- function appendAudioMediaBlock(videoSdp, audioBlock) {
1326
- return `${videoSdp.replace(/[\r\n]+$/, "")}\r\n${audioBlock}\r\n`;
1327
- }
1328
- //#endregion
1329
- //#region src/stream-broker/rtsp/broker-device-id.ts
1330
- /**
1331
- * Parse the numeric deviceId out of a canonical brokerId
1332
- * (`${deviceId}/${camStreamId}` or `${deviceId}/${profile}`). Returns
1333
- * `undefined` for a malformed id — so every device-scoped log line on the RTSP
1334
- * plane can carry `tags.deviceId` without risking a bogus tag value.
1335
- *
1336
- * Every log line about a device carries `tags: { deviceId }`, always the same
1337
- * key and always the numeric id: a miss rate, a media gap or a routing fault is
1338
- * always asked per-camera ("why is 617 worse than 615?"), and a line that omits
1339
- * the tag cannot answer it.
1340
- */
1341
- function deviceIdFromBrokerId$1(brokerId) {
1342
- const parsed = Number(brokerId.split("/")[0]);
1343
- return Number.isFinite(parsed) ? parsed : void 0;
1344
- }
1345
- //#endregion
1346
- //#region src/stream-broker/paced-rtp-replay.ts
1347
- var PACED_REPLAY_DEFAULTS = {
1348
- singleShotMax: 64,
1349
- tickMs: 5,
1350
- targetDurationMs: 300,
1351
- minChunkSize: 32,
1352
- maxChunkSize: 128
1353
- };
1354
- var defaultSchedule = (fn, ms) => {
1355
- const t = setTimeout(fn, ms);
1356
- return () => clearTimeout(t);
1357
- };
1358
- /**
1359
- * Compute the per-tick chunk size for a burst of `total` packets: enough to
1360
- * drain within `targetDurationMs` at one chunk per `tickMs`, clamped to
1361
- * [minChunkSize, maxChunkSize]. Bursts ≤ `singleShotMax` are one chunk.
1362
- */
1363
- function computeReplayChunkSize(total, tuning) {
1364
- if (total <= tuning.singleShotMax) return Math.max(total, 1);
1365
- const maxTicks = Math.max(1, Math.floor(tuning.targetDurationMs / tuning.tickMs));
1366
- const ideal = Math.ceil(total / maxTicks);
1367
- return Math.min(tuning.maxChunkSize, Math.max(tuning.minChunkSize, ideal));
1368
- }
1369
- /**
1370
- * One in-flight paced replay. Create per burst; not reusable after
1371
- * completion or abort.
1372
- */
1373
- var PacedRtpReplay = class {
1374
- send;
1375
- onDone;
1376
- tuning;
1377
- schedule;
1378
- now;
1379
- queue = [];
1380
- cancelTimer = null;
1381
- started = false;
1382
- finished = false;
1383
- startedAt = 0;
1384
- chunkSize = 0;
1385
- initialPackets = 0;
1386
- enqueuedLive = 0;
1387
- sentCount = 0;
1388
- chunkCount = 0;
1389
- constructor(send, onDone, options) {
1390
- this.send = send;
1391
- this.onDone = onDone;
1392
- this.tuning = {
1393
- ...PACED_REPLAY_DEFAULTS,
1394
- ...options?.tuning
1395
- };
1396
- this.schedule = options?.schedule ?? defaultSchedule;
1397
- this.now = options?.now ?? Date.now;
1398
- }
1399
- /** True while packets remain to be drained (live packets must be enqueued,
1400
- * not sent directly, to preserve order). */
1401
- get active() {
1402
- return this.started && !this.finished;
1403
- }
1404
- /**
1405
- * Begin the replay: sends the first chunk synchronously, then paces the
1406
- * rest. Calling `start` more than once is a no-op.
1407
- */
1408
- start(initial) {
1409
- if (this.started) return;
1410
- this.started = true;
1411
- this.startedAt = this.now();
1412
- this.initialPackets = initial.length;
1413
- this.queue = [...initial];
1414
- this.chunkSize = computeReplayChunkSize(initial.length, this.tuning);
1415
- this.drainChunk();
1416
- }
1417
- /**
1418
- * Append a live packet behind the still-queued replay tail. No-op (returns
1419
- * false) when the replay is not active — the caller must then send the
1420
- * packet directly.
1421
- */
1422
- enqueue(item) {
1423
- if (!this.active) return false;
1424
- this.queue.push(item);
1425
- this.enqueuedLive++;
1426
- return true;
1427
- }
1428
- /** Cancel the replay: pending packets are dropped, `onDone` fires with
1429
- * `aborted: true`. Safe to call multiple times / before `start`. */
1430
- abort() {
1431
- if (this.finished) return;
1432
- if (!this.started) {
1433
- this.started = true;
1434
- this.startedAt = this.now();
1435
- }
1436
- this.finish(true);
1437
- }
1438
- drainChunk() {
1439
- if (this.finished) return;
1440
- this.chunkCount++;
1441
- const n = Math.min(this.chunkSize, this.queue.length);
1442
- for (let i = 0; i < n; i++) {
1443
- if (this.finished) return;
1444
- if (this.send(this.queue[i])) this.sentCount++;
1445
- }
1446
- this.queue = this.queue.slice(n);
1447
- if (this.queue.length === 0) {
1448
- this.finish(false);
1449
- return;
1450
- }
1451
- this.cancelTimer = this.schedule(() => {
1452
- this.cancelTimer = null;
1453
- this.drainChunk();
1454
- }, this.tuning.tickMs);
1455
- }
1456
- finish(aborted) {
1457
- if (this.finished) return;
1458
- this.finished = true;
1459
- if (this.cancelTimer) {
1460
- this.cancelTimer();
1461
- this.cancelTimer = null;
1462
- }
1463
- this.queue = [];
1464
- this.onDone({
1465
- packets: this.initialPackets,
1466
- enqueuedLive: this.enqueuedLive,
1467
- sent: this.sentCount,
1468
- chunks: this.chunkCount,
1469
- chunkSize: this.chunkSize,
1470
- durationMs: this.now() - this.startedAt,
1471
- aborted
1472
- });
1473
- }
1474
- };
1475
1475
  //#endregion
1476
1476
  //#region src/stream-broker/rtsp/rtsp-restreamer.ts
1477
1477
  /**
@@ -1637,6 +1637,20 @@ var RtspRestreamer = class {
1637
1637
  /** Throttled drop/withhold accounting — see {@link reportDrops}. */
1638
1638
  dropsWithheldSpan = 0;
1639
1639
  dropsWithheldPackets = 0;
1640
+ /**
1641
+ * Muted (local-decoder loopback) joins refused the ring burst because it was
1642
+ * not near-live — see {@link LIVE_EDGE_MAX_SPAN_MS}. Its own counter: this is
1643
+ * a different rule from the span/packet bound, it fires on a DIFFERENT
1644
+ * population (only decode sessions), and conflating it with
1645
+ * {@link dropsWithheldSpan} would hide that a camera's decoder starts blind
1646
+ * on nearly every dial while its viewers are served normally.
1647
+ *
1648
+ * Counts withhold DECISIONS, not joins — `servePendingSessionsRtp` runs per
1649
+ * packet, so one session withheld for a whole GOP contributes many. Same
1650
+ * semantics as {@link dropsWithheldSpan}; the signal to read is "nonzero on
1651
+ * THIS camera while its viewers are being served".
1652
+ */
1653
+ dropsWithheldMutedLiveEdge = 0;
1640
1654
  dropsPrimeBacklog = 0;
1641
1655
  dropsLiveLatched = 0;
1642
1656
  dropsLiveClosed = 0;
@@ -1905,7 +1919,11 @@ var RtspRestreamer = class {
1905
1919
  const session = this.sessions.get(sessionId);
1906
1920
  if (!session) continue;
1907
1921
  if (session.isMuted()) {
1908
- if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) continue;
1922
+ if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
1923
+ this.dropsWithheldMutedLiveEdge++;
1924
+ withheld++;
1925
+ continue;
1926
+ }
1909
1927
  } else if (overSpan || overPackets) {
1910
1928
  if (overSpan) this.dropsWithheldSpan++;
1911
1929
  else this.dropsWithheldPackets++;
@@ -2028,6 +2046,8 @@ var RtspRestreamer = class {
2028
2046
  boundPackets: bound.maxPackets,
2029
2047
  primeWithheldSpanMs: this.dropsWithheldSpan,
2030
2048
  primeWithheldPackets: this.dropsWithheldPackets,
2049
+ primeWithheldMutedLiveEdge: this.dropsWithheldMutedLiveEdge,
2050
+ liveEdgeMaxSpanMs: LIVE_EDGE_MAX_SPAN_MS,
2031
2051
  primeAbortedOnBacklog: this.dropsPrimeBacklog,
2032
2052
  liveDroppedUntilKeyframe: this.dropsLiveLatched,
2033
2053
  clientsClosedOnBacklog: this.dropsLiveClosed,
@@ -2036,6 +2056,7 @@ var RtspRestreamer = class {
2036
2056
  });
2037
2057
  this.dropsWithheldSpan = 0;
2038
2058
  this.dropsWithheldPackets = 0;
2059
+ this.dropsWithheldMutedLiveEdge = 0;
2039
2060
  this.dropsPrimeBacklog = 0;
2040
2061
  this.dropsLiveLatched = 0;
2041
2062
  this.dropsLiveClosed = 0;
@@ -13060,7 +13081,7 @@ var TranscodePipelineManager = class {
13060
13081
  }
13061
13082
  });
13062
13083
  const passVideoCodec = sourceCodec ?? "H264";
13063
- const url = this.resolveSourceUrl(input.deviceId, sourceStream.camStreamId);
13084
+ const url = this.resolveSourceUrl(input.deviceId, sourceStream.camStreamId, wantedAudio === "none");
13064
13085
  const key = pipelineKeyFor(input.deviceId, sourceStream.camStreamId, "copy", wantedAudio, outResolution, outputArgs, false);
13065
13086
  return this.shareOrCreatePassthrough(key, () => ({
13066
13087
  url,
@@ -13216,10 +13237,11 @@ var TranscodePipelineManager = class {
13216
13237
  * source dial. Throws when no restream entry exists — a targeted source
13217
13238
  * must be dialed before it can be consumed.
13218
13239
  */
13219
- resolveSourceUrl(deviceId, camStreamId) {
13240
+ resolveSourceUrl(deviceId, camStreamId, videoOnly = false) {
13220
13241
  const brokerId = makeSourceBrokerId(deviceId, camStreamId);
13221
13242
  const entry = this.rtspEntryLookup(brokerId);
13222
- if (entry?.url) return entry.url;
13243
+ const url = videoOnly ? entry?.mutedUrl ?? entry?.url : entry?.url;
13244
+ if (url) return url;
13223
13245
  throw new Error(`getStreamWithCodec: no broker restream for ${brokerId} (source not dialed)`);
13224
13246
  }
13225
13247
  /**
@@ -13836,7 +13858,10 @@ var StreamBrokerManager = class StreamBrokerManager {
13836
13858
  cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
13837
13859
  rtspEntryLookup: (brokerId) => {
13838
13860
  const entry = this.rtspProvider.getEntry(brokerId);
13839
- return entry ? { url: entry.url } : null;
13861
+ return entry ? {
13862
+ url: entry.url,
13863
+ mutedUrl: entry.mutedUrl
13864
+ } : null;
13840
13865
  },
13841
13866
  resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
13842
13867
  ffmpegConfig: () => this.ffmpegConfig,