@camstack/addon-pipeline 1.1.18 → 1.1.20

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/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/audio-codec-ffmpeg/index.js +1125 -0
  4. package/dist/audio-codec-ffmpeg/index.mjs +1107 -0
  5. package/dist/decoder-ffmpeg/index.js +1179 -0
  6. package/dist/decoder-ffmpeg/index.mjs +1162 -0
  7. package/dist/detection-pipeline/index.js +1 -1
  8. package/dist/detection-pipeline/index.mjs +1 -1
  9. package/dist/{dist-CRzS7bK1.js → dist-BiP1gPeY.js} +21 -1
  10. package/dist/{dist-CBcVKDyT.mjs → dist-tzhTTRq4.mjs} +21 -1
  11. package/dist/ffmpeg-args-common-CASoNt42.js +68 -0
  12. package/dist/ffmpeg-args-common-c3p-nWtU.mjs +51 -0
  13. package/dist/motion-wasm/index.js +1 -1
  14. package/dist/motion-wasm/index.mjs +1 -1
  15. package/dist/pipeline-runner/index.js +1 -1
  16. package/dist/pipeline-runner/index.mjs +1 -1
  17. package/dist/recorder/index.js +24 -4
  18. package/dist/recorder/index.mjs +24 -4
  19. package/dist/stream-broker/_stub.js +35 -35
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cu2RfLyo.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D08qJTw_.mjs} +3 -3
  21. package/dist/stream-broker/{hostInit-D2VENktw.mjs → hostInit-CGnMNtPl.mjs} +3 -3
  22. package/dist/stream-broker/index.js +513 -501
  23. package/dist/stream-broker/index.mjs +503 -491
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-ohKbGSOY.js → MaskShapeCanvas-DI4BY7W2-CW4Qu4V6.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-m6kOetLh.js → MotionZonesSettings-NcxxQN8r-CHJ39tfi.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BNbgX3su.js → PrivacyMaskSettings-APgPLF7p-dInJ7kcz.js} +1 -1
  28. package/embed-dist/assets/{index-CqqHOFtM.js → index-CKpVWKSG.js} +4 -4
  29. package/embed-dist/index.html +1 -1
  30. package/package.json +17 -17
  31. package/dist/audio-codec-nodeav/index.js +0 -310
  32. package/dist/audio-codec-nodeav/index.mjs +0 -305
  33. package/dist/codec-runtime-BOk-13PN.js +0 -202
  34. package/dist/codec-runtime-BsqlEjPi.mjs +0 -197
  35. package/dist/decoder-nodeav/index.js +0 -1393
  36. package/dist/decoder-nodeav/index.mjs +0 -1385
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-BnQ_awK4.js");
6
- const require_dist = require("../dist-CRzS7bK1.js");
7
- const require_codec_runtime = require("../codec-runtime-BOk-13PN.js");
6
+ const require_dist = require("../dist-BiP1gPeY.js");
7
+ const require_ffmpeg_args_common = require("../ffmpeg-args-common-CASoNt42.js");
8
8
  let node_fs = require("node:fs");
9
9
  node_fs = require_model_download_service_C_IHWnXx.__toESM(node_fs);
10
10
  let node_path = require("node:path");
