@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
@@ -1,7 +1,7 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { A as nodePin, B as BaseAddon, D as maskUrlCredentials, G as asJsonObject, H as DeviceFeature, I as streamBrokerCapability, Q as parseProfileBrokerId, R as webrtcSessionCapability, U as DeviceType, V as CAM_PROFILE_ORDER, W as EventCategory, X as makeSourceBrokerId, Y as makeProfileBrokerId, c as EncodeProfileSchema, ct as object, dt as union, f as RingBuffer, it as discriminatedUnion, lt as record, m as addonWidgetsSourceCapability, ot as literal, q as createEvent, rt as boolean, st as number, tt as _enum, ut as string, v as cameraStreamsCapability, z as errMsg } from "../dist-CBcVKDyT.mjs";
2
+ import { A as nodePin, B as BaseAddon, D as maskUrlCredentials, G as asJsonObject, H as DeviceFeature, I as streamBrokerCapability, Q as parseProfileBrokerId, R as webrtcSessionCapability, U as DeviceType, V as CAM_PROFILE_ORDER, W as EventCategory, X as makeSourceBrokerId, Y as makeProfileBrokerId, c as EncodeProfileSchema, ct as object, dt as union, f as RingBuffer, it as discriminatedUnion, lt as record, m as addonWidgetsSourceCapability, ot as literal, q as createEvent, rt as boolean, st as number, tt as _enum, ut as string, v as cameraStreamsCapability, z as errMsg } from "../dist-tzhTTRq4.mjs";
3
3
  import { t as createFileDataPlaneHandler } from "../model-download-service-C-IHWnXx-3Mmeob3l.mjs";
4
- import { t as DecodeRuntime } from "../codec-runtime-BsqlEjPi.mjs";
4
+ import { n as logBannerArgs, r as FrameDropper, t as audioEncoderArgs } from "../ffmpeg-args-common-c3p-nWtU.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as fs from "node:fs";
7
7
  import * as path$1 from "node:path";
