@camstack/addon-pipeline 1.1.19 → 1.1.21

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 (34) 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 +543 -288
  6. package/dist/decoder-ffmpeg/index.mjs +542 -287
  7. package/dist/detection-pipeline/index.js +1 -1
  8. package/dist/detection-pipeline/index.mjs +1 -1
  9. package/dist/{dist-BgoBMCez.js → dist-BiP1gPeY.js} +20 -1
  10. package/dist/{dist-7Yx2dmuV.mjs → dist-tzhTTRq4.mjs} +20 -1
  11. package/dist/{ffmpeg-args-common-c3p-nWtU.mjs → frame-dropper-CwkBTPGV.mjs} +22 -22
  12. package/dist/motion-wasm/index.js +1 -1
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/pipeline-runner/index.js +1 -1
  15. package/dist/pipeline-runner/index.mjs +1 -1
  16. package/dist/recorder/index.js +24 -4
  17. package/dist/recorder/index.mjs +24 -4
  18. package/dist/stream-broker/_stub.js +35 -35
  19. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-w2pP0MYO.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D08qJTw_.mjs} +3 -3
  20. package/dist/stream-broker/{hostInit-BUEbBinp.mjs → hostInit-CGnMNtPl.mjs} +3 -3
  21. package/dist/stream-broker/index.js +487 -458
  22. package/dist/stream-broker/index.mjs +477 -448
  23. package/dist/stream-broker/remoteEntry.js +1 -1
  24. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CYmHXacX.js → MaskShapeCanvas-DI4BY7W2-CW4Qu4V6.js} +1 -1
  25. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-B-BkPQvX.js → MotionZonesSettings-NcxxQN8r-CHJ39tfi.js} +1 -1
  26. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DC3Jw85p.js → PrivacyMaskSettings-APgPLF7p-dInJ7kcz.js} +1 -1
  27. package/embed-dist/assets/{index-B0IQgci0.js → index-CKpVWKSG.js} +4 -4
  28. package/embed-dist/index.html +1 -1
  29. package/package.json +10 -10
  30. package/dist/audio-codec-nodeav/index.js +0 -310
  31. package/dist/audio-codec-nodeav/index.mjs +0 -305
  32. package/dist/codec-runtime-BOk-13PN.js +0 -202
  33. package/dist/codec-runtime-BsqlEjPi.mjs +0 -197
  34. package/dist/{ffmpeg-args-common-CASoNt42.js → frame-dropper-7RTo_YyG.js} +21 -21
@@ -1,8 +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-7Yx2dmuV.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 { n as logBannerArgs, r as FrameDropper, t as audioEncoderArgs } from "../ffmpeg-args-common-c3p-nWtU.mjs";
5
- import { t as DecodeRuntime } from "../codec-runtime-BsqlEjPi.mjs";
4
+ import { n as audioEncoderArgs, r as logBannerArgs, t as FrameDropper } from "../frame-dropper-CwkBTPGV.mjs";
6
5
  import { createRequire } from "node:module";
7
6
  import * as fs from "node:fs";
8
7
  import * as path$1 from "node:path";
@@ -349,6 +348,11 @@ var FrameHandlePlane = class {
349
348
  localNodeId;
350
349
  subscriptions = /* @__PURE__ */ new Map();
351
350
  sessions = /* @__PURE__ */ new Map();
351
+ /** In-flight `ensureSession` creation per format. Coalesces concurrent
352
+ * same-format callers onto ONE decoder session — without it two callers
353
+ * both pass the `sessions.get` check, both `createSession` (two ffmpeg),
354
+ * and the second registration orphans the first with no `destroySession`. */
355
+ sessionCreating = /* @__PURE__ */ new Map();
352
356
  disposed = false;
353
357
  /** Re-entrancy guard for `armPendingSessions` — collapses the per-packet
354
358
  * `pushPacket` calls into a single in-flight deferred-arm pass. */
@@ -623,15 +627,29 @@ var FrameHandlePlane = class {
623
627
  existing.subscriberIds.add(subscriptionId);
624
628
  return;
625
629
  }
626
- const subscriberIds = new Set([subscriptionId]);
627
- for (const subscription of this.subscriptions.values()) if (subscription.format === format) subscriberIds.add(subscription.id);
628
- const session = {
629
- format,
630
- proxy: null,
631
- nodeId: null,
632
- subscriberIds
633
- };
634
- if (await this.startSessionDecoder(session)) this.sessions.set(format, session);
630
+ const inflight = this.sessionCreating.get(format);
631
+ if (inflight) {
632
+ await inflight;
633
+ this.sessions.get(format)?.subscriberIds.add(subscriptionId);
634
+ return;
635
+ }
636
+ const creation = (async () => {
637
+ const subscriberIds = new Set([subscriptionId]);
638
+ for (const subscription of this.subscriptions.values()) if (subscription.format === format) subscriberIds.add(subscription.id);
639
+ const session = {
640
+ format,
641
+ proxy: null,
642
+ nodeId: null,
643
+ subscriberIds
644
+ };
645
+ if (await this.startSessionDecoder(session)) this.sessions.set(format, session);
646
+ })();
647
+ this.sessionCreating.set(format, creation);
648
+ try {
649
+ await creation;
650
+ } finally {
651
+ this.sessionCreating.delete(format);
652
+ }
635
653
  }
636
654
  /**
637
655
  * Create the `frameSink: 'shm'` decoder for a `FormatSession` and start
@@ -6133,6 +6151,10 @@ var RtspSession = class {
6133
6151
  lastRtpAt = 0;
6134
6152
  /** Total video RTP bytes written to the socket. */
6135
6153
  bytesSent = 0;
6154
+ /** Client `User-Agent` (from the first request that carries one). Lets the
6155
+ * broker panel identify a consumer by purpose — e.g. the recorder sets
6156
+ * `-user_agent recorder` on its RTSP pull — instead of a bare address. */
6157
+ userAgent = null;
6136
6158
  /** When true, this session receives video-only (no audio RTP). */
6137
6159
  muted;