@@ -308,6 +308,31 @@ var DecoderSessionProxy = class {
308
308
  * broker supplies a `pullModeUrl` and the plane calls `openStream` once the
309
309
  * session exists.
310
310
  */
311
+ /**
312
+ * Collapse a hierarchical Moleculer nodeID (`hub/child-abc`) to its owning
313
+ * node (`hub`). Frame-plane co-location only cares about the physical node, not
314
+ * which forked child within it hosts the session.
315
+ */
316
+ function collapseNodeId(nodeId) {
317
+ const slash = nodeId.indexOf("/");
318
+ return slash >= 0 ? nodeId.slice(0, slash) : nodeId;
319
+ }
320
+ /**
321
+ * Decide whether a decoder session created on `decoderNodeId` is co-located
322
+ * with the broker whose local node is `brokerNodeId`.
323
+ *
324
+ * The broker's shm frame ring is NODE-LOCAL: a decoder session on any other
325
+ * node can never deliver frame handles to this broker's consumers, so a
326
+ * mis-placed session must be rejected. Both ids are collapsed (`hub/child`→
327
+ * `hub`) before comparison.
328
+ *
329
+ * A `null` `brokerNodeId` means the broker's own node is unknown — enforcement
330
+ * is skipped (legacy behavior, accept any placement).
331
+ */
332
+ function isDecoderNodeColocated(brokerNodeId, decoderNodeId) {
333
+ if (brokerNodeId === null) return true;
334
+ return collapseNodeId(brokerNodeId) === collapseNodeId(decoderNodeId);
335
+ }
311
336
  /** Per-subscription handle-queue capacity. A handle is tiny; 64 absorbs a
312
337
  * burst between two `pullFrameHandles` polls without unbounded growth. */
313
338
  var HANDLE_QUEUE_CAPACITY = 64;
@@ -324,6 +349,7 @@ var FrameHandlePlane = class {
324
349
  decoderApi;
325
350
  logger;
326
351
  resolveStreamInfo;
352
+ localNodeId;
327
353
  subscriptions = /* @__PURE__ */ new Map();
328
354
  sessions = /* @__PURE__ */ new Map();
329
355
  disposed = false;
@@ -339,6 +365,7 @@ var FrameHandlePlane = class {
339
365
  this.decoderApi = options.decoderApi;
340
366
  this.logger = options.logger;
341
367
  this.resolveStreamInfo = options.resolveStreamInfo;
368
+ this.localNodeId = options.localNodeId;
342
369
  }
343
370
  /** Number of active handle subscriptions — surfaced for diagnostics. */
344
371
  get subscriberCount() {
@@ -639,6 +666,22 @@ var FrameHandlePlane = class {
639
666
  ...info.numericDeviceId !== void 0 ? { deviceId: info.numericDeviceId } : {},
640
667
  tag: `${info.tag}:shm:${format}`
641
668
  });
669
+ if (!isDecoderNodeColocated(this.localNodeId, nodeId)) {
670
+ this.logger?.warn("frame-handle plane: decoder session landed on a non-local node — destroying (shm ring is node-local)", { meta: {
671
+ format,
672
+ requestedNode: this.localNodeId,
673
+ gotNode: nodeId,
674
+ sessionId
675
+ } });
676
+ await this.decoderApi.destroySession({ sessionId }).catch((err) => {
677
+ this.logger?.warn("frame-handle plane: failed to destroy mis-placed decoder session", { meta: {
678
+ format,
679
+ sessionId,
680
+ error: require_dist.errMsg(err)
681
+ } });
682
+ });
683
+ return false;
684
+ }
642
685
  const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
643
686
  session.proxy = proxy;
644
687
  session.nodeId = nodeId;
@@ -4083,6 +4126,13 @@ var StreamBroker = class StreamBroker {
4083
4126
  decoderApi;
4084
4127
  audioCodecApi;
4085
4128
  logger;
4129
+ /**
4130
+ * This broker's OWN local Moleculer node id, threaded from the manager
4131
+ * (`ctx.kernel.localNodeId`). Passed to the `FrameHandlePlane` so it can
4132
+ * reject decoder sessions that land on a different node (the shm frame ring
4133
+ * is node-local). `null` = unknown → co-location enforcement skipped.
4134
+ */
4135
+ localNodeId;
4086
4136
  startedAt = Date.now();
4087
4137
  nativeClient = null;
4088
4138
  rfc4571Reader = null;
@@ -4269,11 +4319,12 @@ var StreamBroker = class StreamBroker {
4269
4319
  static DEFAULT_PRE_BUFFER_SEC = 10;
4270
4320
  /** Maximum allowed pre-buffer duration in seconds */
4271
4321
  static MAX_PRE_BUFFER_SEC = 30;
4272
- constructor(deviceId, decoderApi, logger, audioCodecApi) {
4322
+ constructor(deviceId, decoderApi, logger, audioCodecApi, localNodeId) {
4273
4323
  this.deviceId = deviceId;
4274
4324
  this.decoderApi = decoderApi;
4275
4325
  this.audioCodecApi = audioCodecApi ?? null;
4276
4326
  this.logger = logger;
4327
+ this.localNodeId = localNodeId ?? null;
4277
4328
  this.pipeServer = new StreamPipeServer(logger?.child("pipe-server"));
4278
4329
  this.rtspRestreamer = new RtspRestreamer(deviceId);
4279
4330
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
@@ -5019,7 +5070,8 @@ var StreamBroker = class StreamBroker {
5019
5070
  this.frameHandlePlane = new FrameHandlePlane({
5020
5071
  decoderApi: this.decoderApi,
5021
5072
  logger: this.logger?.child("frame-handle-plane"),
5022
- resolveStreamInfo: () => this.resolveFrameHandleStreamInfo()
5073
+ resolveStreamInfo: () => this.resolveFrameHandleStreamInfo(),
5074
+ localNodeId: this.localNodeId
5023
5075
  });
5024
5076
  return this.frameHandlePlane;
5025
5077
  }
@@ -6084,6 +6136,10 @@ var RtspSession = class {
6084
6136
  lastRtpAt = 0;
6085
6137
  /** Total video RTP bytes written to the socket. */
6086
6138
  bytesSent = 0;
6139
+ /** Client `User-Agent` (from the first request that carries one). Lets the
6140
+ * broker panel identify a consumer by purpose — e.g. the recorder sets
6141
+ * `-user_agent recorder` on its RTSP pull — instead of a bare address. */
6142
+ userAgent = null;
6087
6143
  /** When true, this session receives video-only (no audio RTP). */
6088
6144
  muted;
6089
6145
  constructor(socket, sdp, onClose, muted) {
@@ -6127,6 +6183,7 @@ var RtspSession = class {
6127
6183
  return {
6128
6184
  sessionId: this.sessionId,
6129
6185
  remoteAddr: `${remoteIp}:${remotePort}`,
6186
+ userAgent: this.userAgent,
6130
6187
  playing: this.isPlaying(),
6131
6188
  muted: this.muted,
6132
6189
  connectedAt: this.connectedAt,
@@ -6193,7 +6250,10 @@ var RtspSession = class {
6193
6250
  const requestText = this.buffer.slice(0, endIdx);
6194
6251
  this.buffer = this.buffer.slice(endIdx + 4);
6195
6252
  const request = this.parseRequest(requestText);
6196
- if (request) this.handleRequest(request);
6253
+ if (request) {
6254
+ if (this.userAgent === null) this.userAgent = request.headers.get("user-agent") ?? null;
6255
+ this.handleRequest(request);
6256
+ }
6197
6257
  }
6198
6258
  }
6199
6259
  parseRequest(text) {
@@ -6585,35 +6645,6 @@ var RtspRestreamProviderImpl = class {
6585
6645
  }
6586
6646
  };
6587
6647
  //#endregion
6588
- //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
6589
- var AUDIO_ENCODER_BY_CODEC = {
6590
- opus: "libopus",
6591
- aac: "aac",
6592
- pcmu: "pcm_mulaw",
6593
- pcma: "pcm_alaw",
6594
- copy: "copy"
6595
- };
6596
- /**
6597
- * Build the audio-encode args `-c:a <enc> [-b:a Nk] [-ar N] [-ac N]`. Each knob
6598
- * is emitted only when provided, in this fixed order — matching what every
6599
- * broker site already emitted by hand.
6600
- */
6601
- function audioEncoderArgs(codec, opts = {}) {
6602
- const args = ["-c:a", AUDIO_ENCODER_BY_CODEC[codec]];
6603
- if (opts.bitrateKbps !== void 0) args.push("-b:a", `${opts.bitrateKbps}k`);
6604
- if (opts.sampleRateHz !== void 0) args.push("-ar", String(opts.sampleRateHz));
6605
- if (opts.channels !== void 0) args.push("-ac", String(opts.channels));
6606
- return args;
6607
- }
6608
- /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
6609
- function logBannerArgs(level) {
6610
- return [
6611
- "-hide_banner",
6612
- "-loglevel",
6613
- level
6614
- ];
6615
- }
6616
- //#endregion
6617
6648
  //#region src/stream-broker/stream-broker/ffmpeg-invocation.ts
6618
6649
  /** The full set of hwaccel settings (for validation + option surfaces). */
6619
6650
  var FFMPEG_HW_ACCEL_SETTINGS = [
@@ -6673,23 +6704,23 @@ function pickVideoEncoder(target, encoders, hwAccel) {
6673
6704
  /** Audio encoder args for a transcode target (preset values over the shared low-level builder). */
6674
6705
  function pickAudioEncoderArgs(audio) {
6675
6706
  switch (audio) {
6676
- case "aac": return audioEncoderArgs("aac", {
6707
+ case "aac": return require_ffmpeg_args_common.audioEncoderArgs("aac", {
6677
6708
  bitrateKbps: 128,
6678
6709
  sampleRateHz: 48e3,
6679
6710
  channels: 2
6680
6711
  });
6681
- case "opus": return audioEncoderArgs("opus", {
6712
+ case "opus": return require_ffmpeg_args_common.audioEncoderArgs("opus", {
6682
6713
  bitrateKbps: 64,
6683
6714
  sampleRateHz: 48e3,
6684
6715
  channels: 2
6685
6716
  });
6686
- case "pcmu": return audioEncoderArgs("pcmu", {
6717
+ case "pcmu": return require_ffmpeg_args_common.audioEncoderArgs("pcmu", {
6687
6718
  sampleRateHz: 8e3,
6688
6719
  channels: 1
6689
6720
  });
6690
- case "copy": return audioEncoderArgs("copy");
6721
+ case "copy": return require_ffmpeg_args_common.audioEncoderArgs("copy");
6691
6722
  case "none": return ["-an"];
6692
- default: return audioEncoderArgs("aac", { bitrateKbps: 128 });
6723
+ default: return require_ffmpeg_args_common.audioEncoderArgs("aac", { bitrateKbps: 128 });
6693
6724
  }
6694
6725
  }
6695
6726
  /**
@@ -6754,7 +6785,7 @@ function buildFfmpegArgs$1(inv) {
6754
6785
  const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
6755
6786
  const audioArgs = inv.sink.kind === "stdout" && (inv.sink.container === "h264" || inv.sink.container === "hevc") ? ["-an"] : pickAudioEncoderArgs(inv.audio);
6756
6787
  return [
6757
- ...logBannerArgs("warning"),
6788
+ ...require_ffmpeg_args_common.logBannerArgs("warning"),
6758
6789
  ...decodeArgs,
6759
6790
  "-rtsp_transport",
6760
6791
  "tcp",
@@ -9643,7 +9674,7 @@ var StreamBrokerManager = class {
9643
9674
  camStreamId
9644
9675
  });
9645
9676
  const source = this.buildSourceFromCamStream(cam);
9646
- const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api));
9677
+ const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
9647
9678
  broker.setRestreamers(this.restreamers);
9648
9679
  this.applyPreBufferConfig(broker, deviceId, camStreamId, cam);
9649
9680
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
@@ -11555,7 +11586,7 @@ function planAudioSilencePadPackets(input) {
11555
11586
  * audio plane honours the profile literally.
11556
11587
  */
11557
11588
  function buildTranscodeFfmpegArgs(inputFormat, profile, decodeHwAccel, sourceUrl) {
11558
- const args = [...logBannerArgs("error")];
11589
+ const args = [...require_ffmpeg_args_common.logBannerArgs("error")];
11559
11590
  if (profile.inputArgs?.length) args.push(...profile.inputArgs);
11560
11591
  if (decodeHwAccel && decodeHwAccel !== "none") args.push("-hwaccel", decodeHwAccel);
11561
11592
  args.push("-fflags", "+discardcorrupt", "-rtsp_transport", "tcp", "-i", sourceUrl);
@@ -11577,7 +11608,7 @@ function buildTranscodeFfmpegArgs(inputFormat, profile, decodeHwAccel, sourceUrl
11577
11608
  if (profile.audio === "passthrough") args.push("-an");
11578
11609
  else {
11579
11610
  const a = profile.audio;
11580
- args.push(...audioEncoderArgs(a.codec, {
11611
+ args.push(...require_ffmpeg_args_common.audioEncoderArgs(a.codec, {
11581
11612
  bitrateKbps: a.bitrateKbps,
11582
11613
  sampleRateHz: a.sampleRateHz,
11583
11614
  channels: a.channels
@@ -14278,475 +14309,468 @@ var TimelineSession = class {
14278
14309
  }
14279
14310
  };
14280
14311
  //#endregion
14281
- //#region src/stream-broker/recorded/segment-demux.ts
14312
+ //#region src/stream-broker/recorded/ffmpeg-run-once.ts
14282
14313
  /**
14283
- * Pure selector: map a node-av video `codecId` to its mp4toannexb bitstream
14284
- * filter name and the `AccessUnit.codec` tag. Both BSFs prepend the stream's
14285
- * parameter sets to keyframes (SPS/PPS for H.264; VPS/SPS/PPS for H.265).
14314
+ * ffmpeg-run-once spawn ffmpeg for ONE batch conversion: feed an in-memory
14315
+ * input buffer on stdin, collect the whole stdout, resolve when the process
14316
+ * exits. Used by the recorded-playback demuxers (`segment-demux`,
14317
+ * `segment-audio`) which turn a single self-contained `.m4s` fragment into
14318
+ * Annex-B / µ-law bytes in a crash-isolated subprocess (replacing the old
14319
+ * in-process `node-av` binding).
14286
14320
  *
14287
- * `h264Id`/`hevcId` are injected (from `node-av/constants`
14288
- * `AV_CODEC_ID_H264`=27 / `AV_CODEC_ID_HEVC`=173) so this is unit-testable
14289
- * without loading the FFmpeg binding. Throws on any other codec.
14321
+ * Unlike the live streaming sessions (`decoder-ffmpeg`, `audio-codec-ffmpeg`)
14322
+ * this is fire-once: there is no persistent stdin feed. stdin is written and
14323
+ * closed up-front so ffmpeg reads the whole fragment, transcodes, flushes, and
14324
+ * exits; we buffer stdout and resolve with it.
14325
+ *
14326
+ * The kill sequence copies the CORRECTED gating from `decoder-ffmpeg`/
14327
+ * `audio-codec-ffmpeg`: SIGTERM then escalated SIGKILL gated on
14328
+ * `exitCode === null && signalCode === null` (NOT on `child.killed`, which Node
14329
+ * sets true after ANY kill() so `!child.killed` never fires → leaked ffmpeg).
14290
14330
  */
14291
- function bsfNameForCodecId(codecId, h264Id, hevcId) {
14292
- if (codecId === h264Id) return {
14293
- bsf: "h264_mp4toannexb",
14294
- codec: "H264"
14295
- };
14296
- if (codecId === hevcId) return {
14297
- bsf: "hevc_mp4toannexb",
14298
- codec: "H265"
14299
- };
14300
- throw new Error(`segment-demux: unsupported video codec id ${codecId} (expected H264=${h264Id} or HEVC=${hevcId})`);
14301
- }
14331
+ /** Hard ceiling for one fragment conversion; a ~4 s GOP transcodes in well
14332
+ * under this even on a slow agent. Prevents a wedged ffmpeg from leaking. */
14333
+ var RUN_TIMEOUT_MS = 8e3;
14334
+ /** Grace period between SIGTERM and the escalated SIGKILL. */
14335
+ var KILL_GRACE_MS = 500;
14302
14336
  /**
14303
- * `AV_NOPTS_VALUE` (INT64_MIN) FFmpeg's sentinel for "no timestamp". A packet
14304
- * carrying this in `pts`/`dts` has no usable presentation time; converting it
14305
- * naively yields a garbage ~-9.2e18 ms.
14337
+ * Run ffmpeg once over `input` and resolve with the collected stdout bytes.
14338
+ *
14339
+ * @throws If ffmpeg cannot be spawned, times out, or exits non-zero (unless
14340
+ * `tolerateNonZeroExit` is set).
14306
14341
  */
14307
- var AV_NOPTS_VALUE$1 = -9223372036854775808n;
14308
- var _navPromise$1 = null;
14309
- var _constsPromise$1 = null;
14310
- function getNodeAv$1() {
14311
- return _navPromise$1 ??= import("node-av");
14312
- }
14313
- function getConstants$1() {
14314
- return _constsPromise$1 ??= import("node-av/constants");
14315
- }
14316
- /** Convert a presentation timestamp in stream ticks to milliseconds. */
14317
- function ticksToMs$1(ptsTicks, timeBase) {
14318
- return Math.round(Number(ptsTicks) * 1e3 * timeBase.num / timeBase.den);
14342
+ function runFfmpegOnce(options) {
14343
+ const { ffmpegPath, args, input, tolerateNonZeroExit = false } = options;
14344
+ return new Promise((resolve, reject) => {
14345
+ const chunks = [];
14346
+ const stderrTail = [];
14347
+ let settled = false;
14348
+ const child = (() => {
14349
+ try {
14350
+ return (0, node_child_process.spawn)(ffmpegPath, [...args]);
14351
+ } catch (err) {
14352
+ reject(/* @__PURE__ */ new Error(`ffmpeg spawn failed: ${require_dist.errMsg(err)}`));
14353
+ return null;
14354
+ }
14355
+ })();
14356
+ if (child === null) return;
14357
+ const timer = setTimeout(() => {
14358
+ if (settled) return;
14359
+ settle(() => reject(/* @__PURE__ */ new Error("ffmpeg-run-once: timed out")));
14360
+ kill();
14361
+ }, RUN_TIMEOUT_MS);
14362
+ timer.unref?.();
14363
+ const settle = (fn) => {
14364
+ if (settled) return;
14365
+ settled = true;
14366
+ clearTimeout(timer);
14367
+ fn();
14368
+ };
14369
+ const kill = () => {
14370
+ try {
14371
+ child.kill("SIGTERM");
14372
+ } catch {}
14373
+ setTimeout(() => {
14374
+ if (child.exitCode === null && child.signalCode === null) try {
14375
+ child.kill("SIGKILL");
14376
+ } catch {}
14377
+ }, KILL_GRACE_MS).unref?.();
14378
+ };
14379
+ child.stdin?.on("error", () => {});
14380
+ child.stdout?.on("data", (chunk) => chunks.push(chunk));
14381
+ child.stderr?.on("data", (data) => {
14382
+ const line = data.toString().trim();
14383
+ if (line) {
14384
+ stderrTail.push(line);
14385
+ if (stderrTail.length > 8) stderrTail.shift();
14386
+ }
14387
+ });
14388
+ child.on("error", (err) => {
14389
+ settle(() => reject(/* @__PURE__ */ new Error(`ffmpeg process error: ${err.message}`)));
14390
+ });
14391
+ child.on("exit", (code, signal) => {
14392
+ const stdout = Buffer.concat(chunks);
14393
+ if (code === 0 || tolerateNonZeroExit) {
14394
+ settle(() => resolve(stdout));
14395
+ return;
14396
+ }
14397
+ settle(() => reject(/* @__PURE__ */ new Error(`ffmpeg exited code=${code} signal=${signal}: ${stderrTail.join(" | ") || "(no stderr)"}`)));
14398
+ });
14399
+ try {
14400
+ child.stdin?.write(Buffer.from(input.buffer, input.byteOffset, input.byteLength));
14401
+ child.stdin?.end();
14402
+ } catch {}
14403
+ });
14319
14404
  }
14405
+ //#endregion
14406
+ //#region src/stream-broker/recorded/ts-annexb-demux.ts
14320
14407
  /**
14321
- * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
14322
- * access units.
14408
+ * ts-annexb-demux pure MPEG-TS ordered Annex-B access units.
14323
14409
  *
14324
- * The video codec is detected from the segment's video stream `codecId`; the
14325
- * matching mp4toannexb bitstream filter prepends parameter sets to every IDR
14326
- * (SPS/PPS for H.264, VPS/SPS/PPS for H.265) no manual `avcC`/`hvcC` parsing.
14327
- * Each returned {@link AccessUnit} owns a detached copy of the packet bytes —
14328
- * the underlying `node-av` packets are freed before this resolves, so the
14329
- * result is safe to retain. Throws on a non-H.264/H.265 video codec.
14410
+ * Companion to {@link demuxSegmentToAnnexB}: `segment-demux` runs one `.m4s`
14411
+ * fragment through `ffmpeg -c:v copy -f mpegts` (crash-isolated subprocess,
14412
+ * replacing the old in-process `node-av` demux) and hands the resulting
14413
+ * transport stream here to be split back into per-frame access units.
14330
14414
  *
14331
- * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
14332
- * @returns Access units in source presentation order, each tagged with its codec.
14415
+ * Why MPEG-TS as the intermediate container:
14416
+ * - it preserves EXACTLY the two things the recorded-playback feeder needs
14417
+ * that a raw Annex-B elementary stream (`-f h264/hevc`) throws away — the
14418
+ * per-frame presentation timestamp (PES `PTS`, 90 kHz) and the frame
14419
+ * boundaries (one video PES per access unit, marked by `payload_unit_start`);
14420
+ * - the ffmpeg mpegts muxer FUSES the parameter sets onto every random-access
14421
+ * point exactly like the `h264/hevc_mp4toannexb` bitstream filter did — the
14422
+ * IDR PES carries `AUD` + `VPS/SPS/PPS` (H.265) or `AUD` + `SPS/PPS`
14423
+ * (H.264) ahead of the IDR slice — so no explicit BSF is needed and the
14424
+ * downstream pusher's "one AU per frame, params on keyframes" contract holds;
14425
+ * - the codec is read straight from the PMT `stream_type` (`0x1B` → H.264,
14426
+ * `0x24` → H.265), so no separate probe pass is required.
14427
+ *
14428
+ * This module is intentionally pure (a `Uint8Array` in, access units out) so it
14429
+ * unit-tests against a committed `.ts` fixture with no ffmpeg/native binding.
14333
14430
  */
14334
- async function demuxSegmentToAnnexB(bytes) {
14335
- const nav = await getNodeAv$1();
14336
- const C = await getConstants$1();
14337
- const input = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14338
- const demuxer = await nav.Demuxer.open(input);
14339
- let bsf = null;
14340
- try {
14341
- const videoStream = demuxer.video() ?? demuxer.findBestStream(C.AVMEDIA_TYPE_VIDEO);
14342
- if (!videoStream) throw new Error("segment-demux: no video stream found in fragment");
14343
- const timeBase = videoStream.timeBase;
14344
- const { bsf: bsfName, codec } = bsfNameForCodecId(videoStream.codecpar.codecId, C.AV_CODEC_ID_H264, C.AV_CODEC_ID_HEVC);
14345
- bsf = nav.BitStreamFilterAPI.create(bsfName, videoStream);
14346
- const accessUnits = [];
14347
- for await (const packet of bsf.packets(demuxer.packets(videoStream.index))) {
14348
- if (packet === null) break;
14349
- try {
14350
- const data = packet.data;
14351
- if (data && data.length > 0) {
14352
- const copy = new Uint8Array(data.length);
14353
- copy.set(data);
14354
- const rawPts = packet.pts !== AV_NOPTS_VALUE$1 ? packet.pts : packet.dts !== AV_NOPTS_VALUE$1 ? packet.dts : 0n;
14355
- accessUnits.push({
14356
- data: copy,
14357
- ptsMs: ticksToMs$1(rawPts, timeBase),
14358
- keyframe: packet.isKeyframe,
14359
- codec
14360
- });
14361
- }
14362
- } finally {
14363
- packet.free();
14431
+ /** MPEG-TS transport packet size. */
14432
+ var TS_PACKET_SIZE = 188;
14433
+ /** Transport packet sync byte. */
14434
+ var TS_SYNC_BYTE = 71;
14435
+ /** The PAT always lives on PID 0. */
14436
+ var PAT_PID = 0;
14437
+ /** PMT `stream_type` for H.264 (AVC) video. */
14438
+ var STREAM_TYPE_H264 = 27;
14439
+ /** PMT `stream_type` for H.265 (HEVC) video. */
14440
+ var STREAM_TYPE_H265 = 36;
14441
+ /** Map a PMT `stream_type` to the AccessUnit codec tag, or null if not video. */
14442
+ function codecForStreamType(streamType) {
14443
+ if (streamType === STREAM_TYPE_H264) return "H264";
14444
+ if (streamType === STREAM_TYPE_H265) return "H265";
14445
+ return null;
14446
+ }
14447
+ /**
14448
+ * Detect whether an Annex-B access unit is a keyframe by scanning its NAL units:
14449
+ * H.264 IDR (type 5) or H.265 IRAP (types 16–21). Mirrors
14450
+ * `AnnexBDeframer.isKeyframeNal`; params/AUD NALs ahead of the slice are ignored.
14451
+ */
14452
+ function isKeyframeAccessUnit(au, codec) {
14453
+ for (const nalStart of iterateNalStarts(au)) {
14454
+ const header = au[nalStart];
14455
+ if (header === void 0) continue;
14456
+ if (codec === "H264") {
14457
+ if ((header & 31) === 5) return true;
14458
+ } else {
14459
+ const type = header >> 1 & 63;
14460
+ if (type >= 16 && type <= 21) return true;
14461
+ }
14462
+ }
14463
+ return false;
14464
+ }
14465
+ /**
14466
+ * Yield the byte offset of the FIRST byte of each NAL unit (i.e. just past its
14467
+ * Annex-B start code, `00 00 01` or `00 00 00 01`) in `buf`.
14468
+ */
14469
+ function* iterateNalStarts(buf) {
14470
+ const len = buf.length;
14471
+ let i = 0;
14472
+ while (i + 2 < len) {
14473
+ if (buf[i] === 0 && buf[i + 1] === 0) {
14474
+ if (buf[i + 2] === 1) {
14475
+ yield i + 3;
14476
+ i += 3;
14477
+ continue;
14478
+ }
14479
+ if (buf[i + 2] === 0 && buf[i + 3] === 1) {
14480
+ yield i + 4;
14481
+ i += 4;
14482
+ continue;
14364
14483
  }
14365
14484
  }
14366
- return accessUnits;
14367
- } finally {
14368
- bsf?.close();
14369
- await demuxer.close();
14485
+ i++;
14370
14486
  }
14371
14487
  }
14488
+ /**
14489
+ * Read the 33-bit PES `PTS` (or DTS) from the 5 bytes at `off` and return it in
14490
+ * 90 kHz ticks. The layout interleaves marker bits every byte
14491
+ * (`0010 PPP1 PPPPPPPP PPPPPPP1 PPPPPPPP PPPPPPP1`).
14492
+ */
14493
+ function readPts33(buf, off) {
14494
+ const b0 = buf[off] ?? 0;
14495
+ const b1 = buf[off + 1] ?? 0;
14496
+ const b2 = buf[off + 2] ?? 0;
14497
+ const b3 = buf[off + 3] ?? 0;
14498
+ const b4 = buf[off + 4] ?? 0;
14499
+ const hi = b0 >> 1 & 7;
14500
+ const mid = b1 << 7 | b2 >> 1;
14501
+ const lo = b3 << 7 | b4 >> 1;
14502
+ return hi * 1073741824 + mid * 32768 + lo;
14503
+ }
14504
+ /** Locate the payload offset within a TS packet, skipping any adaptation field. */
14505
+ function payloadOffset(pkt, base) {
14506
+ const adaptationFieldControl = pkt[base + 3] >> 4 & 3;
14507
+ let off = base + 4;
14508
+ if (adaptationFieldControl & 2) off += 1 + pkt[off];
14509
+ return off;
14510
+ }
14511
+ /** Whether a TS packet's adaptation_field_control indicates a payload is present. */
14512
+ function hasPayload(pkt, base) {
14513
+ const afc = pkt[base + 3] >> 4 & 3;
14514
+ return afc === 1 || afc === 3;
14515
+ }
14516
+ /** Parse the PAT payload → the first program's PMT PID. */
14517
+ function parsePatForPmtPid(payload) {
14518
+ const p = 1 + payload[0] + 8;
14519
+ if (p + 3 >= payload.length) return null;
14520
+ return (payload[p + 2] & 31) << 8 | payload[p + 3];
14521
+ }
14522
+ /** Parse the PMT payload → the first H.264/H.265 elementary stream's PID + codec. */
14523
+ function parsePmt(payload) {
14524
+ const p = 1 + payload[0];
14525
+ const sectionLength = (payload[p + 1] & 15) << 8 | payload[p + 2];
14526
+ const programInfoLength = (payload[p + 10] & 15) << 8 | payload[p + 11];
14527
+ let es = p + 12 + programInfoLength;
14528
+ const sectionEnd = p + 3 + sectionLength - 4;
14529
+ while (es + 4 < sectionEnd && es + 4 < payload.length) {
14530
+ const streamType = payload[es];
14531
+ const elementaryPid = (payload[es + 1] & 31) << 8 | payload[es + 2];
14532
+ const esInfoLength = (payload[es + 3] & 15) << 8 | payload[es + 4];
14533
+ const codec = codecForStreamType(streamType);
14534
+ if (codec !== null) return {
14535
+ videoPid: elementaryPid,
14536
+ codec
14537
+ };
14538
+ es += 5 + esInfoLength;
14539
+ }
14540
+ return null;
14541
+ }
14542
+ /** Split one reassembled PES packet into its Annex-B elementary-stream payload
14543
+ * and 90 kHz PTS (null when the PES carries no PTS flag). */
14544
+ function splitPes(pes) {
14545
+ if (pes.length < 9 || pes[0] !== 0 || pes[1] !== 0 || pes[2] !== 1) return null;
14546
+ const ptsDtsFlags = pes[7] >> 6 & 3;
14547
+ const headerDataLength = pes[8];
14548
+ return {
14549
+ es: pes.subarray(9 + headerDataLength),
14550
+ pts: ptsDtsFlags & 2 ? readPts33(pes, 9) : null
14551
+ };
14552
+ }
14553
+ /**
14554
+ * Demux an MPEG-TS byte stream (as produced by `ffmpeg -c:v copy -f mpegts`)
14555
+ * into ordered Annex-B access units, one per video PES.
14556
+ *
14557
+ * @param ts - The full transport stream.
14558
+ * @returns The detected codec and its access units in transport (decode) order.
14559
+ * @throws If no H.264/H.265 video elementary stream is present.
14560
+ */
14561
+ function demuxMpegTsToAnnexB(ts) {
14562
+ let pmtPid = null;
14563
+ let pmt = null;
14564
+ for (let i = 0; i + TS_PACKET_SIZE <= ts.length; i += TS_PACKET_SIZE) {
14565
+ const pkt = ts.subarray(i, i + TS_PACKET_SIZE);
14566
+ if (pkt[0] !== TS_SYNC_BYTE) continue;
14567
+ const pid = (pkt[1] & 31) << 8 | pkt[2];
14568
+ if (!((pkt[1] & 64) !== 0) || !hasPayload(pkt, 0)) continue;
14569
+ const payload = pkt.subarray(payloadOffset(pkt, 0));
14570
+ if (pid === PAT_PID) pmtPid ??= parsePatForPmtPid(payload);
14571
+ else if (pmtPid !== null && pid === pmtPid && pmt === null) pmt = parsePmt(payload);
14572
+ }
14573
+ if (pmt === null) throw new Error("ts-annexb-demux: no H.264/H.265 video stream found in transport stream");
14574
+ const { videoPid, codec } = pmt;
14575
+ const accessUnits = [];
14576
+ let current = null;
14577
+ const finalize = () => {
14578
+ if (current === null) return;
14579
+ const pes = current.length === 1 ? current[0] : concat(current);
14580
+ current = null;
14581
+ const split = splitPes(pes);
14582
+ if (split === null || split.es.length === 0) return;
14583
+ const data = new Uint8Array(split.es.length);
14584
+ data.set(split.es);
14585
+ accessUnits.push({
14586
+ data,
14587
+ ptsMs: split.pts === null ? 0 : Math.round(split.pts / 90),
14588
+ keyframe: isKeyframeAccessUnit(data, codec)
14589
+ });
14590
+ };
14591
+ for (let i = 0; i + TS_PACKET_SIZE <= ts.length; i += TS_PACKET_SIZE) {
14592
+ const pkt = ts.subarray(i, i + TS_PACKET_SIZE);
14593
+ if (pkt[0] !== TS_SYNC_BYTE) continue;
14594
+ if (((pkt[1] & 31) << 8 | pkt[2]) !== videoPid) continue;
14595
+ if (!hasPayload(pkt, 0)) continue;
14596
+ const payloadStart = (pkt[1] & 64) !== 0;
14597
+ const payload = pkt.subarray(payloadOffset(pkt, 0));
14598
+ if (payloadStart) {
14599
+ finalize();
14600
+ current = [Uint8Array.from(payload)];
14601
+ } else if (current !== null) current.push(Uint8Array.from(payload));
14602
+ }
14603
+ finalize();
14604
+ return {
14605
+ codec,
14606
+ accessUnits
14607
+ };
14608
+ }
14609
+ /** Concatenate TS payload fragments into one contiguous PES buffer. */
14610
+ function concat(parts) {
14611
+ let total = 0;
14612
+ for (const p of parts) total += p.length;
14613
+ const out = new Uint8Array(total);
14614
+ let off = 0;
14615
+ for (const p of parts) {
14616
+ out.set(p, off);
14617
+ off += p.length;
14618
+ }
14619
+ return out;
14620
+ }
14372
14621
  //#endregion
14373
- //#region src/stream-broker/recorded/pcm-to-mulaw.ts
14622
+ //#region src/stream-broker/recorded/segment-demux.ts
14374
14623
  /**
14375
- * G.711 µ-law (PCMU) encode pure, no native deps.
14624
+ * segment-demux turn one self-contained `.m4s` fragment (a Buffer) into
14625
+ * ordered Annex-B access units. Codec-agnostic: handles BOTH H.264 and H.265
14626
+ * recordings (the recorder is `-c:v copy` passthrough, so an H.264 camera
14627
+ * produces H.264 recordings and an H.265 camera produces H.265 recordings).
14376
14628
  *
14377
- * The WebRTC audio sender negotiates `audio/PCMU` (8 kHz). Recorded audio is
14378
- * stored as AAC, decoded to 8 kHz mono signed-16 PCM, then companded to µ-law
14379
- * here for the same wire format the live path uses.
14629
+ * The recorder writes self-contained fragments: each `.m4s` carries its own
14630
+ * `moov` (and thus its own `avcC`/`hvcC` parameter sets) plus exactly one
14631
+ * IDR-led GOP (~4s). To replay one over a push pipeline we need the bytes as
14632
+ * Annex-B access units with parameter sets prepended to every IDR (SPS/PPS for
14633
+ * H.264; VPS/SPS/PPS for H.265) and each unit tagged with its source PTS.
14380
14634
  *
14381
- * Standard ITU-T G.711 µ-law: bias 0x84, clip 32635, exponent table.
14635
+ * Implementation: run the fragment through `ffmpeg -c:v copy -f mpegts` (a
14636
+ * crash-isolated subprocess — this replaced the old in-process `node-av`
14637
+ * demux + `h264/hevc_mp4toannexb` BSF, which could SIGBUS the whole broker on
14638
+ * a fragile stream), then split the transport stream back into access units
14639
+ * with {@link demuxMpegTsToAnnexB}. The mpegts muxer fuses the parameter sets
14640
+ * onto every IDR exactly like the mp4toannexb BSF did, preserves the per-frame
14641
+ * PTS (PES 90 kHz), and exposes the codec via the PMT `stream_type` — so no
14642
+ * explicit bitstream filter and no separate probe pass are needed. See
14643
+ * `ts-annexb-demux.ts` for the container-choice rationale.
14382
14644
  */
14383
- var BIAS = 132;
14384
- var CLIP = 32635;
14385
- var EXP_LUT = [
14386
- 0,
14387
- 0,
14388
- 1,
14389
- 1,
14390
- 2,
14391
- 2,
14392
- 2,
14393
- 2,
14394
- 3,
14395
- 3,
14396
- 3,
14397
- 3,
14398
- 3,
14399
- 3,
14400
- 3,
14401
- 3,
14402
- 4,
14403
- 4,
14404
- 4,
14405
- 4,
14406
- 4,
14407
- 4,
14408
- 4,
14409
- 4,
14410
- 4,
14411
- 4,
14412
- 4,
14413
- 4,
14414
- 4,
14415
- 4,
14416
- 4,
14417
- 4,
14418
- 5,
14419
- 5,
14420
- 5,
14421
- 5,
14422
- 5,
14423
- 5,
14424
- 5,
14425
- 5,
14426
- 5,
14427
- 5,
14428
- 5,
14429
- 5,
14430
- 5,
14431
- 5,
14432
- 5,
14433
- 5,
14434
- 5,
14435
- 5,
14436
- 5,
14437
- 5,
14438
- 5,
14439
- 5,
14440
- 5,
14441
- 5,
14442
- 5,
14443
- 5,
14444
- 5,
14445
- 5,
14446
- 5,
14447
- 5,
14448
- 5,
14449
- 5,
14450
- 6,
14451
- 6,
14452
- 6,
14453
- 6,
14454
- 6,
14455
- 6,
14456
- 6,
14457
- 6,
14458
- 6,
14459
- 6,
14460
- 6,
14461
- 6,
14462
- 6,
14463
- 6,
14464
- 6,
14465
- 6,
14466
- 6,
14467
- 6,
14468
- 6,
14469
- 6,
14470
- 6,
14471
- 6,
14472
- 6,
14473
- 6,
14474
- 6,
14475
- 6,
14476
- 6,
14477
- 6,
14478
- 6,
14479
- 6,
14480
- 6,
14481
- 6,
14482
- 6,
14483
- 6,
14484
- 6,
14485
- 6,
14486
- 6,
14487
- 6,
14488
- 6,
14489
- 6,
14490
- 6,
14491
- 6,
14492
- 6,
14493
- 6,
14494
- 6,
14495
- 6,
14496
- 6,
14497
- 6,
14498
- 6,
14499
- 6,
14500
- 6,
14501
- 6,
14502
- 6,
14503
- 6,
14504
- 6,
14505
- 6,
14506
- 6,
14507
- 6,
14508
- 6,
14509
- 6,
14510
- 6,
14511
- 6,
14512
- 6,
14513
- 6,
14514
- 7,
14515
- 7,
14516
- 7,
14517
- 7,
14518
- 7,
14519
- 7,
14520
- 7,
14521
- 7,
14522
- 7,
14523
- 7,
14524
- 7,
14525
- 7,
14526
- 7,
14527
- 7,
14528
- 7,
14529
- 7,
14530
- 7,
14531
- 7,
14532
- 7,
14533
- 7,
14534
- 7,
14535
- 7,
14536
- 7,
14537
- 7,
14538
- 7,
14539
- 7,
14540
- 7,
14541
- 7,
14542
- 7,
14543
- 7,
14544
- 7,
14545
- 7,
14546
- 7,
14547
- 7,
14548
- 7,
14549
- 7,
14550
- 7,
14551
- 7,
14552
- 7,
14553
- 7,
14554
- 7,
14555
- 7,
14556
- 7,
14557
- 7,
14558
- 7,
14559
- 7,
14560
- 7,
14561
- 7,
14562
- 7,
14563
- 7,
14564
- 7,
14565
- 7,
14566
- 7,
14567
- 7,
14568
- 7,
14569
- 7,
14570
- 7,
14571
- 7,
14572
- 7,
14573
- 7,
14574
- 7,
14575
- 7,
14576
- 7,
14577
- 7,
14578
- 7,
14579
- 7,
14580
- 7,
14581
- 7,
14582
- 7,
14583
- 7,
14584
- 7,
14585
- 7,
14586
- 7,
14587
- 7,
14588
- 7,
14589
- 7,
14590
- 7,
14591
- 7,
14592
- 7,
14593
- 7,
14594
- 7,
14595
- 7,
14596
- 7,
14597
- 7,
14598
- 7,
14599
- 7,
14600
- 7,
14601
- 7,
14602
- 7,
14603
- 7,
14604
- 7,
14605
- 7,
14606
- 7,
14607
- 7,
14608
- 7,
14609
- 7,
14610
- 7,
14611
- 7,
14612
- 7,
14613
- 7,
14614
- 7,
14615
- 7,
14616
- 7,
14617
- 7,
14618
- 7,
14619
- 7,
14620
- 7,
14621
- 7,
14622
- 7,
14623
- 7,
14624
- 7,
14625
- 7,
14626
- 7,
14627
- 7,
14628
- 7,
14629
- 7,
14630
- 7,
14631
- 7,
14632
- 7,
14633
- 7,
14634
- 7,
14635
- 7,
14636
- 7,
14637
- 7,
14638
- 7,
14639
- 7,
14640
- 7,
14641
- 7
14645
+ /**
14646
+ * ffmpeg arguments to remux one `.m4s` fragment (read from stdin) into an
14647
+ * Annex-B MPEG-TS on stdout. `-c:v copy` = no re-encode; the mpegts muxer does
14648
+ * the mp4→annexb conversion and fuses parameter sets onto every IDR. `-an`
14649
+ * drops audio (handled separately by `segment-audio`). Exported for the spec.
14650
+ */
14651
+ var SEGMENT_DEMUX_FFMPEG_ARGS = [
14652
+ "-hide_banner",
14653
+ "-loglevel",
14654
+ "error",
14655
+ "-i",
14656
+ "pipe:0",
14657
+ "-map",
14658
+ "0:v:0",
14659
+ "-c:v",
14660
+ "copy",
14661
+ "-an",
14662
+ "-f",
14663
+ "mpegts",
14664
+ "pipe:1"
14642
14665
  ];
14643
- /** Compand one signed 16-bit linear PCM sample to an 8-bit µ-law byte. */
14644
- function linearToMuLaw(sample) {
14645
- let s = sample;
14646
- const sign = s >> 8 & 128;
14647
- if (sign !== 0) s = -s;
14648
- if (s > CLIP) s = CLIP;
14649
- s += BIAS;
14650
- const exponent = EXP_LUT[s >> 7 & 255];
14651
- const mantissa = s >> exponent + 3 & 15;
14652
- return ~(sign | exponent << 4 | mantissa) & 255;
14653
- }
14654
14666
  /**
14655
- * Encode interleaved signed-16 little-endian PCM (mono) to µ-law bytes.
14656
- * One output byte per input sample.
14667
+ * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
14668
+ * access units.
14669
+ *
14670
+ * The video codec is detected from the transport stream's PMT `stream_type`;
14671
+ * the mpegts muxer fuses parameter sets onto every IDR (SPS/PPS for H.264,
14672
+ * VPS/SPS/PPS for H.265). Each returned {@link AccessUnit} owns a detached copy
14673
+ * of the bytes. Throws on a non-H.264/H.265 stream or a failed conversion.
14674
+ *
14675
+ * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
14676
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
14677
+ * @returns Access units in source presentation order, each tagged with its codec.
14657
14678
  */
14658
- function encodeMuLaw(pcmS16le) {
14659
- const view = new DataView(pcmS16le.buffer, pcmS16le.byteOffset, pcmS16le.byteLength);
14660
- const n = pcmS16le.byteLength >> 1;
14661
- const out = new Uint8Array(n);
14662
- for (let i = 0; i < n; i++) out[i] = linearToMuLaw(view.getInt16(i * 2, true));
14663
- return out;
14679
+ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
14680
+ const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
14681
+ ffmpegPath,
14682
+ args: SEGMENT_DEMUX_FFMPEG_ARGS,
14683
+ input: bytes
14684
+ }));
14685
+ return accessUnits.map((au) => ({
14686
+ data: au.data,
14687
+ ptsMs: au.ptsMs,
14688
+ keyframe: au.keyframe,
14689
+ codec
14690
+ }));
14664
14691
  }
14665
14692
  //#endregion
14666
14693
  //#region src/stream-broker/recorded/segment-audio.ts
14667
14694
  /**
14668
- * segment-audio — turn one `.m4s` fragment's AAC audio into 8 kHz µ-law (PCMU)
14695
+ * segment-audio — turn one `.m4s` fragment's audio into 8 kHz µ-law (PCMU)
14669
14696
  * access units, matching the WebRTC audio sender's negotiated codec
14670
14697
  * (`audio/PCMU`, 8 kHz, PT 0 — see session.ts). Recorded audio is stored as AAC
14671
- * (the recorder transcodes every camera's audio to AAC); WebRTC speaks PCMU, so
14672
- * we decode AAC → 8 kHz mono s16 PCM (via the in-process `DecodeRuntime`, the
14673
- * same one the live audio-codec path uses) then compand to µ-law here.
14698
+ * (the recorder transcodes every camera's audio to AAC); WebRTC speaks PCMU.
14674
14699
  *
14675
- * Output: 20 ms (160-sample) PCMU frames with segment-relative `ptsMs`, ready to
14676
- * push on the session's audio sender alongside the recorded video.
14700
+ * Implementation: run the fragment through
14701
+ * `ffmpeg -vn -ar 8000 -ac 1 -c:a pcm_mulaw -f mulaw` (a crash-isolated
14702
+ * subprocess — this replaced the old in-process `node-av` `DecodeRuntime`
14703
+ * decode + hand-rolled µ-law companding). ffmpeg decodes the AAC, resamples to
14704
+ * 8 kHz mono, and companded µ-law bytes come out on stdout; we slice them into
14705
+ * fixed 20 ms (160-sample) PCMU frames here.
14677
14706
  *
14678
- * Best-effort by contract: returns [] when the fragment has no usable audio, and
14679
- * the caller swallows throws — recorded audio must NEVER block recorded video.
14707
+ * Output: 20 ms PCMU frames with segment-relative `ptsMs`, ready to push on the
14708
+ * session's audio sender alongside the recorded video.
14709
+ *
14710
+ * Best-effort by contract: returns [] when the fragment has no usable audio
14711
+ * (ffmpeg exits non-zero → tolerated, empty output), and the caller swallows
14712
+ * throws — recorded audio must NEVER block recorded video.
14680
14713
  */
14681
14714
  /** PCMU target: 8 kHz mono, 160 samples (20 ms) per RTP frame. */
14682
- var TARGET_RATE = 8e3;
14683
14715
  var FRAME_SAMPLES = 160;
14684
14716
  var FRAME_MS = 20;
14685
- /** FFmpeg's "no timestamp" sentinel (INT64_MIN). */
14686
- var AV_NOPTS_VALUE = -9223372036854775808n;
14687
- var _navPromise = null;
14688
- var _constsPromise = null;
14689
- var getNodeAv = () => _navPromise ??= import("node-av");
14690
- var getConstants = () => _constsPromise ??= import("node-av/constants");
14691
- function ticksToMs(pts, tb) {
14692
- return Math.round(Number(pts) * 1e3 * tb.num / tb.den);
14717
+ /**
14718
+ * ffmpeg arguments to decode one `.m4s` fragment's audio (read from stdin) into
14719
+ * raw 8 kHz mono µ-law bytes on stdout. `-vn` drops video (handled by
14720
+ * `segment-demux`); `-c:a pcm_mulaw -f mulaw` companded output = one byte per
14721
+ * 8 kHz sample. Exported for the spec.
14722
+ */
14723
+ var SEGMENT_AUDIO_FFMPEG_ARGS = [
14724
+ "-hide_banner",
14725
+ "-loglevel",
14726
+ "error",
14727
+ "-i",
14728
+ "pipe:0",
14729
+ "-vn",
14730
+ "-map",
14731
+ "0:a:0",
14732
+ "-ar",
14733
+ "8000",
14734
+ "-ac",
14735
+ "1",
14736
+ "-c:a",
14737
+ "pcm_mulaw",
14738
+ "-f",
14739
+ "mulaw",
14740
+ "pipe:1"
14741
+ ];
14742
+ /**
14743
+ * Slice a contiguous 8 kHz µ-law byte stream (one byte per sample) into fixed
14744
+ * 160-sample (20 ms) PCMU frames, dropping any trailing partial frame. `ptsMs`
14745
+ * is derived from the running frame index — audio is continuous 8 kHz, and each
14746
+ * recorded segment re-anchors its own timeline downstream, so the frames start
14747
+ * at 0 (segment-relative). Pure, so it unit-tests without ffmpeg.
14748
+ */
14749
+ function sliceMuLawToPcmuFrames(mulaw) {
14750
+ const out = [];
14751
+ for (let off = 0, idx = 0; off + FRAME_SAMPLES <= mulaw.length; off += FRAME_SAMPLES, idx++) out.push({
14752
+ data: mulaw.slice(off, off + FRAME_SAMPLES),
14753
+ ptsMs: idx * FRAME_MS
14754
+ });
14755
+ return out;
14693
14756
  }
14694
14757
  /**
14695
- * Demux + transcode one fragment's AAC audio into PCMU access units. Returns []
14696
- * when there is no usable audio stream (the fragment is video-only, or carries a
14697
- * non-AAC audio codec we don't expect from the recorder).
14758
+ * Decode + resample one fragment's audio into PCMU access units. Returns []
14759
+ * when there is no usable audio stream (video-only fragment, or a codec ffmpeg
14760
+ * can't decode) — ffmpeg exits non-zero, which is tolerated as empty output.
14761
+ *
14762
+ * @param bytes - The full `.m4s` fragment.
14763
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
14698
14764
  */
14699
- async function demuxSegmentToPcmu(bytes) {
14700
- const nav = await getNodeAv();
14701
- const C = await getConstants();
14702
- const input = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14703
- const demuxer = await nav.Demuxer.open(input);
14704
- let decode = null;
14705
- try {
14706
- const stream = demuxer.audio() ?? demuxer.findBestStream(C.AVMEDIA_TYPE_AUDIO);
14707
- if (!stream) return [];
14708
- const cp = stream.codecpar;
14709
- const codecName = cp.codecId === C.AV_CODEC_ID_AAC ? "aac" : cp.codecId === C.AV_CODEC_ID_AAC_LATM ? "aac_latm" : null;
14710
- if (codecName === null) return [];
14711
- const sourceChannels = cp.channelLayout?.nbChannels ?? cp.channels ?? 1;
14712
- decode = require_codec_runtime.DecodeRuntime.create({
14713
- codec: codecName,
14714
- sourceSampleRate: cp.sampleRate,
14715
- sourceChannels,
14716
- extraData: cp.extradata ? new Uint8Array(cp.extradata) : void 0,
14717
- targetSampleRate: TARGET_RATE,
14718
- targetChannels: 1,
14719
- targetFormat: "s16le"
14720
- });
14721
- let baseMs = null;
14722
- const muLaw = [];
14723
- for await (const packet of demuxer.packets(stream.index)) {
14724
- if (packet === null) break;
14725
- try {
14726
- const data = packet.data;
14727
- if (data && data.length > 0) {
14728
- if (baseMs === null && packet.pts !== AV_NOPTS_VALUE) baseMs = ticksToMs(packet.pts, stream.timeBase);
14729
- for (const pcm of decode.decode(data)) {
14730
- const enc = encodeMuLaw(pcm.data);
14731
- for (let i = 0; i < enc.length; i++) muLaw.push(enc[i]);
14732
- }
14733
- }
14734
- } finally {
14735
- packet.free();
14736
- }
14737
- }
14738
- if (muLaw.length === 0) return [];
14739
- const base = baseMs ?? 0;
14740
- const out = [];
14741
- for (let off = 0, idx = 0; off + FRAME_SAMPLES <= muLaw.length; off += FRAME_SAMPLES, idx++) out.push({
14742
- data: Uint8Array.from(muLaw.slice(off, off + FRAME_SAMPLES)),
14743
- ptsMs: base + idx * FRAME_MS
14744
- });
14745
- return out;
14746
- } finally {
14747
- decode?.close();
14748
- await demuxer.close();
14749
- }
14765
+ async function demuxSegmentToPcmu(bytes, ffmpegPath = "ffmpeg") {
14766
+ const mulaw = await runFfmpegOnce({
14767
+ ffmpegPath,
14768
+ args: SEGMENT_AUDIO_FFMPEG_ARGS,
14769
+ input: bytes,
14770
+ tolerateNonZeroExit: true
14771
+ });
14772
+ if (mulaw.length === 0) return [];
14773
+ return sliceMuLawToPcmuFrames(mulaw);
14750
14774
  }
14751
14775
  //#endregion
14752
14776
  //#region src/stream-broker/webrtc/broker-webrtc-server.ts
@@ -14931,6 +14955,7 @@ var BrokerWebrtcServer = class {
14931
14955
  resolveTranscodeDecodeHwAccelFn;
14932
14956
  locateRecordedSegment;
14933
14957
  readRecordedSegmentBytes;
14958
+ resolveFfmpegPath;
14934
14959
  /** Cached decode-hwaccel backend for transcode feeds (resolved once). */
14935
14960
  transcodeDecodeHwAccel = void 0;
14936
14961
  /** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
@@ -14974,6 +14999,7 @@ var BrokerWebrtcServer = class {
14974
14999
  this.resolveTranscodeDecodeHwAccelFn = options.resolveTranscodeDecodeHwAccel;
14975
15000
  this.locateRecordedSegment = options.locateRecordedSegment;
14976
15001
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
15002
+ this.resolveFfmpegPath = options.resolveFfmpegPath;
14977
15003
  }
14978
15004
  /**
14979
15005
  * Resolve (once, cached) the decode-hwaccel backend for transcode feeds.
@@ -15489,6 +15515,7 @@ var BrokerWebrtcServer = class {
15489
15515
  session.onClosed = () => this.cleanupSession(sessionId);
15490
15516
  const locateRecorded = this.locateRecordedSegment;
15491
15517
  const readRecorded = this.readRecordedSegmentBytes;
15518
+ const resolveFfmpeg = this.resolveFfmpegPath;
15492
15519
  if (locateRecorded && readRecorded) {
15493
15520
  let timeline;
15494
15521
  timeline = new TimelineSession({
@@ -15499,8 +15526,8 @@ var BrokerWebrtcServer = class {
15499
15526
  profile,
15500
15527
  locate: locateRecorded,
15501
15528
  readBytes: readRecorded,
15502
- demux: demuxSegmentToAnnexB,
15503
- demuxAudio: demuxSegmentToPcmu,
15529
+ demux: (b) => demuxSegmentToAnnexB(b, resolveFfmpeg?.()),
15530
+ demuxAudio: (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.()),
15504
15531
  sink: {
15505
15532
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
15506
15533
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
@@ -16261,6 +16288,10 @@ var BROKER_METRICS_HEARTBEAT_MS = 3e4;
16261
16288
  var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
16262
16289
  var StreamBrokerAddon = class extends require_dist.BaseAddon {
16263
16290
  brokerManager = null;
16291
+ /** ffmpeg binary path from the `ffmpeg` config section, mirrored here so the
16292
+ * recorded-playback demuxers (segment-demux/segment-audio) spawn the same
16293
+ * configured binary as the transcode egress. Kept in sync by loadFfmpegConfig. */
16294
+ ffmpegBinaryPath = "ffmpeg";
16264
16295
  metricsSnapshotTimer = null;
16265
16296
  catalogReconcileTimer = null;
16266
16297
  /** Kept so onShutdown can close every live PeerConnection — releasing
@@ -16321,6 +16352,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
16321
16352
  hwAccel,
16322
16353
  threadCount
16323
16354
  });
16355
+ this.ffmpegBinaryPath = binaryPath;
16324
16356
  this.ctx.logger.info("Loaded ffmpeg config", { meta: {
16325
16357
  binaryPath,
16326
16358
  hwAccel,
@@ -16583,7 +16615,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
16583
16615
  },
16584
16616
  resolveTranscodeDecodeHwAccel: () => this.resolveTranscodeDecodeHwAccel(),
16585
16617
  locateRecordedSegment: (args) => this.ctx.api.recording.locateSegment.query(args),
16586
- readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data
16618
+ readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data,
16619
+ resolveFfmpegPath: () => this.ffmpegBinaryPath
16587
16620
  });
16588
16621
  this.brokerManager.setWebrtcServer(webrtcServer);
16589
16622
  this.webrtcServer = webrtcServer;
@@ -16867,27 +16900,6 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
16867
16900
  }
16868
16901
  };
16869
16902
  //#endregion
16870
- //#region src/stream-broker/stream-broker/frame-dropper.ts
16871
- var FrameDropper = class {
16872
- intervalMs;
16873
- lastPassedAt = -Infinity;
16874
- constructor(maxFps) {
16875
- this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
16876
- }
16877
- shouldKeep() {
16878
- if (this.intervalMs === 0) return true;
16879
- const now = Date.now();
16880
- if (now - this.lastPassedAt >= this.intervalMs) {
16881
- this.lastPassedAt = now;
16882
- return true;
16883
- }
16884
- return false;
16885
- }
16886
- setMaxFps(maxFps) {
16887
- this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
16888
- }
16889
- };
16890
- //#endregion
16891
16903
  //#region src/stream-broker/stream-broker/ffmpeg-decoder-session.ts
16892
16904
  /** Minimal no-op logger for default parameter */
16893
16905
  var noopLogger = {
@@ -16915,7 +16927,7 @@ function codecToInputFormat(codec) {
16915
16927
  function buildFfmpegArgs(config) {
16916
16928
  const inputFormat = codecToInputFormat(config.codec);
16917
16929
  const args = [
16918
- ...logBannerArgs("error"),
16930
+ ...require_ffmpeg_args_common.logBannerArgs("error"),
16919
16931
  "-fflags",
16920
16932
  "+nobuffer+flush_packets",
16921
16933
  "-flags",
@@ -16952,7 +16964,7 @@ var FfmpegDecoderSession = class {
16952
16964
  constructor(config, logger = noopLogger) {
16953
16965
  this.config = { ...config };
16954
16966
  this.logger = logger;
16955
- this.frameDropper = new FrameDropper(config.maxFps);
16967
+ this.frameDropper = new require_ffmpeg_args_common.FrameDropper(config.maxFps);
16956
16968
  this.spawnFfmpeg();
16957
16969
  }
16958
16970
  spawnFfmpeg() {