@@ -304,6 +304,31 @@ var DecoderSessionProxy = class {
304
304
  * broker supplies a `pullModeUrl` and the plane calls `openStream` once the
305
305
  * session exists.
306
306
  */
307
+ /**
308
+ * Collapse a hierarchical Moleculer nodeID (`hub/child-abc`) to its owning
309
+ * node (`hub`). Frame-plane co-location only cares about the physical node, not
310
+ * which forked child within it hosts the session.
311
+ */
312
+ function collapseNodeId(nodeId) {
313
+ const slash = nodeId.indexOf("/");
314
+ return slash >= 0 ? nodeId.slice(0, slash) : nodeId;
315
+ }
316
+ /**
317
+ * Decide whether a decoder session created on `decoderNodeId` is co-located
318
+ * with the broker whose local node is `brokerNodeId`.
319
+ *
320
+ * The broker's shm frame ring is NODE-LOCAL: a decoder session on any other
321
+ * node can never deliver frame handles to this broker's consumers, so a
322
+ * mis-placed session must be rejected. Both ids are collapsed (`hub/child`→
323
+ * `hub`) before comparison.
324
+ *
325
+ * A `null` `brokerNodeId` means the broker's own node is unknown — enforcement
326
+ * is skipped (legacy behavior, accept any placement).
327
+ */
328
+ function isDecoderNodeColocated(brokerNodeId, decoderNodeId) {
329
+ if (brokerNodeId === null) return true;
330
+ return collapseNodeId(brokerNodeId) === collapseNodeId(decoderNodeId);
331
+ }
307
332
  /** Per-subscription handle-queue capacity. A handle is tiny; 64 absorbs a
308
333
  * burst between two `pullFrameHandles` polls without unbounded growth. */
309
334
  var HANDLE_QUEUE_CAPACITY = 64;
@@ -320,6 +345,7 @@ var FrameHandlePlane = class {
320
345
  decoderApi;
321
346
  logger;
322
347
  resolveStreamInfo;
348
+ localNodeId;
323
349
  subscriptions = /* @__PURE__ */ new Map();
324
350
  sessions = /* @__PURE__ */ new Map();
325
351
  disposed = false;
@@ -335,6 +361,7 @@ var FrameHandlePlane = class {
335
361
  this.decoderApi = options.decoderApi;
336
362
  this.logger = options.logger;
337
363
  this.resolveStreamInfo = options.resolveStreamInfo;
364
+ this.localNodeId = options.localNodeId;
338
365
  }
339
366
  /** Number of active handle subscriptions — surfaced for diagnostics. */
340
367
  get subscriberCount() {
@@ -635,6 +662,22 @@ var FrameHandlePlane = class {
635
662
  ...info.numericDeviceId !== void 0 ? { deviceId: info.numericDeviceId } : {},
636
663
  tag: `${info.tag}:shm:${format}`
637
664
  });
665
+ if (!isDecoderNodeColocated(this.localNodeId, nodeId)) {
666
+ this.logger?.warn("frame-handle plane: decoder session landed on a non-local node — destroying (shm ring is node-local)", { meta: {
667
+ format,
668
+ requestedNode: this.localNodeId,
669
+ gotNode: nodeId,
670
+ sessionId
671
+ } });
672
+ await this.decoderApi.destroySession({ sessionId }).catch((err) => {
673
+ this.logger?.warn("frame-handle plane: failed to destroy mis-placed decoder session", { meta: {
674
+ format,
675
+ sessionId,
676
+ error: errMsg(err)
677
+ } });
678
+ });
679
+ return false;
680
+ }
638
681
  const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
639
682
  session.proxy = proxy;
640
683
  session.nodeId = nodeId;
@@ -4079,6 +4122,13 @@ var StreamBroker = class StreamBroker {
4079
4122
  decoderApi;
4080
4123
  audioCodecApi;
4081
4124
  logger;
4125
+ /**
4126
+ * This broker's OWN local Moleculer node id, threaded from the manager
4127
+ * (`ctx.kernel.localNodeId`). Passed to the `FrameHandlePlane` so it can
4128
+ * reject decoder sessions that land on a different node (the shm frame ring
4129
+ * is node-local). `null` = unknown → co-location enforcement skipped.
4130
+ */
4131
+ localNodeId;
4082
4132
  startedAt = Date.now();
4083
4133
  nativeClient = null;
4084
4134
  rfc4571Reader = null;
@@ -4265,11 +4315,12 @@ var StreamBroker = class StreamBroker {
4265
4315
  static DEFAULT_PRE_BUFFER_SEC = 10;
4266
4316
  /** Maximum allowed pre-buffer duration in seconds */
4267
4317
  static MAX_PRE_BUFFER_SEC = 30;
4268
- constructor(deviceId, decoderApi, logger, audioCodecApi) {
4318
+ constructor(deviceId, decoderApi, logger, audioCodecApi, localNodeId) {
4269
4319
  this.deviceId = deviceId;
4270
4320
  this.decoderApi = decoderApi;
4271
4321
  this.audioCodecApi = audioCodecApi ?? null;
4272
4322
  this.logger = logger;
4323
+ this.localNodeId = localNodeId ?? null;
4273
4324
  this.pipeServer = new StreamPipeServer(logger?.child("pipe-server"));
4274
4325
  this.rtspRestreamer = new RtspRestreamer(deviceId);
4275
4326
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
@@ -5015,7 +5066,8 @@ var StreamBroker = class StreamBroker {
5015
5066
  this.frameHandlePlane = new FrameHandlePlane({
5016
5067
  decoderApi: this.decoderApi,
5017
5068
  logger: this.logger?.child("frame-handle-plane"),
5018
- resolveStreamInfo: () => this.resolveFrameHandleStreamInfo()
5069
+ resolveStreamInfo: () => this.resolveFrameHandleStreamInfo(),
5070
+ localNodeId: this.localNodeId
5019
5071
  });
5020
5072
  return this.frameHandlePlane;
5021
5073
  }
@@ -6080,6 +6132,10 @@ var RtspSession = class {
6080
6132
  lastRtpAt = 0;
6081
6133
  /** Total video RTP bytes written to the socket. */
6082
6134
  bytesSent = 0;
6135
+ /** Client `User-Agent` (from the first request that carries one). Lets the
6136
+ * broker panel identify a consumer by purpose — e.g. the recorder sets
6137
+ * `-user_agent recorder` on its RTSP pull — instead of a bare address. */
6138
+ userAgent = null;
6083
6139
  /** When true, this session receives video-only (no audio RTP). */
6084
6140
  muted;
6085
6141
  constructor(socket, sdp, onClose, muted) {
@@ -6123,6 +6179,7 @@ var RtspSession = class {
6123
6179
  return {
6124
6180
  sessionId: this.sessionId,
6125
6181
  remoteAddr: `${remoteIp}:${remotePort}`,
6182
+ userAgent: this.userAgent,
6126
6183
  playing: this.isPlaying(),
6127
6184
  muted: this.muted,
6128
6185
  connectedAt: this.connectedAt,
@@ -6189,7 +6246,10 @@ var RtspSession = class {
6189
6246
  const requestText = this.buffer.slice(0, endIdx);
6190
6247
  this.buffer = this.buffer.slice(endIdx + 4);
6191
6248
  const request = this.parseRequest(requestText);
6192
- if (request) this.handleRequest(request);
6249
+ if (request) {
6250
+ if (this.userAgent === null) this.userAgent = request.headers.get("user-agent") ?? null;
6251
+ this.handleRequest(request);
6252
+ }
6193
6253
  }
6194
6254
  }
6195
6255
  parseRequest(text) {
@@ -6581,35 +6641,6 @@ var RtspRestreamProviderImpl = class {
6581
6641
  }
6582
6642
  };
6583
6643
  //#endregion
6584
- //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
6585
- var AUDIO_ENCODER_BY_CODEC = {
6586
- opus: "libopus",
6587
- aac: "aac",
6588
- pcmu: "pcm_mulaw",
6589
- pcma: "pcm_alaw",
6590
- copy: "copy"
6591
- };
6592
- /**
6593
- * Build the audio-encode args `-c:a <enc> [-b:a Nk] [-ar N] [-ac N]`. Each knob
6594
- * is emitted only when provided, in this fixed order — matching what every
6595
- * broker site already emitted by hand.
6596
- */
6597
- function audioEncoderArgs(codec, opts = {}) {
6598
- const args = ["-c:a", AUDIO_ENCODER_BY_CODEC[codec]];
6599
- if (opts.bitrateKbps !== void 0) args.push("-b:a", `${opts.bitrateKbps}k`);
6600
- if (opts.sampleRateHz !== void 0) args.push("-ar", String(opts.sampleRateHz));
6601
- if (opts.channels !== void 0) args.push("-ac", String(opts.channels));
6602
- return args;
6603
- }
6604
- /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
6605
- function logBannerArgs(level) {
6606
- return [
6607
- "-hide_banner",
6608
- "-loglevel",
6609
- level
6610
- ];
6611
- }
6612
- //#endregion
6613
6644
  //#region src/stream-broker/stream-broker/ffmpeg-invocation.ts
6614
6645
  /** The full set of hwaccel settings (for validation + option surfaces). */
6615
6646
  var FFMPEG_HW_ACCEL_SETTINGS = [
@@ -9639,7 +9670,7 @@ var StreamBrokerManager = class {
9639
9670
  camStreamId
9640
9671
  });
9641
9672
  const source = this.buildSourceFromCamStream(cam);
9642
- const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api));
9673
+ const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
9643
9674
  broker.setRestreamers(this.restreamers);
9644
9675
  this.applyPreBufferConfig(broker, deviceId, camStreamId, cam);
9645
9676
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
@@ -14274,475 +14305,468 @@ var TimelineSession = class {
14274
14305
  }
14275
14306
  };
14276
14307
  //#endregion
14277
- //#region src/stream-broker/recorded/segment-demux.ts
14308
+ //#region src/stream-broker/recorded/ffmpeg-run-once.ts
14278
14309
  /**
14279
- * Pure selector: map a node-av video `codecId` to its mp4toannexb bitstream
14280
- * filter name and the `AccessUnit.codec` tag. Both BSFs prepend the stream's
14281
- * parameter sets to keyframes (SPS/PPS for H.264; VPS/SPS/PPS for H.265).
14310
+ * ffmpeg-run-once spawn ffmpeg for ONE batch conversion: feed an in-memory
14311
+ * input buffer on stdin, collect the whole stdout, resolve when the process
14312
+ * exits. Used by the recorded-playback demuxers (`segment-demux`,
14313
+ * `segment-audio`) which turn a single self-contained `.m4s` fragment into
14314
+ * Annex-B / µ-law bytes in a crash-isolated subprocess (replacing the old
14315
+ * in-process `node-av` binding).
14282
14316
  *
14283
- * `h264Id`/`hevcId` are injected (from `node-av/constants`
14284
- * `AV_CODEC_ID_H264`=27 / `AV_CODEC_ID_HEVC`=173) so this is unit-testable
14285
- * without loading the FFmpeg binding. Throws on any other codec.
14317
+ * Unlike the live streaming sessions (`decoder-ffmpeg`, `audio-codec-ffmpeg`)
14318
+ * this is fire-once: there is no persistent stdin feed. stdin is written and
14319
+ * closed up-front so ffmpeg reads the whole fragment, transcodes, flushes, and
14320
+ * exits; we buffer stdout and resolve with it.
14321
+ *
14322
+ * The kill sequence copies the CORRECTED gating from `decoder-ffmpeg`/
14323
+ * `audio-codec-ffmpeg`: SIGTERM then escalated SIGKILL gated on
14324
+ * `exitCode === null && signalCode === null` (NOT on `child.killed`, which Node
14325
+ * sets true after ANY kill() so `!child.killed` never fires → leaked ffmpeg).
14286
14326
  */
14287
- function bsfNameForCodecId(codecId, h264Id, hevcId) {
14288
- if (codecId === h264Id) return {
14289
- bsf: "h264_mp4toannexb",
14290
- codec: "H264"
14291
- };
14292
- if (codecId === hevcId) return {
14293
- bsf: "hevc_mp4toannexb",
14294
- codec: "H265"
14295
- };
14296
- throw new Error(`segment-demux: unsupported video codec id ${codecId} (expected H264=${h264Id} or HEVC=${hevcId})`);
14297
- }
14327
+ /** Hard ceiling for one fragment conversion; a ~4 s GOP transcodes in well
14328
+ * under this even on a slow agent. Prevents a wedged ffmpeg from leaking. */
14329
+ var RUN_TIMEOUT_MS = 8e3;
14330
+ /** Grace period between SIGTERM and the escalated SIGKILL. */
14331
+ var KILL_GRACE_MS = 500;
14298
14332
  /**
14299
- * `AV_NOPTS_VALUE` (INT64_MIN) FFmpeg's sentinel for "no timestamp". A packet
14300
- * carrying this in `pts`/`dts` has no usable presentation time; converting it
14301
- * naively yields a garbage ~-9.2e18 ms.
14333
+ * Run ffmpeg once over `input` and resolve with the collected stdout bytes.
14334
+ *
14335
+ * @throws If ffmpeg cannot be spawned, times out, or exits non-zero (unless
14336
+ * `tolerateNonZeroExit` is set).
14302
14337
  */
14303
- var AV_NOPTS_VALUE$1 = -9223372036854775808n;
14304
- var _navPromise$1 = null;
14305
- var _constsPromise$1 = null;
14306
- function getNodeAv$1() {
14307
- return _navPromise$1 ??= import("node-av");
14308
- }
14309
- function getConstants$1() {
14310
- return _constsPromise$1 ??= import("node-av/constants");
14311
- }
14312
- /** Convert a presentation timestamp in stream ticks to milliseconds. */
14313
- function ticksToMs$1(ptsTicks, timeBase) {
14314
- return Math.round(Number(ptsTicks) * 1e3 * timeBase.num / timeBase.den);
14338
+ function runFfmpegOnce(options) {
14339
+ const { ffmpegPath, args, input, tolerateNonZeroExit = false } = options;
14340
+ return new Promise((resolve, reject) => {
14341
+ const chunks = [];
14342
+ const stderrTail = [];
14343
+ let settled = false;
14344
+ const child = (() => {
14345
+ try {
14346
+ return spawn(ffmpegPath, [...args]);
14347
+ } catch (err) {
14348
+ reject(/* @__PURE__ */ new Error(`ffmpeg spawn failed: ${errMsg(err)}`));
14349
+ return null;
14350
+ }
14351
+ })();
14352
+ if (child === null) return;
14353
+ const timer = setTimeout(() => {
14354
+ if (settled) return;
14355
+ settle(() => reject(/* @__PURE__ */ new Error("ffmpeg-run-once: timed out")));
14356
+ kill();
14357
+ }, RUN_TIMEOUT_MS);
14358
+ timer.unref?.();
14359
+ const settle = (fn) => {
14360
+ if (settled) return;
14361
+ settled = true;
14362
+ clearTimeout(timer);
14363
+ fn();
14364
+ };
14365
+ const kill = () => {
14366
+ try {
14367
+ child.kill("SIGTERM");
14368
+ } catch {}
14369
+ setTimeout(() => {
14370
+ if (child.exitCode === null && child.signalCode === null) try {
14371
+ child.kill("SIGKILL");
14372
+ } catch {}
14373
+ }, KILL_GRACE_MS).unref?.();
14374
+ };
14375
+ child.stdin?.on("error", () => {});
14376
+ child.stdout?.on("data", (chunk) => chunks.push(chunk));
14377
+ child.stderr?.on("data", (data) => {
14378
+ const line = data.toString().trim();
14379
+ if (line) {
14380
+ stderrTail.push(line);
14381
+ if (stderrTail.length > 8) stderrTail.shift();
14382
+ }
14383
+ });
14384
+ child.on("error", (err) => {
14385
+ settle(() => reject(/* @__PURE__ */ new Error(`ffmpeg process error: ${err.message}`)));
14386
+ });
14387
+ child.on("exit", (code, signal) => {
14388
+ const stdout = Buffer.concat(chunks);
14389
+ if (code === 0 || tolerateNonZeroExit) {
14390
+ settle(() => resolve(stdout));
14391
+ return;
14392
+ }
14393
+ settle(() => reject(/* @__PURE__ */ new Error(`ffmpeg exited code=${code} signal=${signal}: ${stderrTail.join(" | ") || "(no stderr)"}`)));
14394
+ });
14395
+ try {
14396
+ child.stdin?.write(Buffer.from(input.buffer, input.byteOffset, input.byteLength));
14397
+ child.stdin?.end();
14398
+ } catch {}
14399
+ });
14315
14400
  }
14401
+ //#endregion
14402
+ //#region src/stream-broker/recorded/ts-annexb-demux.ts
14316
14403
  /**
14317
- * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
14318
- * access units.
14404
+ * ts-annexb-demux pure MPEG-TS ordered Annex-B access units.
14319
14405
  *
14320
- * The video codec is detected from the segment's video stream `codecId`; the
14321
- * matching mp4toannexb bitstream filter prepends parameter sets to every IDR
14322
- * (SPS/PPS for H.264, VPS/SPS/PPS for H.265) no manual `avcC`/`hvcC` parsing.
14323
- * Each returned {@link AccessUnit} owns a detached copy of the packet bytes —
14324
- * the underlying `node-av` packets are freed before this resolves, so the
14325
- * result is safe to retain. Throws on a non-H.264/H.265 video codec.
14406
+ * Companion to {@link demuxSegmentToAnnexB}: `segment-demux` runs one `.m4s`
14407
+ * fragment through `ffmpeg -c:v copy -f mpegts` (crash-isolated subprocess,
14408
+ * replacing the old in-process `node-av` demux) and hands the resulting
14409
+ * transport stream here to be split back into per-frame access units.
14326
14410
  *
14327
- * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
14328
- * @returns Access units in source presentation order, each tagged with its codec.
14411
+ * Why MPEG-TS as the intermediate container:
14412
+ * - it preserves EXACTLY the two things the recorded-playback feeder needs
14413
+ * that a raw Annex-B elementary stream (`-f h264/hevc`) throws away — the
14414
+ * per-frame presentation timestamp (PES `PTS`, 90 kHz) and the frame
14415
+ * boundaries (one video PES per access unit, marked by `payload_unit_start`);
14416
+ * - the ffmpeg mpegts muxer FUSES the parameter sets onto every random-access
14417
+ * point exactly like the `h264/hevc_mp4toannexb` bitstream filter did — the
14418
+ * IDR PES carries `AUD` + `VPS/SPS/PPS` (H.265) or `AUD` + `SPS/PPS`
14419
+ * (H.264) ahead of the IDR slice — so no explicit BSF is needed and the
14420
+ * downstream pusher's "one AU per frame, params on keyframes" contract holds;
14421
+ * - the codec is read straight from the PMT `stream_type` (`0x1B` → H.264,
14422
+ * `0x24` → H.265), so no separate probe pass is required.
14423
+ *
14424
+ * This module is intentionally pure (a `Uint8Array` in, access units out) so it
14425
+ * unit-tests against a committed `.ts` fixture with no ffmpeg/native binding.
14329
14426
  */
14330
- async function demuxSegmentToAnnexB(bytes) {
14331
- const nav = await getNodeAv$1();
14332
- const C = await getConstants$1();
14333
- const input = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14334
- const demuxer = await nav.Demuxer.open(input);
14335
- let bsf = null;
14336
- try {
14337
- const videoStream = demuxer.video() ?? demuxer.findBestStream(C.AVMEDIA_TYPE_VIDEO);
14338
- if (!videoStream) throw new Error("segment-demux: no video stream found in fragment");
14339
- const timeBase = videoStream.timeBase;
14340
- const { bsf: bsfName, codec } = bsfNameForCodecId(videoStream.codecpar.codecId, C.AV_CODEC_ID_H264, C.AV_CODEC_ID_HEVC);
14341
- bsf = nav.BitStreamFilterAPI.create(bsfName, videoStream);
14342
- const accessUnits = [];
14343
- for await (const packet of bsf.packets(demuxer.packets(videoStream.index))) {
14344
- if (packet === null) break;
14345
- try {
14346
- const data = packet.data;
14347
- if (data && data.length > 0) {
14348
- const copy = new Uint8Array(data.length);
14349
- copy.set(data);
14350
- const rawPts = packet.pts !== AV_NOPTS_VALUE$1 ? packet.pts : packet.dts !== AV_NOPTS_VALUE$1 ? packet.dts : 0n;
14351
- accessUnits.push({
14352
- data: copy,
14353
- ptsMs: ticksToMs$1(rawPts, timeBase),
14354
- keyframe: packet.isKeyframe,
14355
- codec
14356
- });
14357
- }
14358
- } finally {
14359
- packet.free();
14427
+ /** MPEG-TS transport packet size. */
14428
+ var TS_PACKET_SIZE = 188;
14429
+ /** Transport packet sync byte. */
14430
+ var TS_SYNC_BYTE = 71;
14431
+ /** The PAT always lives on PID 0. */
14432
+ var PAT_PID = 0;
14433
+ /** PMT `stream_type` for H.264 (AVC) video. */
14434
+ var STREAM_TYPE_H264 = 27;
14435
+ /** PMT `stream_type` for H.265 (HEVC) video. */
14436
+ var STREAM_TYPE_H265 = 36;
14437
+ /** Map a PMT `stream_type` to the AccessUnit codec tag, or null if not video. */
14438
+ function codecForStreamType(streamType) {
14439
+ if (streamType === STREAM_TYPE_H264) return "H264";
14440
+ if (streamType === STREAM_TYPE_H265) return "H265";
14441
+ return null;
14442
+ }
14443
+ /**
14444
+ * Detect whether an Annex-B access unit is a keyframe by scanning its NAL units:
14445
+ * H.264 IDR (type 5) or H.265 IRAP (types 16–21). Mirrors
14446
+ * `AnnexBDeframer.isKeyframeNal`; params/AUD NALs ahead of the slice are ignored.
14447
+ */
14448
+ function isKeyframeAccessUnit(au, codec) {
14449
+ for (const nalStart of iterateNalStarts(au)) {
14450
+ const header = au[nalStart];
14451
+ if (header === void 0) continue;
14452
+ if (codec === "H264") {
14453
+ if ((header & 31) === 5) return true;
14454
+ } else {
14455
+ const type = header >> 1 & 63;
14456
+ if (type >= 16 && type <= 21) return true;
14457
+ }
14458
+ }
14459
+ return false;
14460
+ }
14461
+ /**
14462
+ * Yield the byte offset of the FIRST byte of each NAL unit (i.e. just past its
14463
+ * Annex-B start code, `00 00 01` or `00 00 00 01`) in `buf`.
14464
+ */
14465
+ function* iterateNalStarts(buf) {
14466
+ const len = buf.length;
14467
+ let i = 0;
14468
+ while (i + 2 < len) {
14469
+ if (buf[i] === 0 && buf[i + 1] === 0) {
14470
+ if (buf[i + 2] === 1) {
14471
+ yield i + 3;
14472
+ i += 3;
14473
+ continue;
14474
+ }
14475
+ if (buf[i + 2] === 0 && buf[i + 3] === 1) {
14476
+ yield i + 4;
14477
+ i += 4;
14478
+ continue;
14360
14479
  }
14361
14480
  }
14362
- return accessUnits;
14363
- } finally {
14364
- bsf?.close();
14365
- await demuxer.close();
14481
+ i++;
14366
14482
  }
14367
14483
  }
14484
+ /**
14485
+ * Read the 33-bit PES `PTS` (or DTS) from the 5 bytes at `off` and return it in
14486
+ * 90 kHz ticks. The layout interleaves marker bits every byte
14487
+ * (`0010 PPP1 PPPPPPPP PPPPPPP1 PPPPPPPP PPPPPPP1`).
14488
+ */
14489
+ function readPts33(buf, off) {
14490
+ const b0 = buf[off] ?? 0;
14491
+ const b1 = buf[off + 1] ?? 0;
14492
+ const b2 = buf[off + 2] ?? 0;
14493
+ const b3 = buf[off + 3] ?? 0;
14494
+ const b4 = buf[off + 4] ?? 0;
14495
+ const hi = b0 >> 1 & 7;
14496
+ const mid = b1 << 7 | b2 >> 1;
14497
+ const lo = b3 << 7 | b4 >> 1;
14498
+ return hi * 1073741824 + mid * 32768 + lo;
14499
+ }
14500
+ /** Locate the payload offset within a TS packet, skipping any adaptation field. */
14501
+ function payloadOffset(pkt, base) {
14502
+ const adaptationFieldControl = pkt[base + 3] >> 4 & 3;
14503
+ let off = base + 4;
14504
+ if (adaptationFieldControl & 2) off += 1 + pkt[off];
14505
+ return off;
14506
+ }
14507
+ /** Whether a TS packet's adaptation_field_control indicates a payload is present. */
14508
+ function hasPayload(pkt, base) {
14509
+ const afc = pkt[base + 3] >> 4 & 3;
14510
+ return afc === 1 || afc === 3;
14511
+ }
14512
+ /** Parse the PAT payload → the first program's PMT PID. */
14513
+ function parsePatForPmtPid(payload) {
14514
+ const p = 1 + payload[0] + 8;
14515
+ if (p + 3 >= payload.length) return null;
14516
+ return (payload[p + 2] & 31) << 8 | payload[p + 3];
14517
+ }
14518
+ /** Parse the PMT payload → the first H.264/H.265 elementary stream's PID + codec. */
14519
+ function parsePmt(payload) {
14520
+ const p = 1 + payload[0];
14521
+ const sectionLength = (payload[p + 1] & 15) << 8 | payload[p + 2];
14522
+ const programInfoLength = (payload[p + 10] & 15) << 8 | payload[p + 11];
14523
+ let es = p + 12 + programInfoLength;
14524
+ const sectionEnd = p + 3 + sectionLength - 4;
14525
+ while (es + 4 < sectionEnd && es + 4 < payload.length) {
14526
+ const streamType = payload[es];
14527
+ const elementaryPid = (payload[es + 1] & 31) << 8 | payload[es + 2];
14528
+ const esInfoLength = (payload[es + 3] & 15) << 8 | payload[es + 4];
14529
+ const codec = codecForStreamType(streamType);
14530
+ if (codec !== null) return {
14531
+ videoPid: elementaryPid,
14532
+ codec
14533
+ };
14534
+ es += 5 + esInfoLength;
14535
+ }
14536
+ return null;
14537
+ }
14538
+ /** Split one reassembled PES packet into its Annex-B elementary-stream payload
14539
+ * and 90 kHz PTS (null when the PES carries no PTS flag). */
14540
+ function splitPes(pes) {
14541
+ if (pes.length < 9 || pes[0] !== 0 || pes[1] !== 0 || pes[2] !== 1) return null;
14542
+ const ptsDtsFlags = pes[7] >> 6 & 3;
14543
+ const headerDataLength = pes[8];
14544
+ return {
14545
+ es: pes.subarray(9 + headerDataLength),
14546
+ pts: ptsDtsFlags & 2 ? readPts33(pes, 9) : null
14547
+ };
14548
+ }
14549
+ /**
14550
+ * Demux an MPEG-TS byte stream (as produced by `ffmpeg -c:v copy -f mpegts`)
14551
+ * into ordered Annex-B access units, one per video PES.
14552
+ *
14553
+ * @param ts - The full transport stream.
14554
+ * @returns The detected codec and its access units in transport (decode) order.
14555
+ * @throws If no H.264/H.265 video elementary stream is present.
14556
+ */
14557
+ function demuxMpegTsToAnnexB(ts) {
14558
+ let pmtPid = null;
14559
+ let pmt = null;
14560
+ for (let i = 0; i + TS_PACKET_SIZE <= ts.length; i += TS_PACKET_SIZE) {
14561
+ const pkt = ts.subarray(i, i + TS_PACKET_SIZE);
14562
+ if (pkt[0] !== TS_SYNC_BYTE) continue;
14563
+ const pid = (pkt[1] & 31) << 8 | pkt[2];
14564
+ if (!((pkt[1] & 64) !== 0) || !hasPayload(pkt, 0)) continue;
14565
+ const payload = pkt.subarray(payloadOffset(pkt, 0));
14566
+ if (pid === PAT_PID) pmtPid ??= parsePatForPmtPid(payload);
14567
+ else if (pmtPid !== null && pid === pmtPid && pmt === null) pmt = parsePmt(payload);
14568
+ }
14569
+ if (pmt === null) throw new Error("ts-annexb-demux: no H.264/H.265 video stream found in transport stream");
14570
+ const { videoPid, codec } = pmt;
14571
+ const accessUnits = [];
14572
+ let current = null;
14573
+ const finalize = () => {
14574
+ if (current === null) return;
14575
+ const pes = current.length === 1 ? current[0] : concat(current);
14576
+ current = null;
14577
+ const split = splitPes(pes);
14578
+ if (split === null || split.es.length === 0) return;
14579
+ const data = new Uint8Array(split.es.length);
14580
+ data.set(split.es);
14581
+ accessUnits.push({
14582
+ data,
14583
+ ptsMs: split.pts === null ? 0 : Math.round(split.pts / 90),
14584
+ keyframe: isKeyframeAccessUnit(data, codec)
14585
+ });
14586
+ };
14587
+ for (let i = 0; i + TS_PACKET_SIZE <= ts.length; i += TS_PACKET_SIZE) {
14588
+ const pkt = ts.subarray(i, i + TS_PACKET_SIZE);
14589
+ if (pkt[0] !== TS_SYNC_BYTE) continue;
14590
+ if (((pkt[1] & 31) << 8 | pkt[2]) !== videoPid) continue;
14591
+ if (!hasPayload(pkt, 0)) continue;
14592
+ const payloadStart = (pkt[1] & 64) !== 0;
14593
+ const payload = pkt.subarray(payloadOffset(pkt, 0));
14594
+ if (payloadStart) {
14595
+ finalize();
14596
+ current = [Uint8Array.from(payload)];
14597
+ } else if (current !== null) current.push(Uint8Array.from(payload));
14598
+ }
14599
+ finalize();
14600
+ return {
14601
+ codec,
14602
+ accessUnits
14603
+ };
14604
+ }
14605
+ /** Concatenate TS payload fragments into one contiguous PES buffer. */
14606
+ function concat(parts) {
14607
+ let total = 0;
14608
+ for (const p of parts) total += p.length;
14609
+ const out = new Uint8Array(total);
14610
+ let off = 0;
14611
+ for (const p of parts) {
14612
+ out.set(p, off);
14613
+ off += p.length;
14614
+ }
14615
+ return out;
14616
+ }
14368
14617
  //#endregion
14369
- //#region src/stream-broker/recorded/pcm-to-mulaw.ts
14618
+ //#region src/stream-broker/recorded/segment-demux.ts
14370
14619
  /**
14371
- * G.711 µ-law (PCMU) encode pure, no native deps.
14620
+ * segment-demux turn one self-contained `.m4s` fragment (a Buffer) into
14621
+ * ordered Annex-B access units. Codec-agnostic: handles BOTH H.264 and H.265
14622
+ * recordings (the recorder is `-c:v copy` passthrough, so an H.264 camera
14623
+ * produces H.264 recordings and an H.265 camera produces H.265 recordings).
14372
14624
  *
14373
- * The WebRTC audio sender negotiates `audio/PCMU` (8 kHz). Recorded audio is
14374
- * stored as AAC, decoded to 8 kHz mono signed-16 PCM, then companded to µ-law
14375
- * here for the same wire format the live path uses.
14625
+ * The recorder writes self-contained fragments: each `.m4s` carries its own
14626
+ * `moov` (and thus its own `avcC`/`hvcC` parameter sets) plus exactly one
14627
+ * IDR-led GOP (~4s). To replay one over a push pipeline we need the bytes as
14628
+ * Annex-B access units with parameter sets prepended to every IDR (SPS/PPS for
14629
+ * H.264; VPS/SPS/PPS for H.265) and each unit tagged with its source PTS.
14376
14630
  *
14377
- * Standard ITU-T G.711 µ-law: bias 0x84, clip 32635, exponent table.
14631
+ * Implementation: run the fragment through `ffmpeg -c:v copy -f mpegts` (a
14632
+ * crash-isolated subprocess — this replaced the old in-process `node-av`
14633
+ * demux + `h264/hevc_mp4toannexb` BSF, which could SIGBUS the whole broker on
14634
+ * a fragile stream), then split the transport stream back into access units
14635
+ * with {@link demuxMpegTsToAnnexB}. The mpegts muxer fuses the parameter sets
14636
+ * onto every IDR exactly like the mp4toannexb BSF did, preserves the per-frame
14637
+ * PTS (PES 90 kHz), and exposes the codec via the PMT `stream_type` — so no
14638
+ * explicit bitstream filter and no separate probe pass are needed. See
14639
+ * `ts-annexb-demux.ts` for the container-choice rationale.
14378
14640
  */
14379
- var BIAS = 132;
14380
- var CLIP = 32635;
14381
- var EXP_LUT = [
14382
- 0,
14383
- 0,
14384
- 1,
14385
- 1,
14386
- 2,
14387
- 2,
14388
- 2,
14389
- 2,
14390
- 3,
14391
- 3,
14392
- 3,
14393
- 3,
14394
- 3,
14395
- 3,
14396
- 3,
14397
- 3,
14398
- 4,
14399
- 4,
14400
- 4,
14401
- 4,
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
- 5,
14415
- 5,
14416
- 5,
14417
- 5,
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
- 6,
14447
- 6,
14448
- 6,
14449
- 6,
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
- 7,
14511
- 7,
14512
- 7,
14513
- 7,
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
14641
+ /**
14642
+ * ffmpeg arguments to remux one `.m4s` fragment (read from stdin) into an
14643
+ * Annex-B MPEG-TS on stdout. `-c:v copy` = no re-encode; the mpegts muxer does
14644
+ * the mp4→annexb conversion and fuses parameter sets onto every IDR. `-an`
14645
+ * drops audio (handled separately by `segment-audio`). Exported for the spec.
14646
+ */
14647
+ var SEGMENT_DEMUX_FFMPEG_ARGS = [
14648
+ "-hide_banner",
14649
+ "-loglevel",
14650
+ "error",
14651
+ "-i",
14652
+ "pipe:0",
14653
+ "-map",
14654
+ "0:v:0",
14655
+ "-c:v",
14656
+ "copy",
14657
+ "-an",
14658
+ "-f",
14659
+ "mpegts",
14660
+ "pipe:1"
14638
14661
  ];
14639
- /** Compand one signed 16-bit linear PCM sample to an 8-bit µ-law byte. */
14640
- function linearToMuLaw(sample) {
14641
- let s = sample;
14642
- const sign = s >> 8 & 128;
14643
- if (sign !== 0) s = -s;
14644
- if (s > CLIP) s = CLIP;
14645
- s += BIAS;
14646
- const exponent = EXP_LUT[s >> 7 & 255];
14647
- const mantissa = s >> exponent + 3 & 15;
14648
- return ~(sign | exponent << 4 | mantissa) & 255;
14649
- }
14650
14662
  /**
14651
- * Encode interleaved signed-16 little-endian PCM (mono) to µ-law bytes.
14652
- * One output byte per input sample.
14663
+ * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
14664
+ * access units.
14665
+ *
14666
+ * The video codec is detected from the transport stream's PMT `stream_type`;
14667
+ * the mpegts muxer fuses parameter sets onto every IDR (SPS/PPS for H.264,
14668
+ * VPS/SPS/PPS for H.265). Each returned {@link AccessUnit} owns a detached copy
14669
+ * of the bytes. Throws on a non-H.264/H.265 stream or a failed conversion.
14670
+ *
14671
+ * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
14672
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
14673
+ * @returns Access units in source presentation order, each tagged with its codec.
14653
14674
  */
14654
- function encodeMuLaw(pcmS16le) {
14655
- const view = new DataView(pcmS16le.buffer, pcmS16le.byteOffset, pcmS16le.byteLength);
14656
- const n = pcmS16le.byteLength >> 1;
14657
- const out = new Uint8Array(n);
14658
- for (let i = 0; i < n; i++) out[i] = linearToMuLaw(view.getInt16(i * 2, true));
14659
- return out;
14675
+ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
14676
+ const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
14677
+ ffmpegPath,
14678
+ args: SEGMENT_DEMUX_FFMPEG_ARGS,
14679
+ input: bytes
14680
+ }));
14681
+ return accessUnits.map((au) => ({
14682
+ data: au.data,
14683
+ ptsMs: au.ptsMs,
14684
+ keyframe: au.keyframe,
14685
+ codec
14686
+ }));
14660
14687
  }
14661
14688
  //#endregion
14662
14689
  //#region src/stream-broker/recorded/segment-audio.ts
14663
14690
  /**
14664
- * segment-audio — turn one `.m4s` fragment's AAC audio into 8 kHz µ-law (PCMU)
14691
+ * segment-audio — turn one `.m4s` fragment's audio into 8 kHz µ-law (PCMU)
14665
14692
  * access units, matching the WebRTC audio sender's negotiated codec
14666
14693
  * (`audio/PCMU`, 8 kHz, PT 0 — see session.ts). Recorded audio is stored as AAC
14667
- * (the recorder transcodes every camera's audio to AAC); WebRTC speaks PCMU, so
14668
- * we decode AAC → 8 kHz mono s16 PCM (via the in-process `DecodeRuntime`, the
14669
- * same one the live audio-codec path uses) then compand to µ-law here.
14694
+ * (the recorder transcodes every camera's audio to AAC); WebRTC speaks PCMU.
14670
14695
  *
14671
- * Output: 20 ms (160-sample) PCMU frames with segment-relative `ptsMs`, ready to
14672
- * push on the session's audio sender alongside the recorded video.
14696
+ * Implementation: run the fragment through
14697
+ * `ffmpeg -vn -ar 8000 -ac 1 -c:a pcm_mulaw -f mulaw` (a crash-isolated
14698
+ * subprocess — this replaced the old in-process `node-av` `DecodeRuntime`
14699
+ * decode + hand-rolled µ-law companding). ffmpeg decodes the AAC, resamples to
14700
+ * 8 kHz mono, and companded µ-law bytes come out on stdout; we slice them into
14701
+ * fixed 20 ms (160-sample) PCMU frames here.
14673
14702
  *
14674
- * Best-effort by contract: returns [] when the fragment has no usable audio, and
14675
- * the caller swallows throws — recorded audio must NEVER block recorded video.
14703
+ * Output: 20 ms PCMU frames with segment-relative `ptsMs`, ready to push on the
14704
+ * session's audio sender alongside the recorded video.
14705
+ *
14706
+ * Best-effort by contract: returns [] when the fragment has no usable audio
14707
+ * (ffmpeg exits non-zero → tolerated, empty output), and the caller swallows
14708
+ * throws — recorded audio must NEVER block recorded video.
14676
14709
  */
14677
14710
  /** PCMU target: 8 kHz mono, 160 samples (20 ms) per RTP frame. */
14678
- var TARGET_RATE = 8e3;
14679
14711
  var FRAME_SAMPLES = 160;
14680
14712
  var FRAME_MS = 20;
14681
- /** FFmpeg's "no timestamp" sentinel (INT64_MIN). */
14682
- var AV_NOPTS_VALUE = -9223372036854775808n;
14683
- var _navPromise = null;
14684
- var _constsPromise = null;
14685
- var getNodeAv = () => _navPromise ??= import("node-av");
14686
- var getConstants = () => _constsPromise ??= import("node-av/constants");
14687
- function ticksToMs(pts, tb) {
14688
- return Math.round(Number(pts) * 1e3 * tb.num / tb.den);
14713
+ /**
14714
+ * ffmpeg arguments to decode one `.m4s` fragment's audio (read from stdin) into
14715
+ * raw 8 kHz mono µ-law bytes on stdout. `-vn` drops video (handled by
14716
+ * `segment-demux`); `-c:a pcm_mulaw -f mulaw` companded output = one byte per
14717
+ * 8 kHz sample. Exported for the spec.
14718
+ */
14719
+ var SEGMENT_AUDIO_FFMPEG_ARGS = [
14720
+ "-hide_banner",
14721
+ "-loglevel",
14722
+ "error",
14723
+ "-i",
14724
+ "pipe:0",
14725
+ "-vn",
14726
+ "-map",
14727
+ "0:a:0",
14728
+ "-ar",
14729
+ "8000",
14730
+ "-ac",
14731
+ "1",
14732
+ "-c:a",
14733
+ "pcm_mulaw",
14734
+ "-f",
14735
+ "mulaw",
14736
+ "pipe:1"
14737
+ ];
14738
+ /**
14739
+ * Slice a contiguous 8 kHz µ-law byte stream (one byte per sample) into fixed
14740
+ * 160-sample (20 ms) PCMU frames, dropping any trailing partial frame. `ptsMs`
14741
+ * is derived from the running frame index — audio is continuous 8 kHz, and each
14742
+ * recorded segment re-anchors its own timeline downstream, so the frames start
14743
+ * at 0 (segment-relative). Pure, so it unit-tests without ffmpeg.
14744
+ */
14745
+ function sliceMuLawToPcmuFrames(mulaw) {
14746
+ const out = [];
14747
+ for (let off = 0, idx = 0; off + FRAME_SAMPLES <= mulaw.length; off += FRAME_SAMPLES, idx++) out.push({
14748
+ data: mulaw.slice(off, off + FRAME_SAMPLES),
14749
+ ptsMs: idx * FRAME_MS
14750
+ });
14751
+ return out;
14689
14752
  }
14690
14753
  /**
14691
- * Demux + transcode one fragment's AAC audio into PCMU access units. Returns []
14692
- * when there is no usable audio stream (the fragment is video-only, or carries a
14693
- * non-AAC audio codec we don't expect from the recorder).
14754
+ * Decode + resample one fragment's audio into PCMU access units. Returns []
14755
+ * when there is no usable audio stream (video-only fragment, or a codec ffmpeg
14756
+ * can't decode) — ffmpeg exits non-zero, which is tolerated as empty output.
14757
+ *
14758
+ * @param bytes - The full `.m4s` fragment.
14759
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
14694
14760
  */
14695
- async function demuxSegmentToPcmu(bytes) {
14696
- const nav = await getNodeAv();
14697
- const C = await getConstants();
14698
- const input = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14699
- const demuxer = await nav.Demuxer.open(input);
14700
- let decode = null;
14701
- try {
14702
- const stream = demuxer.audio() ?? demuxer.findBestStream(C.AVMEDIA_TYPE_AUDIO);
14703
- if (!stream) return [];
14704
- const cp = stream.codecpar;
14705
- const codecName = cp.codecId === C.AV_CODEC_ID_AAC ? "aac" : cp.codecId === C.AV_CODEC_ID_AAC_LATM ? "aac_latm" : null;
14706
- if (codecName === null) return [];
14707
- const sourceChannels = cp.channelLayout?.nbChannels ?? cp.channels ?? 1;
14708
- decode = DecodeRuntime.create({
14709
- codec: codecName,
14710
- sourceSampleRate: cp.sampleRate,
14711
- sourceChannels,
14712
- extraData: cp.extradata ? new Uint8Array(cp.extradata) : void 0,
14713
- targetSampleRate: TARGET_RATE,
14714
- targetChannels: 1,
14715
- targetFormat: "s16le"
14716
- });
14717
- let baseMs = null;
14718
- const muLaw = [];
14719
- for await (const packet of demuxer.packets(stream.index)) {
14720
- if (packet === null) break;
14721
- try {
14722
- const data = packet.data;
14723
- if (data && data.length > 0) {
14724
- if (baseMs === null && packet.pts !== AV_NOPTS_VALUE) baseMs = ticksToMs(packet.pts, stream.timeBase);
14725
- for (const pcm of decode.decode(data)) {
14726
- const enc = encodeMuLaw(pcm.data);
14727
- for (let i = 0; i < enc.length; i++) muLaw.push(enc[i]);
14728
- }
14729
- }
14730
- } finally {
14731
- packet.free();
14732
- }
14733
- }
14734
- if (muLaw.length === 0) return [];
14735
- const base = baseMs ?? 0;
14736
- const out = [];
14737
- for (let off = 0, idx = 0; off + FRAME_SAMPLES <= muLaw.length; off += FRAME_SAMPLES, idx++) out.push({
14738
- data: Uint8Array.from(muLaw.slice(off, off + FRAME_SAMPLES)),
14739
- ptsMs: base + idx * FRAME_MS
14740
- });
14741
- return out;
14742
- } finally {
14743
- decode?.close();
14744
- await demuxer.close();
14745
- }
14761
+ async function demuxSegmentToPcmu(bytes, ffmpegPath = "ffmpeg") {
14762
+ const mulaw = await runFfmpegOnce({
14763
+ ffmpegPath,
14764
+ args: SEGMENT_AUDIO_FFMPEG_ARGS,
14765
+ input: bytes,
14766
+ tolerateNonZeroExit: true
14767
+ });
14768
+ if (mulaw.length === 0) return [];
14769
+ return sliceMuLawToPcmuFrames(mulaw);
14746
14770
  }
14747
14771
  //#endregion
14748
14772
  //#region src/stream-broker/webrtc/broker-webrtc-server.ts
@@ -14927,6 +14951,7 @@ var BrokerWebrtcServer = class {
14927
14951
  resolveTranscodeDecodeHwAccelFn;
14928
14952
  locateRecordedSegment;
14929
14953
  readRecordedSegmentBytes;
14954
+ resolveFfmpegPath;
14930
14955
  /** Cached decode-hwaccel backend for transcode feeds (resolved once). */
14931
14956
  transcodeDecodeHwAccel = void 0;
14932
14957
  /** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
@@ -14970,6 +14995,7 @@ var BrokerWebrtcServer = class {
14970
14995
  this.resolveTranscodeDecodeHwAccelFn = options.resolveTranscodeDecodeHwAccel;
14971
14996
  this.locateRecordedSegment = options.locateRecordedSegment;
14972
14997
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
14998
+ this.resolveFfmpegPath = options.resolveFfmpegPath;
14973
14999
  }
14974
15000
  /**
14975
15001
  * Resolve (once, cached) the decode-hwaccel backend for transcode feeds.
@@ -15485,6 +15511,7 @@ var BrokerWebrtcServer = class {
15485
15511
  session.onClosed = () => this.cleanupSession(sessionId);
15486
15512
  const locateRecorded = this.locateRecordedSegment;
15487
15513
  const readRecorded = this.readRecordedSegmentBytes;
15514
+ const resolveFfmpeg = this.resolveFfmpegPath;
15488
15515
  if (locateRecorded && readRecorded) {
15489
15516
  let timeline;
15490
15517
  timeline = new TimelineSession({
@@ -15495,8 +15522,8 @@ var BrokerWebrtcServer = class {
15495
15522
  profile,
15496
15523
  locate: locateRecorded,
15497
15524
  readBytes: readRecorded,
15498
- demux: demuxSegmentToAnnexB,
15499
- demuxAudio: demuxSegmentToPcmu,
15525
+ demux: (b) => demuxSegmentToAnnexB(b, resolveFfmpeg?.()),
15526
+ demuxAudio: (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.()),
15500
15527
  sink: {
15501
15528
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
15502
15529
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
@@ -16257,6 +16284,10 @@ var BROKER_METRICS_HEARTBEAT_MS = 3e4;
16257
16284
  var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
16258
16285
  var StreamBrokerAddon = class extends BaseAddon {
16259
16286
  brokerManager = null;
16287
+ /** ffmpeg binary path from the `ffmpeg` config section, mirrored here so the
16288
+ * recorded-playback demuxers (segment-demux/segment-audio) spawn the same
16289
+ * configured binary as the transcode egress. Kept in sync by loadFfmpegConfig. */
16290
+ ffmpegBinaryPath = "ffmpeg";
16260
16291
  metricsSnapshotTimer = null;
16261
16292
  catalogReconcileTimer = null;
16262
16293
  /** Kept so onShutdown can close every live PeerConnection — releasing
@@ -16317,6 +16348,7 @@ var StreamBrokerAddon = class extends BaseAddon {
16317
16348
  hwAccel,
16318
16349
  threadCount
16319
16350
  });
16351
+ this.ffmpegBinaryPath = binaryPath;
16320
16352
  this.ctx.logger.info("Loaded ffmpeg config", { meta: {
16321
16353
  binaryPath,
16322
16354
  hwAccel,
@@ -16579,7 +16611,8 @@ var StreamBrokerAddon = class extends BaseAddon {
16579
16611
  },
16580
16612
  resolveTranscodeDecodeHwAccel: () => this.resolveTranscodeDecodeHwAccel(),
16581
16613
  locateRecordedSegment: (args) => this.ctx.api.recording.locateSegment.query(args),
16582
- readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data
16614
+ readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data,
16615
+ resolveFfmpegPath: () => this.ffmpegBinaryPath
16583
16616
  });
16584
16617
  this.brokerManager.setWebrtcServer(webrtcServer);
16585
16618
  this.webrtcServer = webrtcServer;
@@ -16863,27 +16896,6 @@ var StreamBrokerAddon = class extends BaseAddon {
16863
16896
  }
16864
16897
  };
16865
16898
  //#endregion
16866
- //#region src/stream-broker/stream-broker/frame-dropper.ts
16867
- var FrameDropper = class {
16868
- intervalMs;
16869
- lastPassedAt = -Infinity;
16870
- constructor(maxFps) {
16871
- this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
16872
- }
16873
- shouldKeep() {
16874
- if (this.intervalMs === 0) return true;
16875
- const now = Date.now();
16876
- if (now - this.lastPassedAt >= this.intervalMs) {
16877
- this.lastPassedAt = now;
16878
- return true;
16879
- }
16880
- return false;
16881
- }
16882
- setMaxFps(maxFps) {
16883
- this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
16884
- }
16885
- };
16886
- //#endregion
16887
16899
  //#region src/stream-broker/stream-broker/ffmpeg-decoder-session.ts
16888
16900
  /** Minimal no-op logger for default parameter */
16889
16901
  var noopLogger = {