6138
6160
  constructor(socket, sdp, onClose, muted) {
@@ -6176,6 +6198,7 @@ var RtspSession = class {
6176
6198
  return {
6177
6199
  sessionId: this.sessionId,
6178
6200
  remoteAddr: `${remoteIp}:${remotePort}`,
6201
+ userAgent: this.userAgent,
6179
6202
  playing: this.isPlaying(),
6180
6203
  muted: this.muted,
6181
6204
  connectedAt: this.connectedAt,
@@ -6242,7 +6265,10 @@ var RtspSession = class {
6242
6265
  const requestText = this.buffer.slice(0, endIdx);
6243
6266
  this.buffer = this.buffer.slice(endIdx + 4);
6244
6267
  const request = this.parseRequest(requestText);
6245
- if (request) this.handleRequest(request);
6268
+ if (request) {
6269
+ if (this.userAgent === null) this.userAgent = request.headers.get("user-agent") ?? null;
6270
+ this.handleRequest(request);
6271
+ }
6246
6272
  }
6247
6273
  }
6248
6274
  parseRequest(text) {
@@ -6491,6 +6517,7 @@ var RtspListenServer = class RtspListenServer {
6491
6517
  releaseOnce();
6492
6518
  }, isMuted);
6493
6519
  restreamer.addSession(session);
6520
+ releaseOnce();
6494
6521
  socket.unshift(Buffer.from(requestBuffer, "ascii"));
6495
6522
  }
6496
6523
  /**
@@ -14298,475 +14325,468 @@ var TimelineSession = class {
14298
14325
  }
14299
14326
  };
14300
14327
  //#endregion
14301
- //#region src/stream-broker/recorded/segment-demux.ts
14328
+ //#region src/stream-broker/recorded/ffmpeg-run-once.ts
14302
14329
  /**
14303
- * Pure selector: map a node-av video `codecId` to its mp4toannexb bitstream
14304
- * filter name and the `AccessUnit.codec` tag. Both BSFs prepend the stream's
14305
- * parameter sets to keyframes (SPS/PPS for H.264; VPS/SPS/PPS for H.265).
14330
+ * ffmpeg-run-once spawn ffmpeg for ONE batch conversion: feed an in-memory
14331
+ * input buffer on stdin, collect the whole stdout, resolve when the process
14332
+ * exits. Used by the recorded-playback demuxers (`segment-demux`,
14333
+ * `segment-audio`) which turn a single self-contained `.m4s` fragment into
14334
+ * Annex-B / µ-law bytes in a crash-isolated subprocess (replacing the old
14335
+ * in-process `node-av` binding).
14306
14336
  *
14307
- * `h264Id`/`hevcId` are injected (from `node-av/constants`
14308
- * `AV_CODEC_ID_H264`=27 / `AV_CODEC_ID_HEVC`=173) so this is unit-testable
14309
- * without loading the FFmpeg binding. Throws on any other codec.
14337
+ * Unlike the live streaming sessions (`decoder-ffmpeg`, `audio-codec-ffmpeg`)
14338
+ * this is fire-once: there is no persistent stdin feed. stdin is written and
14339
+ * closed up-front so ffmpeg reads the whole fragment, transcodes, flushes, and
14340
+ * exits; we buffer stdout and resolve with it.
14341
+ *
14342
+ * The kill sequence copies the CORRECTED gating from `decoder-ffmpeg`/
14343
+ * `audio-codec-ffmpeg`: SIGTERM then escalated SIGKILL gated on
14344
+ * `exitCode === null && signalCode === null` (NOT on `child.killed`, which Node
14345
+ * sets true after ANY kill() so `!child.killed` never fires → leaked ffmpeg).
14310
14346
  */
14311
- function bsfNameForCodecId(codecId, h264Id, hevcId) {
14312
- if (codecId === h264Id) return {
14313
- bsf: "h264_mp4toannexb",
14314
- codec: "H264"
14315
- };
14316
- if (codecId === hevcId) return {
14317
- bsf: "hevc_mp4toannexb",
14318
- codec: "H265"
14319
- };
14320
- throw new Error(`segment-demux: unsupported video codec id ${codecId} (expected H264=${h264Id} or HEVC=${hevcId})`);
14321
- }
14347
+ /** Hard ceiling for one fragment conversion; a ~4 s GOP transcodes in well
14348
+ * under this even on a slow agent. Prevents a wedged ffmpeg from leaking. */
14349
+ var RUN_TIMEOUT_MS = 8e3;
14350
+ /** Grace period between SIGTERM and the escalated SIGKILL. */
14351
+ var KILL_GRACE_MS = 500;
14322
14352
  /**
14323
- * `AV_NOPTS_VALUE` (INT64_MIN) FFmpeg's sentinel for "no timestamp". A packet
14324
- * carrying this in `pts`/`dts` has no usable presentation time; converting it
14325
- * naively yields a garbage ~-9.2e18 ms.
14353
+ * Run ffmpeg once over `input` and resolve with the collected stdout bytes.
14354
+ *
14355
+ * @throws If ffmpeg cannot be spawned, times out, or exits non-zero (unless
14356
+ * `tolerateNonZeroExit` is set).
14326
14357
  */
14327
- var AV_NOPTS_VALUE$1 = -9223372036854775808n;
14328
- var _navPromise$1 = null;
14329
- var _constsPromise$1 = null;
14330
- function getNodeAv$1() {
14331
- return _navPromise$1 ??= import("node-av");
14332
- }
14333
- function getConstants$1() {
14334
- return _constsPromise$1 ??= import("node-av/constants");
14335
- }
14336
- /** Convert a presentation timestamp in stream ticks to milliseconds. */
14337
- function ticksToMs$1(ptsTicks, timeBase) {
14338
- return Math.round(Number(ptsTicks) * 1e3 * timeBase.num / timeBase.den);
14358
+ function runFfmpegOnce(options) {
14359
+ const { ffmpegPath, args, input, tolerateNonZeroExit = false } = options;
14360
+ return new Promise((resolve, reject) => {
14361
+ const chunks = [];
14362
+ const stderrTail = [];
14363
+ let settled = false;
14364
+ const child = (() => {
14365
+ try {
14366
+ return spawn(ffmpegPath, [...args]);
14367
+ } catch (err) {
14368
+ reject(/* @__PURE__ */ new Error(`ffmpeg spawn failed: ${errMsg(err)}`));
14369
+ return null;
14370
+ }
14371
+ })();
14372
+ if (child === null) return;
14373
+ const timer = setTimeout(() => {
14374
+ if (settled) return;
14375
+ settle(() => reject(/* @__PURE__ */ new Error("ffmpeg-run-once: timed out")));
14376
+ kill();
14377
+ }, RUN_TIMEOUT_MS);
14378
+ timer.unref?.();
14379
+ const settle = (fn) => {
14380
+ if (settled) return;
14381
+ settled = true;
14382
+ clearTimeout(timer);
14383
+ fn();
14384
+ };
14385
+ const kill = () => {
14386
+ try {
14387
+ child.kill("SIGTERM");
14388
+ } catch {}
14389
+ setTimeout(() => {
14390
+ if (child.exitCode === null && child.signalCode === null) try {
14391
+ child.kill("SIGKILL");
14392
+ } catch {}
14393
+ }, KILL_GRACE_MS).unref?.();
14394
+ };
14395
+ child.stdin?.on("error", () => {});
14396
+ child.stdout?.on("data", (chunk) => chunks.push(chunk));
14397
+ child.stderr?.on("data", (data) => {
14398
+ const line = data.toString().trim();
14399
+ if (line) {
14400
+ stderrTail.push(line);
14401
+ if (stderrTail.length > 8) stderrTail.shift();
14402
+ }
14403
+ });
14404
+ child.on("error", (err) => {
14405
+ settle(() => reject(/* @__PURE__ */ new Error(`ffmpeg process error: ${err.message}`)));
14406
+ });
14407
+ child.on("exit", (code, signal) => {
14408
+ const stdout = Buffer.concat(chunks);
14409
+ if (code === 0 || tolerateNonZeroExit) {
14410
+ settle(() => resolve(stdout));
14411
+ return;
14412
+ }
14413
+ settle(() => reject(/* @__PURE__ */ new Error(`ffmpeg exited code=${code} signal=${signal}: ${stderrTail.join(" | ") || "(no stderr)"}`)));
14414
+ });
14415
+ try {
14416
+ child.stdin?.write(Buffer.from(input.buffer, input.byteOffset, input.byteLength));
14417
+ child.stdin?.end();
14418
+ } catch {}
14419
+ });
14339
14420
  }
14421
+ //#endregion
14422
+ //#region src/stream-broker/recorded/ts-annexb-demux.ts
14340
14423
  /**
14341
- * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
14342
- * access units.
14424
+ * ts-annexb-demux pure MPEG-TS ordered Annex-B access units.
14343
14425
  *
14344
- * The video codec is detected from the segment's video stream `codecId`; the
14345
- * matching mp4toannexb bitstream filter prepends parameter sets to every IDR
14346
- * (SPS/PPS for H.264, VPS/SPS/PPS for H.265) no manual `avcC`/`hvcC` parsing.
14347
- * Each returned {@link AccessUnit} owns a detached copy of the packet bytes —
14348
- * the underlying `node-av` packets are freed before this resolves, so the
14349
- * result is safe to retain. Throws on a non-H.264/H.265 video codec.
14426
+ * Companion to {@link demuxSegmentToAnnexB}: `segment-demux` runs one `.m4s`
14427
+ * fragment through `ffmpeg -c:v copy -f mpegts` (crash-isolated subprocess,
14428
+ * replacing the old in-process `node-av` demux) and hands the resulting
14429
+ * transport stream here to be split back into per-frame access units.
14350
14430
  *
14351
- * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
14352
- * @returns Access units in source presentation order, each tagged with its codec.
14431
+ * Why MPEG-TS as the intermediate container:
14432
+ * - it preserves EXACTLY the two things the recorded-playback feeder needs
14433
+ * that a raw Annex-B elementary stream (`-f h264/hevc`) throws away — the
14434
+ * per-frame presentation timestamp (PES `PTS`, 90 kHz) and the frame
14435
+ * boundaries (one video PES per access unit, marked by `payload_unit_start`);
14436
+ * - the ffmpeg mpegts muxer FUSES the parameter sets onto every random-access
14437
+ * point exactly like the `h264/hevc_mp4toannexb` bitstream filter did — the
14438
+ * IDR PES carries `AUD` + `VPS/SPS/PPS` (H.265) or `AUD` + `SPS/PPS`
14439
+ * (H.264) ahead of the IDR slice — so no explicit BSF is needed and the
14440
+ * downstream pusher's "one AU per frame, params on keyframes" contract holds;
14441
+ * - the codec is read straight from the PMT `stream_type` (`0x1B` → H.264,
14442
+ * `0x24` → H.265), so no separate probe pass is required.
14443
+ *
14444
+ * This module is intentionally pure (a `Uint8Array` in, access units out) so it
14445
+ * unit-tests against a committed `.ts` fixture with no ffmpeg/native binding.
14353
14446
  */
14354
- async function demuxSegmentToAnnexB(bytes) {
14355
- const nav = await getNodeAv$1();
14356
- const C = await getConstants$1();
14357
- const input = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14358
- const demuxer = await nav.Demuxer.open(input);
14359
- let bsf = null;
14360
- try {
14361
- const videoStream = demuxer.video() ?? demuxer.findBestStream(C.AVMEDIA_TYPE_VIDEO);
14362
- if (!videoStream) throw new Error("segment-demux: no video stream found in fragment");
14363
- const timeBase = videoStream.timeBase;
14364
- const { bsf: bsfName, codec } = bsfNameForCodecId(videoStream.codecpar.codecId, C.AV_CODEC_ID_H264, C.AV_CODEC_ID_HEVC);
14365
- bsf = nav.BitStreamFilterAPI.create(bsfName, videoStream);
14366
- const accessUnits = [];
14367
- for await (const packet of bsf.packets(demuxer.packets(videoStream.index))) {
14368
- if (packet === null) break;
14369
- try {
14370
- const data = packet.data;
14371
- if (data && data.length > 0) {
14372
- const copy = new Uint8Array(data.length);
14373
- copy.set(data);
14374
- const rawPts = packet.pts !== AV_NOPTS_VALUE$1 ? packet.pts : packet.dts !== AV_NOPTS_VALUE$1 ? packet.dts : 0n;
14375
- accessUnits.push({
14376
- data: copy,
14377
- ptsMs: ticksToMs$1(rawPts, timeBase),
14378
- keyframe: packet.isKeyframe,
14379
- codec
14380
- });
14381
- }
14382
- } finally {
14383
- packet.free();
14447
+ /** MPEG-TS transport packet size. */
14448
+ var TS_PACKET_SIZE = 188;
14449
+ /** Transport packet sync byte. */
14450
+ var TS_SYNC_BYTE = 71;
14451
+ /** The PAT always lives on PID 0. */
14452
+ var PAT_PID = 0;
14453
+ /** PMT `stream_type` for H.264 (AVC) video. */
14454
+ var STREAM_TYPE_H264 = 27;
14455
+ /** PMT `stream_type` for H.265 (HEVC) video. */
14456
+ var STREAM_TYPE_H265 = 36;
14457
+ /** Map a PMT `stream_type` to the AccessUnit codec tag, or null if not video. */
14458
+ function codecForStreamType(streamType) {
14459
+ if (streamType === STREAM_TYPE_H264) return "H264";
14460
+ if (streamType === STREAM_TYPE_H265) return "H265";
14461
+ return null;
14462
+ }
14463
+ /**
14464
+ * Detect whether an Annex-B access unit is a keyframe by scanning its NAL units:
14465
+ * H.264 IDR (type 5) or H.265 IRAP (types 16–21). Mirrors
14466
+ * `AnnexBDeframer.isKeyframeNal`; params/AUD NALs ahead of the slice are ignored.
14467
+ */
14468
+ function isKeyframeAccessUnit(au, codec) {
14469
+ for (const nalStart of iterateNalStarts(au)) {
14470
+ const header = au[nalStart];
14471
+ if (header === void 0) continue;
14472
+ if (codec === "H264") {
14473
+ if ((header & 31) === 5) return true;
14474
+ } else {
14475
+ const type = header >> 1 & 63;
14476
+ if (type >= 16 && type <= 21) return true;
14477
+ }
14478
+ }
14479
+ return false;
14480
+ }
14481
+ /**
14482
+ * Yield the byte offset of the FIRST byte of each NAL unit (i.e. just past its
14483
+ * Annex-B start code, `00 00 01` or `00 00 00 01`) in `buf`.
14484
+ */
14485
+ function* iterateNalStarts(buf) {
14486
+ const len = buf.length;
14487
+ let i = 0;
14488
+ while (i + 2 < len) {
14489
+ if (buf[i] === 0 && buf[i + 1] === 0) {
14490
+ if (buf[i + 2] === 1) {
14491
+ yield i + 3;
14492
+ i += 3;
14493
+ continue;
14494
+ }
14495
+ if (buf[i + 2] === 0 && buf[i + 3] === 1) {
14496
+ yield i + 4;
14497
+ i += 4;
14498
+ continue;
14384
14499
  }
14385
14500
  }
14386
- return accessUnits;
14387
- } finally {
14388
- bsf?.close();
14389
- await demuxer.close();
14501
+ i++;
14502
+ }
14503
+ }
14504
+ /**
14505
+ * Read the 33-bit PES `PTS` (or DTS) from the 5 bytes at `off` and return it in
14506
+ * 90 kHz ticks. The layout interleaves marker bits every byte
14507
+ * (`0010 PPP1 PPPPPPPP PPPPPPP1 PPPPPPPP PPPPPPP1`).
14508
+ */
14509
+ function readPts33(buf, off) {
14510
+ const b0 = buf[off] ?? 0;
14511
+ const b1 = buf[off + 1] ?? 0;
14512
+ const b2 = buf[off + 2] ?? 0;
14513
+ const b3 = buf[off + 3] ?? 0;
14514
+ const b4 = buf[off + 4] ?? 0;
14515
+ const hi = b0 >> 1 & 7;
14516
+ const mid = b1 << 7 | b2 >> 1;
14517
+ const lo = b3 << 7 | b4 >> 1;
14518
+ return hi * 1073741824 + mid * 32768 + lo;
14519
+ }
14520
+ /** Locate the payload offset within a TS packet, skipping any adaptation field. */
14521
+ function payloadOffset(pkt, base) {
14522
+ const adaptationFieldControl = pkt[base + 3] >> 4 & 3;
14523
+ let off = base + 4;
14524
+ if (adaptationFieldControl & 2) off += 1 + pkt[off];
14525
+ return off;
14526
+ }
14527
+ /** Whether a TS packet's adaptation_field_control indicates a payload is present. */
14528
+ function hasPayload(pkt, base) {
14529
+ const afc = pkt[base + 3] >> 4 & 3;
14530
+ return afc === 1 || afc === 3;
14531
+ }
14532
+ /** Parse the PAT payload → the first program's PMT PID. */
14533
+ function parsePatForPmtPid(payload) {
14534
+ const p = 1 + payload[0] + 8;
14535
+ if (p + 3 >= payload.length) return null;
14536
+ return (payload[p + 2] & 31) << 8 | payload[p + 3];
14537
+ }
14538
+ /** Parse the PMT payload → the first H.264/H.265 elementary stream's PID + codec. */
14539
+ function parsePmt(payload) {
14540
+ const p = 1 + payload[0];
14541
+ const sectionLength = (payload[p + 1] & 15) << 8 | payload[p + 2];
14542
+ const programInfoLength = (payload[p + 10] & 15) << 8 | payload[p + 11];
14543
+ let es = p + 12 + programInfoLength;
14544
+ const sectionEnd = p + 3 + sectionLength - 4;
14545
+ while (es + 4 < sectionEnd && es + 4 < payload.length) {
14546
+ const streamType = payload[es];
14547
+ const elementaryPid = (payload[es + 1] & 31) << 8 | payload[es + 2];
14548
+ const esInfoLength = (payload[es + 3] & 15) << 8 | payload[es + 4];
14549
+ const codec = codecForStreamType(streamType);
14550
+ if (codec !== null) return {
14551
+ videoPid: elementaryPid,
14552
+ codec
14553
+ };
14554
+ es += 5 + esInfoLength;
14390
14555
  }
14556
+ return null;
14557
+ }
14558
+ /** Split one reassembled PES packet into its Annex-B elementary-stream payload
14559
+ * and 90 kHz PTS (null when the PES carries no PTS flag). */
14560
+ function splitPes(pes) {
14561
+ if (pes.length < 9 || pes[0] !== 0 || pes[1] !== 0 || pes[2] !== 1) return null;
14562
+ const ptsDtsFlags = pes[7] >> 6 & 3;
14563
+ const headerDataLength = pes[8];
14564
+ return {
14565
+ es: pes.subarray(9 + headerDataLength),
14566
+ pts: ptsDtsFlags & 2 ? readPts33(pes, 9) : null
14567
+ };
14568
+ }
14569
+ /**
14570
+ * Demux an MPEG-TS byte stream (as produced by `ffmpeg -c:v copy -f mpegts`)
14571
+ * into ordered Annex-B access units, one per video PES.
14572
+ *
14573
+ * @param ts - The full transport stream.
14574
+ * @returns The detected codec and its access units in transport (decode) order.
14575
+ * @throws If no H.264/H.265 video elementary stream is present.
14576
+ */
14577
+ function demuxMpegTsToAnnexB(ts) {
14578
+ let pmtPid = null;
14579
+ let pmt = null;
14580
+ for (let i = 0; i + TS_PACKET_SIZE <= ts.length; i += TS_PACKET_SIZE) {
14581
+ const pkt = ts.subarray(i, i + TS_PACKET_SIZE);
14582
+ if (pkt[0] !== TS_SYNC_BYTE) continue;
14583
+ const pid = (pkt[1] & 31) << 8 | pkt[2];
14584
+ if (!((pkt[1] & 64) !== 0) || !hasPayload(pkt, 0)) continue;
14585
+ const payload = pkt.subarray(payloadOffset(pkt, 0));
14586
+ if (pid === PAT_PID) pmtPid ??= parsePatForPmtPid(payload);
14587
+ else if (pmtPid !== null && pid === pmtPid && pmt === null) pmt = parsePmt(payload);
14588
+ }
14589
+ if (pmt === null) throw new Error("ts-annexb-demux: no H.264/H.265 video stream found in transport stream");
14590
+ const { videoPid, codec } = pmt;
14591
+ const accessUnits = [];
14592
+ let current = null;
14593
+ const finalize = () => {
14594
+ if (current === null) return;
14595
+ const pes = current.length === 1 ? current[0] : concat(current);
14596
+ current = null;
14597
+ const split = splitPes(pes);
14598
+ if (split === null || split.es.length === 0) return;
14599
+ const data = new Uint8Array(split.es.length);
14600
+ data.set(split.es);
14601
+ accessUnits.push({
14602
+ data,
14603
+ ptsMs: split.pts === null ? 0 : Math.round(split.pts / 90),
14604
+ keyframe: isKeyframeAccessUnit(data, codec)
14605
+ });
14606
+ };
14607
+ for (let i = 0; i + TS_PACKET_SIZE <= ts.length; i += TS_PACKET_SIZE) {
14608
+ const pkt = ts.subarray(i, i + TS_PACKET_SIZE);
14609
+ if (pkt[0] !== TS_SYNC_BYTE) continue;
14610
+ if (((pkt[1] & 31) << 8 | pkt[2]) !== videoPid) continue;
14611
+ if (!hasPayload(pkt, 0)) continue;
14612
+ const payloadStart = (pkt[1] & 64) !== 0;
14613
+ const payload = pkt.subarray(payloadOffset(pkt, 0));
14614
+ if (payloadStart) {
14615
+ finalize();
14616
+ current = [Uint8Array.from(payload)];
14617
+ } else if (current !== null) current.push(Uint8Array.from(payload));
14618
+ }
14619
+ finalize();
14620
+ return {
14621
+ codec,
14622
+ accessUnits
14623
+ };
14624
+ }
14625
+ /** Concatenate TS payload fragments into one contiguous PES buffer. */
14626
+ function concat(parts) {
14627
+ let total = 0;
14628
+ for (const p of parts) total += p.length;
14629
+ const out = new Uint8Array(total);
14630
+ let off = 0;
14631
+ for (const p of parts) {
14632
+ out.set(p, off);
14633
+ off += p.length;
14634
+ }
14635
+ return out;
14391
14636
  }
14392
14637
  //#endregion
14393
- //#region src/stream-broker/recorded/pcm-to-mulaw.ts
14638
+ //#region src/stream-broker/recorded/segment-demux.ts
14394
14639
  /**
14395
- * G.711 µ-law (PCMU) encode pure, no native deps.
14640
+ * segment-demux turn one self-contained `.m4s` fragment (a Buffer) into
14641
+ * ordered Annex-B access units. Codec-agnostic: handles BOTH H.264 and H.265
14642
+ * recordings (the recorder is `-c:v copy` passthrough, so an H.264 camera
14643
+ * produces H.264 recordings and an H.265 camera produces H.265 recordings).
14396
14644
  *
14397
- * The WebRTC audio sender negotiates `audio/PCMU` (8 kHz). Recorded audio is
14398
- * stored as AAC, decoded to 8 kHz mono signed-16 PCM, then companded to µ-law
14399
- * here for the same wire format the live path uses.
14645
+ * The recorder writes self-contained fragments: each `.m4s` carries its own
14646
+ * `moov` (and thus its own `avcC`/`hvcC` parameter sets) plus exactly one
14647
+ * IDR-led GOP (~4s). To replay one over a push pipeline we need the bytes as
14648
+ * Annex-B access units with parameter sets prepended to every IDR (SPS/PPS for
14649
+ * H.264; VPS/SPS/PPS for H.265) and each unit tagged with its source PTS.
14400
14650
  *
14401
- * Standard ITU-T G.711 µ-law: bias 0x84, clip 32635, exponent table.
14651
+ * Implementation: run the fragment through `ffmpeg -c:v copy -f mpegts` (a
14652
+ * crash-isolated subprocess — this replaced the old in-process `node-av`
14653
+ * demux + `h264/hevc_mp4toannexb` BSF, which could SIGBUS the whole broker on
14654
+ * a fragile stream), then split the transport stream back into access units
14655
+ * with {@link demuxMpegTsToAnnexB}. The mpegts muxer fuses the parameter sets
14656
+ * onto every IDR exactly like the mp4toannexb BSF did, preserves the per-frame
14657
+ * PTS (PES 90 kHz), and exposes the codec via the PMT `stream_type` — so no
14658
+ * explicit bitstream filter and no separate probe pass are needed. See
14659
+ * `ts-annexb-demux.ts` for the container-choice rationale.
14402
14660
  */
14403
- var BIAS = 132;
14404
- var CLIP = 32635;
14405
- var EXP_LUT = [
14406
- 0,
14407
- 0,
14408
- 1,
14409
- 1,
14410
- 2,
14411
- 2,
14412
- 2,
14413
- 2,
14414
- 3,
14415
- 3,
14416
- 3,
14417
- 3,
14418
- 3,
14419
- 3,
14420
- 3,
14421
- 3,
14422
- 4,
14423
- 4,
14424
- 4,
14425
- 4,
14426
- 4,
14427
- 4,
14428
- 4,
14429
- 4,
14430
- 4,
14431
- 4,
14432
- 4,
14433
- 4,
14434
- 4,
14435
- 4,
14436
- 4,
14437
- 4,
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
- 5,
14451
- 5,
14452
- 5,
14453
- 5,
14454
- 5,
14455
- 5,
14456
- 5,
14457
- 5,
14458
- 5,
14459
- 5,
14460
- 5,
14461
- 5,
14462
- 5,
14463
- 5,
14464
- 5,
14465
- 5,
14466
- 5,
14467
- 5,
14468
- 5,
14469
- 5,
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
- 6,
14515
- 6,
14516
- 6,
14517
- 6,
14518
- 6,
14519
- 6,
14520
- 6,
14521
- 6,
14522
- 6,
14523
- 6,
14524
- 6,
14525
- 6,
14526
- 6,
14527
- 6,
14528
- 6,
14529
- 6,
14530
- 6,
14531
- 6,
14532
- 6,
14533
- 6,
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,
14642
- 7,
14643
- 7,
14644
- 7,
14645
- 7,
14646
- 7,
14647
- 7,
14648
- 7,
14649
- 7,
14650
- 7,
14651
- 7,
14652
- 7,
14653
- 7,
14654
- 7,
14655
- 7,
14656
- 7,
14657
- 7,
14658
- 7,
14659
- 7,
14660
- 7,
14661
- 7
14661
+ /**
14662
+ * ffmpeg arguments to remux one `.m4s` fragment (read from stdin) into an
14663
+ * Annex-B MPEG-TS on stdout. `-c:v copy` = no re-encode; the mpegts muxer does
14664
+ * the mp4→annexb conversion and fuses parameter sets onto every IDR. `-an`
14665
+ * drops audio (handled separately by `segment-audio`). Exported for the spec.
14666
+ */
14667
+ var SEGMENT_DEMUX_FFMPEG_ARGS = [
14668
+ "-hide_banner",
14669
+ "-loglevel",
14670
+ "error",
14671
+ "-i",
14672
+ "pipe:0",
14673
+ "-map",
14674
+ "0:v:0",
14675
+ "-c:v",
14676
+ "copy",
14677
+ "-an",
14678
+ "-f",
14679
+ "mpegts",
14680
+ "pipe:1"
14662
14681
  ];
14663
- /** Compand one signed 16-bit linear PCM sample to an 8-bit µ-law byte. */
14664
- function linearToMuLaw(sample) {
14665
- let s = sample;
14666
- const sign = s >> 8 & 128;
14667
- if (sign !== 0) s = -s;
14668
- if (s > CLIP) s = CLIP;
14669
- s += BIAS;
14670
- const exponent = EXP_LUT[s >> 7 & 255];
14671
- const mantissa = s >> exponent + 3 & 15;
14672
- return ~(sign | exponent << 4 | mantissa) & 255;
14673
- }
14674
14682
  /**
14675
- * Encode interleaved signed-16 little-endian PCM (mono) to µ-law bytes.
14676
- * One output byte per input sample.
14683
+ * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
14684
+ * access units.
14685
+ *
14686
+ * The video codec is detected from the transport stream's PMT `stream_type`;
14687
+ * the mpegts muxer fuses parameter sets onto every IDR (SPS/PPS for H.264,
14688
+ * VPS/SPS/PPS for H.265). Each returned {@link AccessUnit} owns a detached copy
14689
+ * of the bytes. Throws on a non-H.264/H.265 stream or a failed conversion.
14690
+ *
14691
+ * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
14692
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
14693
+ * @returns Access units in source presentation order, each tagged with its codec.
14677
14694
  */
14678
- function encodeMuLaw(pcmS16le) {
14679
- const view = new DataView(pcmS16le.buffer, pcmS16le.byteOffset, pcmS16le.byteLength);
14680
- const n = pcmS16le.byteLength >> 1;
14681
- const out = new Uint8Array(n);
14682
- for (let i = 0; i < n; i++) out[i] = linearToMuLaw(view.getInt16(i * 2, true));
14683
- return out;
14695
+ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
14696
+ const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
14697
+ ffmpegPath,
14698
+ args: SEGMENT_DEMUX_FFMPEG_ARGS,
14699
+ input: bytes
14700
+ }));
14701
+ return accessUnits.map((au) => ({
14702
+ data: au.data,
14703
+ ptsMs: au.ptsMs,
14704
+ keyframe: au.keyframe,
14705
+ codec
14706
+ }));
14684
14707
  }
14685
14708
  //#endregion
14686
14709
  //#region src/stream-broker/recorded/segment-audio.ts
14687
14710
  /**
14688
- * segment-audio — turn one `.m4s` fragment's AAC audio into 8 kHz µ-law (PCMU)
14711
+ * segment-audio — turn one `.m4s` fragment's audio into 8 kHz µ-law (PCMU)
14689
14712
  * access units, matching the WebRTC audio sender's negotiated codec
14690
14713
  * (`audio/PCMU`, 8 kHz, PT 0 — see session.ts). Recorded audio is stored as AAC
14691
- * (the recorder transcodes every camera's audio to AAC); WebRTC speaks PCMU, so
14692
- * we decode AAC → 8 kHz mono s16 PCM (via the in-process `DecodeRuntime`, the
14693
- * same one the live audio-codec path uses) then compand to µ-law here.
14714
+ * (the recorder transcodes every camera's audio to AAC); WebRTC speaks PCMU.
14715
+ *
14716
+ * Implementation: run the fragment through
14717
+ * `ffmpeg -vn -ar 8000 -ac 1 -c:a pcm_mulaw -f mulaw` (a crash-isolated
14718
+ * subprocess — this replaced the old in-process `node-av` `DecodeRuntime`
14719
+ * decode + hand-rolled µ-law companding). ffmpeg decodes the AAC, resamples to
14720
+ * 8 kHz mono, and companded µ-law bytes come out on stdout; we slice them into
14721
+ * fixed 20 ms (160-sample) PCMU frames here.
14694
14722
  *
14695
- * Output: 20 ms (160-sample) PCMU frames with segment-relative `ptsMs`, ready to
14696
- * push on the session's audio sender alongside the recorded video.
14723
+ * Output: 20 ms PCMU frames with segment-relative `ptsMs`, ready to push on the
14724
+ * session's audio sender alongside the recorded video.
14697
14725
  *
14698
- * Best-effort by contract: returns [] when the fragment has no usable audio, and
14699
- * the caller swallows throws recorded audio must NEVER block recorded video.
14726
+ * Best-effort by contract: returns [] when the fragment has no usable audio
14727
+ * (ffmpeg exits non-zero tolerated, empty output), and the caller swallows
14728
+ * throws — recorded audio must NEVER block recorded video.
14700
14729
  */
14701
14730
  /** PCMU target: 8 kHz mono, 160 samples (20 ms) per RTP frame. */
14702
- var TARGET_RATE = 8e3;
14703
14731
  var FRAME_SAMPLES = 160;
14704
14732
  var FRAME_MS = 20;
14705
- /** FFmpeg's "no timestamp" sentinel (INT64_MIN). */
14706
- var AV_NOPTS_VALUE = -9223372036854775808n;
14707
- var _navPromise = null;
14708
- var _constsPromise = null;
14709
- var getNodeAv = () => _navPromise ??= import("node-av");
14710
- var getConstants = () => _constsPromise ??= import("node-av/constants");
14711
- function ticksToMs(pts, tb) {
14712
- return Math.round(Number(pts) * 1e3 * tb.num / tb.den);
14733
+ /**
14734
+ * ffmpeg arguments to decode one `.m4s` fragment's audio (read from stdin) into
14735
+ * raw 8 kHz mono µ-law bytes on stdout. `-vn` drops video (handled by
14736
+ * `segment-demux`); `-c:a pcm_mulaw -f mulaw` companded output = one byte per
14737
+ * 8 kHz sample. Exported for the spec.
14738
+ */
14739
+ var SEGMENT_AUDIO_FFMPEG_ARGS = [
14740
+ "-hide_banner",
14741
+ "-loglevel",
14742
+ "error",
14743
+ "-i",
14744
+ "pipe:0",
14745
+ "-vn",
14746
+ "-map",
14747
+ "0:a:0",
14748
+ "-ar",
14749
+ "8000",
14750
+ "-ac",
14751
+ "1",
14752
+ "-c:a",
14753
+ "pcm_mulaw",
14754
+ "-f",
14755
+ "mulaw",
14756
+ "pipe:1"
14757
+ ];
14758
+ /**
14759
+ * Slice a contiguous 8 kHz µ-law byte stream (one byte per sample) into fixed
14760
+ * 160-sample (20 ms) PCMU frames, dropping any trailing partial frame. `ptsMs`
14761
+ * is derived from the running frame index — audio is continuous 8 kHz, and each
14762
+ * recorded segment re-anchors its own timeline downstream, so the frames start
14763
+ * at 0 (segment-relative). Pure, so it unit-tests without ffmpeg.
14764
+ */
14765
+ function sliceMuLawToPcmuFrames(mulaw) {
14766
+ const out = [];
14767
+ for (let off = 0, idx = 0; off + FRAME_SAMPLES <= mulaw.length; off += FRAME_SAMPLES, idx++) out.push({
14768
+ data: mulaw.slice(off, off + FRAME_SAMPLES),
14769
+ ptsMs: idx * FRAME_MS
14770
+ });
14771
+ return out;
14713
14772
  }
14714
14773
  /**
14715
- * Demux + transcode one fragment's AAC audio into PCMU access units. Returns []
14716
- * when there is no usable audio stream (the fragment is video-only, or carries a
14717
- * non-AAC audio codec we don't expect from the recorder).
14774
+ * Decode + resample one fragment's audio into PCMU access units. Returns []
14775
+ * when there is no usable audio stream (video-only fragment, or a codec ffmpeg
14776
+ * can't decode) — ffmpeg exits non-zero, which is tolerated as empty output.
14777
+ *
14778
+ * @param bytes - The full `.m4s` fragment.
14779
+ * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
14718
14780
  */
14719
- async function demuxSegmentToPcmu(bytes) {
14720
- const nav = await getNodeAv();
14721
- const C = await getConstants();
14722
- const input = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14723
- const demuxer = await nav.Demuxer.open(input);
14724
- let decode = null;
14725
- try {
14726
- const stream = demuxer.audio() ?? demuxer.findBestStream(C.AVMEDIA_TYPE_AUDIO);
14727
- if (!stream) return [];
14728
- const cp = stream.codecpar;
14729
- const codecName = cp.codecId === C.AV_CODEC_ID_AAC ? "aac" : cp.codecId === C.AV_CODEC_ID_AAC_LATM ? "aac_latm" : null;
14730
- if (codecName === null) return [];
14731
- const sourceChannels = cp.channelLayout?.nbChannels ?? cp.channels ?? 1;
14732
- decode = DecodeRuntime.create({
14733
- codec: codecName,
14734
- sourceSampleRate: cp.sampleRate,
14735
- sourceChannels,
14736
- extraData: cp.extradata ? new Uint8Array(cp.extradata) : void 0,
14737
- targetSampleRate: TARGET_RATE,
14738
- targetChannels: 1,
14739
- targetFormat: "s16le"
14740
- });
14741
- let baseMs = null;
14742
- const muLaw = [];
14743
- for await (const packet of demuxer.packets(stream.index)) {
14744
- if (packet === null) break;
14745
- try {
14746
- const data = packet.data;
14747
- if (data && data.length > 0) {
14748
- if (baseMs === null && packet.pts !== AV_NOPTS_VALUE) baseMs = ticksToMs(packet.pts, stream.timeBase);
14749
- for (const pcm of decode.decode(data)) {
14750
- const enc = encodeMuLaw(pcm.data);
14751
- for (let i = 0; i < enc.length; i++) muLaw.push(enc[i]);
14752
- }
14753
- }
14754
- } finally {
14755
- packet.free();
14756
- }
14757
- }
14758
- if (muLaw.length === 0) return [];
14759
- const base = baseMs ?? 0;
14760
- const out = [];
14761
- for (let off = 0, idx = 0; off + FRAME_SAMPLES <= muLaw.length; off += FRAME_SAMPLES, idx++) out.push({
14762
- data: Uint8Array.from(muLaw.slice(off, off + FRAME_SAMPLES)),
14763
- ptsMs: base + idx * FRAME_MS
14764
- });
14765
- return out;
14766
- } finally {
14767
- decode?.close();
14768
- await demuxer.close();
14769
- }
14781
+ async function demuxSegmentToPcmu(bytes, ffmpegPath = "ffmpeg") {
14782
+ const mulaw = await runFfmpegOnce({
14783
+ ffmpegPath,
14784
+ args: SEGMENT_AUDIO_FFMPEG_ARGS,
14785
+ input: bytes,
14786
+ tolerateNonZeroExit: true
14787
+ });
14788
+ if (mulaw.length === 0) return [];
14789
+ return sliceMuLawToPcmuFrames(mulaw);
14770
14790
  }
14771
14791
  //#endregion
14772
14792
  //#region src/stream-broker/webrtc/broker-webrtc-server.ts
@@ -14951,6 +14971,7 @@ var BrokerWebrtcServer = class {
14951
14971
  resolveTranscodeDecodeHwAccelFn;
14952
14972
  locateRecordedSegment;
14953
14973
  readRecordedSegmentBytes;
14974
+ resolveFfmpegPath;
14954
14975
  /** Cached decode-hwaccel backend for transcode feeds (resolved once). */
14955
14976
  transcodeDecodeHwAccel = void 0;
14956
14977
  /** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
@@ -14994,6 +15015,7 @@ var BrokerWebrtcServer = class {
14994
15015
  this.resolveTranscodeDecodeHwAccelFn = options.resolveTranscodeDecodeHwAccel;
14995
15016
  this.locateRecordedSegment = options.locateRecordedSegment;
14996
15017
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
15018
+ this.resolveFfmpegPath = options.resolveFfmpegPath;
14997
15019
  }
14998
15020
  /**
14999
15021
  * Resolve (once, cached) the decode-hwaccel backend for transcode feeds.
@@ -15509,6 +15531,7 @@ var BrokerWebrtcServer = class {
15509
15531
  session.onClosed = () => this.cleanupSession(sessionId);
15510
15532
  const locateRecorded = this.locateRecordedSegment;
15511
15533
  const readRecorded = this.readRecordedSegmentBytes;
15534
+ const resolveFfmpeg = this.resolveFfmpegPath;
15512
15535
  if (locateRecorded && readRecorded) {
15513
15536
  let timeline;
15514
15537
  timeline = new TimelineSession({
@@ -15519,8 +15542,8 @@ var BrokerWebrtcServer = class {
15519
15542
  profile,
15520
15543
  locate: locateRecorded,
15521
15544
  readBytes: readRecorded,
15522
- demux: demuxSegmentToAnnexB,
15523
- demuxAudio: demuxSegmentToPcmu,
15545
+ demux: (b) => demuxSegmentToAnnexB(b, resolveFfmpeg?.()),
15546
+ demuxAudio: (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.()),
15524
15547
  sink: {
15525
15548
  pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
15526
15549
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
@@ -16281,6 +16304,10 @@ var BROKER_METRICS_HEARTBEAT_MS = 3e4;
16281
16304
  var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
16282
16305
  var StreamBrokerAddon = class extends BaseAddon {
16283
16306
  brokerManager = null;
16307
+ /** ffmpeg binary path from the `ffmpeg` config section, mirrored here so the
16308
+ * recorded-playback demuxers (segment-demux/segment-audio) spawn the same
16309
+ * configured binary as the transcode egress. Kept in sync by loadFfmpegConfig. */
16310
+ ffmpegBinaryPath = "ffmpeg";
16284
16311
  metricsSnapshotTimer = null;
16285
16312
  catalogReconcileTimer = null;
16286
16313
  /** Kept so onShutdown can close every live PeerConnection — releasing
@@ -16341,6 +16368,7 @@ var StreamBrokerAddon = class extends BaseAddon {
16341
16368
  hwAccel,
16342
16369
  threadCount
16343
16370
  });
16371
+ this.ffmpegBinaryPath = binaryPath;
16344
16372
  this.ctx.logger.info("Loaded ffmpeg config", { meta: {
16345
16373
  binaryPath,
16346
16374
  hwAccel,
@@ -16603,7 +16631,8 @@ var StreamBrokerAddon = class extends BaseAddon {
16603
16631
  },
16604
16632
  resolveTranscodeDecodeHwAccel: () => this.resolveTranscodeDecodeHwAccel(),
16605
16633
  locateRecordedSegment: (args) => this.ctx.api.recording.locateSegment.query(args),
16606
- readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data
16634
+ readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data,
16635
+ resolveFfmpegPath: () => this.ffmpegBinaryPath
16607
16636
  });
16608
16637
  this.brokerManager.setWebrtcServer(webrtcServer);
16609
16638
  this.webrtcServer = webrtcServer;