@camstack/addon-pipeline 1.2.46 → 1.2.48

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 (35) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-CPfFUIl9.js → dist-CksKE1Sn.js} +1399 -1285
  6. package/dist/{dist-CUj9mdhh.mjs → dist-DoTw0NM3.mjs} +1399 -1285
  7. package/dist/{event-loop-stall-monitor-md__iwWh.mjs → event-loop-stall-monitor-CtktCf3u.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-BGbgcrEN.js → event-loop-stall-monitor-DzdwBr4X.js} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +3 -3
  12. package/dist/pipeline-runner/index.mjs +3 -3
  13. package/dist/recorder/index.js +1 -1
  14. package/dist/recorder/index.mjs +1 -1
  15. package/dist/session-decode/decode-worker-child.js +1 -1
  16. package/dist/session-decode/decode-worker-child.mjs +1 -1
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-aUWVCncO.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BNISghj9.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cj2xMupA.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Czrsozsg.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-CRwWRwJH.mjs → hostInit-DqD1qq8w.mjs} +2 -2
  22. package/dist/stream-broker/index.js +671 -101
  23. package/dist/stream-broker/index.mjs +671 -101
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-CD0M9YD7.mjs → worker-protocol-4_4L1QOe.mjs} +1 -1
  26. package/dist/{worker-protocol-hdTR-F5q.js → worker-protocol-CuEcTi66.js} +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C8eGXtf-.js → MaskShapeCanvas-DI4BY7W2-EcWZ5m8e.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-vvEcAgxS.js → MotionZonesSettings-NcxxQN8r-CfiyCD3z.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BLhx7VI-.js → PrivacyMaskSettings-APgPLF7p-ESPy4XXk.js} +1 -1
  30. package/embed-dist/assets/index-C3FKV1x-.js +112 -0
  31. package/embed-dist/index.html +1 -1
  32. package/package.json +1 -1
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DN2e2GJL.mjs +0 -26
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-2D_L2y3g.mjs +0 -26
  35. package/embed-dist/assets/index-DvOgpqcE.js +0 -112
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-CPfFUIl9.js");
6
+ const require_dist = require("../dist-CksKE1Sn.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_model_download_service_D8B_4ktF = require("../model-download-service-D8B-4ktF-7-YxE9Wx.js");
9
9
  let node_crypto = require("node:crypto");
@@ -3011,6 +3011,79 @@ function buildAudioCodecCapApi(api) {
3011
3011
  flushEncode: (input) => router.flushEncode.mutate(input)
3012
3012
  };
3013
3013
  }
3014
+ /**
3015
+ * Minimum gap between two re-dials of the same device. A re-dial costs a
3016
+ * ~2 s video interruption on every source of the camera; at the 30 s
3017
+ * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
3018
+ * cannot come back would be a permanent stutter.
3019
+ */
3020
+ var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
3021
+ var EMPTY_AUDIO_PLANE_STATE = {
3022
+ disagreements: 0,
3023
+ redials: 0,
3024
+ lastRedialMs: 0,
3025
+ gaveUp: false
3026
+ };
3027
+ /**
3028
+ * Decide what one pass should do. Pure: no clock, no I/O, no broker.
3029
+ *
3030
+ * `nowMs` is passed in rather than read so the cooldown is testable without
3031
+ * waiting five minutes for it.
3032
+ */
3033
+ function decideAudioPlaneAction(observation, state, nowMs) {
3034
+ switch (observation.kind) {
3035
+ case "broker-muted":
3036
+ case "no-live-source":
3037
+ case "audio-present":
3038
+ case "declared-off": return {
3039
+ action: { kind: "none" },
3040
+ nextState: EMPTY_AUDIO_PLANE_STATE
3041
+ };
3042
+ case "read-failed":
3043
+ case "audio-unknown": return {
3044
+ action: { kind: "none" },
3045
+ nextState: state
3046
+ };
3047
+ case "declared-on": {
3048
+ if (state.gaveUp) return {
3049
+ action: { kind: "none" },
3050
+ nextState: state
3051
+ };
3052
+ const disagreements = state.disagreements + 1;
3053
+ if (disagreements < 2) return {
3054
+ action: { kind: "await-second-pass" },
3055
+ nextState: {
3056
+ ...state,
3057
+ disagreements
3058
+ }
3059
+ };
3060
+ if (state.redials >= 3) return {
3061
+ action: { kind: "give-up" },
3062
+ nextState: {
3063
+ ...state,
3064
+ disagreements,
3065
+ gaveUp: true
3066
+ }
3067
+ };
3068
+ if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
3069
+ action: { kind: "cooldown" },
3070
+ nextState: {
3071
+ ...state,
3072
+ disagreements: 2
3073
+ }
3074
+ };
3075
+ return {
3076
+ action: { kind: "redial" },
3077
+ nextState: {
3078
+ disagreements: 0,
3079
+ redials: state.redials + 1,
3080
+ lastRedialMs: nowMs,
3081
+ gaveUp: false
3082
+ }
3083
+ };
3084
+ }
3085
+ }
3086
+ }
3014
3087
  //#endregion
3015
3088
  //#region src/stream-broker/stream-broker/dead-decode-backends.ts
3016
3089
  /**
@@ -5031,6 +5104,69 @@ var EgressTranscodeManager = class {
5031
5104
  };
5032
5105
  }
5033
5106
  };
5107
+ var EventMediaStore = class {
5108
+ deps;
5109
+ /** Insertion-ordered — `Map` iteration order IS the eviction order. */
5110
+ items = /* @__PURE__ */ new Map();
5111
+ totalBytes = 0;
5112
+ constructor(deps = {}) {
5113
+ this.deps = deps;
5114
+ }
5115
+ get size() {
5116
+ return this.items.size;
5117
+ }
5118
+ get bytes() {
5119
+ return this.totalBytes;
5120
+ }
5121
+ /** Store one artifact and return its handle. */
5122
+ put(input) {
5123
+ const now = this.deps.now ?? Date.now;
5124
+ this.sweep();
5125
+ const handle = `evm_${input.deviceId}_${(this.deps.makeId ?? defaultId)()}`;
5126
+ this.items.set(handle, {
5127
+ handle,
5128
+ storedAt: now(),
5129
+ ...input
5130
+ });
5131
+ this.totalBytes += input.bytes.length;
5132
+ const maxBytes = this.deps.maxBytes ?? 67108864;
5133
+ while (this.totalBytes > maxBytes && this.items.size > 1) {
5134
+ const oldest = this.items.keys().next().value;
5135
+ if (oldest === void 0) break;
5136
+ this.drop(oldest);
5137
+ }
5138
+ return handle;
5139
+ }
5140
+ /** Redeem a handle. `null` when it never existed or has expired. */
5141
+ get(handle) {
5142
+ this.sweep();
5143
+ return this.items.get(handle) ?? null;
5144
+ }
5145
+ /** Drop everything — shutdown. */
5146
+ clear() {
5147
+ this.items.clear();
5148
+ this.totalBytes = 0;
5149
+ }
5150
+ sweep() {
5151
+ const now = (this.deps.now ?? Date.now)();
5152
+ const ttl = this.deps.ttlMs ?? 3e5;
5153
+ for (const [handle, item] of this.items) {
5154
+ if (now - item.storedAt < ttl) break;
5155
+ this.drop(handle);
5156
+ }
5157
+ }
5158
+ drop(handle) {
5159
+ const item = this.items.get(handle);
5160
+ if (item === void 0) return;
5161
+ this.items.delete(handle);
5162
+ this.totalBytes -= item.bytes.length;
5163
+ }
5164
+ };
5165
+ var counter = 0;
5166
+ function defaultId() {
5167
+ counter += 1;
5168
+ return `${Date.now().toString(36)}${counter.toString(36)}`;
5169
+ }
5034
5170
  //#endregion
5035
5171
  //#region src/stream-broker/webrtc/nal-utils.ts
5036
5172
  var NAL_START_CODE_4B = Buffer.from([
@@ -5555,6 +5691,46 @@ function pickClipProfile(assigned, requested) {
5555
5691
  return null;
5556
5692
  }
5557
5693
  /**
5694
+ * The tallest rendition an event clip is cut from.
5695
+ *
5696
+ * 1080 and not 720: the attachment is native-resolution H.264 that nothing
5697
+ * re-encodes, and the operator asked for a clip they could actually watch. A
5698
+ * few seconds of 4K copy is megabytes, which is the difference between "every
5699
+ * notifier takes it" and "pushover drops it at 5 MiB".
5700
+ */
5701
+ var EVENT_CLIP_MAX_HEIGHT = 1080;
5702
+ /**
5703
+ * The rendition an EVENT production should cut from — the opposite default to
5704
+ * {@link pickClipProfile}.
5705
+ *
5706
+ * `pickClipProfile` takes the cheapest assigned slot, which was right when a
5707
+ * notification attachment was a 480 px thumbnail with motion. It is wrong now:
5708
+ * on this fleet the cheapest slot is a 640×360 substream, and a gif derived
5709
+ * from it is the "de-pixellated" attachment the operator reported. So an event
5710
+ * production prefers the LARGEST H.264 rendition at or below
5711
+ * {@link EVENT_CLIP_MAX_HEIGHT}:
5712
+ *
5713
+ * - **H.264, because that is what can be COPIED.** An H.265 slot forces a
5714
+ * re-encode, which is both expensive and not universally playable on the
5715
+ * phones notifications land on.
5716
+ * - **At or below 1080p**, falling back to the smallest slot above it when a
5717
+ * camera offers nothing else — a 4K-only camera still gets a clip, it just
5718
+ * pays for it.
5719
+ *
5720
+ * `null` when no rendition qualifies; the caller then falls back to
5721
+ * `pickClipProfile` and the answer reports which was used.
5722
+ */
5723
+ function pickBestClipProfile(candidates) {
5724
+ const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
5725
+ if (usable.length === 0) return null;
5726
+ const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
5727
+ return [...within.length > 0 ? within : usable].sort((a, b) => within.length > 0 ? (b.height ?? 0) - (a.height ?? 0) : (a.height ?? 0) - (b.height ?? 0))[0]?.profile ?? null;
5728
+ }
5729
+ /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
5730
+ function normaliseCodec$1(codec) {
5731
+ return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
5732
+ }
5733
+ /**
5558
5734
  * Placeholder packets ("RECONNECTING"/"SLEEPING" keyframes the broker pumps at
5559
5735
  * 1 Hz while no RTP flows) are a DIFFERENT resolution and parameter set —
5560
5736
  * muxing them into a clip corrupts the decode and produces a file that freezes
@@ -5611,6 +5787,33 @@ function windowAround(input) {
5611
5787
  if (momentPts > newest.pts || momentPts < oldest.pts) return [];
5612
5788
  return sliceFromKeyframe(video, startPts, endPts);
5613
5789
  }
5790
+ /**
5791
+ * What the selected window covers, in WALL CLOCK.
5792
+ *
5793
+ * The same anchoring {@link windowAround} selects by, and for the same reason:
5794
+ * `pts` is a stream clock with an arbitrary origin, so the only fixed point is
5795
+ * that the newest retained packet arrived (near enough) at `nowMs`. Every
5796
+ * earlier packet is `newest.pts − p.pts` milliseconds before it.
5797
+ *
5798
+ * This exists so a caller can say "the clip starts 4.1 s BEFORE the event"
5799
+ * instead of inferring it from a duration and a hope. A production whose
5800
+ * `fromTs` is after the event has no pre-roll, which is precisely the defect
5801
+ * that has to be visible rather than plausible.
5802
+ */
5803
+ function windowCoverage(packets, window, nowMs) {
5804
+ const video = decodableVideo(packets);
5805
+ const newest = video[video.length - 1];
5806
+ const first = window[0];
5807
+ const last = window[window.length - 1];
5808
+ if (newest === void 0 || first === void 0 || last === void 0) return {
5809
+ fromTs: nowMs,
5810
+ toTs: nowMs
5811
+ };
5812
+ return {
5813
+ fromTs: nowMs - (newest.pts - first.pts),
5814
+ toTs: nowMs - (newest.pts - last.pts)
5815
+ };
5816
+ }
5614
5817
  /** True for an HEVC packet — the container flag and the demuxer both differ. */
5615
5818
  function isHevcPacket(packet) {
5616
5819
  const codec = (packet?.codec ?? "h264").toLowerCase();
@@ -5643,12 +5846,78 @@ function toElementaryStream(window, sdpParameterSets) {
5643
5846
  return Buffer.concat(parts);
5644
5847
  }
5645
5848
  var RENDER_TIMEOUT_MS = 2e4;
5849
+ /**
5850
+ * The widest an event clip is RE-ENCODED at — 720p.
5851
+ *
5852
+ * Only ever reached when the render already forced a decode (a speed other than
5853
+ * real time, or an H.265 source). At real time the width must stay the source's
5854
+ * own, because an identity scale is precisely what `clipCanCopy` looks for.
5855
+ *
5856
+ * Measured on a real 615 720p window, `-preset veryfast`: crf 23 at 1280 is
5857
+ * 254 KB and 0.23 s of encode, against the 922 KB the copy of the same window
5858
+ * carried. Capping wider renditions here costs nothing an operator can see on a
5859
+ * phone and keeps the burst bounded on a hub that is already busy.
5860
+ */
5861
+ var EVENT_CLIP_ENCODE_MAX_WIDTH = 1280;
5862
+ /**
5863
+ * The width to render an event clip at, given what the source is and whether
5864
+ * the caller asked for real time.
5865
+ *
5866
+ * One function because two call sites have to agree: the mux and the
5867
+ * `copy`/`encode` the production REPORTS. They disagreed once already — a
5868
+ * reported mode that does not match the render is a log line that lies.
5869
+ */
5870
+ function eventClipEncodeWidth(sourceWidth, speed) {
5871
+ const source = Math.max(1, Math.round(sourceWidth));
5872
+ if (speed === 1) return source;
5873
+ return Math.min(source, EVENT_CLIP_ENCODE_MAX_WIDTH);
5874
+ }
5875
+ /**
5876
+ * Format a frame rate for an ffmpeg filter argument.
5877
+ *
5878
+ * `12 / 4` must reach the filter as `3` and `15 / 4` as `3.75`, not as a float
5879
+ * tail that `av_parse_video_rate` then has to guess at.
5880
+ */
5881
+ function ffmpegRate(rate) {
5882
+ return String(Math.round(rate * 1e3) / 1e3);
5883
+ }
5884
+ /**
5885
+ * Whether this render can COPY the camera's own H.264 instead of re-encoding it.
5886
+ *
5887
+ * The ring already holds the camera's elementary stream. Re-encoding it is only
5888
+ * ever necessary to change something, so the question is entirely "is anything
5889
+ * being changed":
5890
+ *
5891
+ * - **A scale is a change.** Only when the caller asks for at least the
5892
+ * source's own width is the `scale` filter an identity. `sourceWidth === 0`
5893
+ * means nobody knows the source's width, and a copy would then be a guess —
5894
+ * so it re-encodes, which is what it always did.
5895
+ * - **A speed is a change.** `setpts` rewrites the timeline.
5896
+ * - **H.265 is a change we WANT.** The mp4 lands on phones, and HEVC in an mp4
5897
+ * is not universally playable there; transcoding an H.265 camera's clip to
5898
+ * H.264 is the one case where the re-encode is the feature. (It is also why
5899
+ * the clip ring remains the fallback for an H.265-only camera rather than
5900
+ * something to delete.)
5901
+ * - **A GIF is a re-encode by definition.**
5902
+ *
5903
+ * Copying is what makes a notification clip the camera's own 1280×720 picture
5904
+ * rather than `libx264 -crf 28` at 480 px — measured on 615, the same eleven
5905
+ * seconds are 224 KB copied against a visibly softer re-encode, and the gif
5906
+ * derived from it stops looking pixellated for the same reason.
5907
+ */
5908
+ function clipCanCopy(input) {
5909
+ if (input.format !== "mp4") return false;
5910
+ if (input.hevc === true) return false;
5911
+ if ((input.speed ?? 2) !== 1) return false;
5912
+ return input.sourceWidth > 0 && input.maxWidth >= input.sourceWidth;
5913
+ }
5646
5914
  /** ffmpeg args for the chosen container, reading an annex-B stream from stdin. */
5647
5915
  function clipFfmpegArgs(input) {
5648
5916
  const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
5649
5917
  const demuxer = input.hevc === true ? "hevc" : "h264";
5650
5918
  const speed = input.speed ?? 2;
5651
5919
  const setpts = speed !== 1 ? `,setpts=PTS/${speed}` : "";
5920
+ const gifSetpts = speed !== 1 ? `${setpts},fps=${ffmpegRate(input.fps * speed)}` : setpts;
5652
5921
  if (input.format === "gif") return [
5653
5922
  "-y",
5654
5923
  "-f",
@@ -5656,13 +5925,28 @@ function clipFfmpegArgs(input) {
5656
5925
  "-i",
5657
5926
  "pipe:0",
5658
5927
  "-filter_complex",
5659
- `[0:v]fps=${input.fps}${setpts},${scale},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
5928
+ `[0:v]fps=${ffmpegRate(input.fps)}${gifSetpts},${scale},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
5660
5929
  "-loop",
5661
5930
  "0",
5662
5931
  "-f",
5663
5932
  "gif",
5664
5933
  "pipe:1"
5665
5934
  ];
5935
+ if (input.copyVideo === true) return [
5936
+ "-y",
5937
+ "-f",
5938
+ demuxer,
5939
+ "-i",
5940
+ "pipe:0",
5941
+ "-c:v",
5942
+ "copy",
5943
+ "-movflags",
5944
+ "frag_keyframe+empty_moov",
5945
+ "-an",
5946
+ "-f",
5947
+ "mp4",
5948
+ "pipe:1"
5949
+ ];
5666
5950
  return [
5667
5951
  "-y",
5668
5952
  "-f",
@@ -5676,7 +5960,7 @@ function clipFfmpegArgs(input) {
5676
5960
  "-preset",
5677
5961
  "veryfast",
5678
5962
  "-crf",
5679
- "28",
5963
+ "23",
5680
5964
  "-pix_fmt",
5681
5965
  "yuv420p",
5682
5966
  "-movflags",
@@ -5699,9 +5983,17 @@ async function renderPreBufferClip(input) {
5699
5983
  if (window.length === 0) throw new Error("pre-buffer holds no decodable video for this window");
5700
5984
  return new Promise((resolve, reject) => {
5701
5985
  const hevc = isHevcPacket(window[0]);
5986
+ const copyVideo = clipCanCopy({
5987
+ format: input.format,
5988
+ maxWidth: input.maxWidth,
5989
+ ...input.speed !== void 0 ? { speed: input.speed } : {},
5990
+ hevc,
5991
+ sourceWidth: input.sourceWidth ?? 0
5992
+ });
5702
5993
  const proc = input.spawn(input.ffmpegPath ?? "ffmpeg", clipFfmpegArgs({
5703
5994
  ...input,
5704
- hevc
5995
+ hevc,
5996
+ copyVideo
5705
5997
  }));
5706
5998
  const chunks = [];
5707
5999
  let stderrTail = "";
@@ -5744,6 +6036,142 @@ async function renderPreBufferClip(input) {
5744
6036
  proc.stdin?.end();
5745
6037
  });
5746
6038
  }
6039
+ /**
6040
+ * The factor the derive must apply ON TOP of the speed the mp4 already carries.
6041
+ *
6042
+ * The gif is derived from the muxed mp4, so if that mux already ran `setpts` at
6043
+ * the caller's `speed`, applying the absolute gif rate again would compound
6044
+ * them — a 4× gif beside a 2× video would come out at 8×. Discounting here is
6045
+ * what keeps {@link GIF_SPEED} an ABSOLUTE rate against real time whatever the
6046
+ * video does.
6047
+ */
6048
+ function gifDeriveSpeed(gifSpeed, videoSpeed) {
6049
+ if (!Number.isFinite(videoSpeed) || videoSpeed <= 0) return gifSpeed;
6050
+ return gifSpeed / videoSpeed;
6051
+ }
6052
+ /** A derive that has not finished by now will not finish inside the drain. */
6053
+ var GIF_RENDER_TIMEOUT_MS = 2e4;
6054
+ /**
6055
+ * The filter chain. Extracted so a test can assert the palette pair without
6056
+ * spawning ffmpeg — an argv test cannot see quality, but it can see a filter
6057
+ * that somebody deleted.
6058
+ */
6059
+ function gifFilterComplex(input) {
6060
+ const speed = input.speed > 0 ? input.speed : 1;
6061
+ if (speed === 1) return `[0:v]fps=${ffmpegRate(input.fps)},scale=${input.maxWidth}:-2:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`;
6062
+ return `[0:v]fps=${ffmpegRate(input.fps / speed)},setpts=PTS/${speed},fps=${ffmpegRate(input.fps)},scale=${input.maxWidth}:-2:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`;
6063
+ }
6064
+ /** The full argv, for the same reason. */
6065
+ function gifFromMp4Args(input) {
6066
+ return [
6067
+ "-y",
6068
+ "-v",
6069
+ "error",
6070
+ "-f",
6071
+ "mp4",
6072
+ "-i",
6073
+ "pipe:0",
6074
+ "-filter_complex",
6075
+ gifFilterComplex(input),
6076
+ "-loop",
6077
+ "0",
6078
+ "-f",
6079
+ "gif",
6080
+ "pipe:1"
6081
+ ];
6082
+ }
6083
+ /**
6084
+ * Derive the gif. `null` on every failure — a notification that lost its gif is
6085
+ * a notification, and one that failed is not.
6086
+ *
6087
+ * The child is guaranteed dead before this settles: these run on every
6088
+ * notification that asks for a gif, and a leaked ffmpeg accumulates on the node
6089
+ * until somebody notices the host is slow.
6090
+ */
6091
+ async function gifFromMp4(input) {
6092
+ const args = gifFromMp4Args({
6093
+ maxWidth: input.maxWidth ?? 640,
6094
+ fps: input.fps ?? 12,
6095
+ speed: input.speed ?? 4
6096
+ });
6097
+ const startedAt = Date.now();
6098
+ try {
6099
+ const bytes = await runGif(input, args);
6100
+ input.logger?.info("nc gif: derived from the notification video", {
6101
+ tags: { deviceId: input.deviceId },
6102
+ meta: {
6103
+ sourceBytes: input.mp4.length,
6104
+ gifBytes: bytes.length,
6105
+ width: input.maxWidth ?? 640,
6106
+ fps: input.fps ?? 12,
6107
+ speed: input.speed ?? 4,
6108
+ encodeMs: Date.now() - startedAt
6109
+ }
6110
+ });
6111
+ return bytes;
6112
+ } catch (err) {
6113
+ input.logger?.warn("nc gif: the derive failed — this notification ships without a gif", {
6114
+ tags: { deviceId: input.deviceId },
6115
+ meta: {
6116
+ sourceBytes: input.mp4.length,
6117
+ error: err instanceof Error ? err.message : String(err)
6118
+ }
6119
+ });
6120
+ return null;
6121
+ }
6122
+ }
6123
+ function runGif(input, args) {
6124
+ return new Promise((resolve, reject) => {
6125
+ const proc = input.spawnFn(input.ffmpegPath ?? "ffmpeg", [...args], { stdio: [
6126
+ "pipe",
6127
+ "pipe",
6128
+ "pipe"
6129
+ ] });
6130
+ const chunks = [];
6131
+ let stderrTail = "";
6132
+ let done = false;
6133
+ let timer = null;
6134
+ const settle = (err, out) => {
6135
+ if (done) return;
6136
+ done = true;
6137
+ if (timer !== null) clearTimeout(timer);
6138
+ if (proc.exitCode === null && proc.signalCode === null) try {
6139
+ proc.kill("SIGKILL");
6140
+ } catch {}
6141
+ try {
6142
+ proc.stdout?.removeAllListeners();
6143
+ proc.stderr?.removeAllListeners();
6144
+ proc.stdin?.destroy();
6145
+ } catch {}
6146
+ if (err !== null) reject(err);
6147
+ else resolve(out ?? new Uint8Array(0));
6148
+ };
6149
+ timer = setTimeout(() => settle(/* @__PURE__ */ new Error("gif derive timed out")), GIF_RENDER_TIMEOUT_MS);
6150
+ timer.unref?.();
6151
+ proc.stdout?.on("data", (c) => chunks.push(c));
6152
+ proc.stderr?.on("data", (c) => {
6153
+ stderrTail = (stderrTail + c.toString()).slice(-400);
6154
+ });
6155
+ proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
6156
+ proc.on("close", (code) => {
6157
+ if (code !== 0) {
6158
+ settle(/* @__PURE__ */ new Error(`ffmpeg gif derive exited ${code}: ${stderrTail}`));
6159
+ return;
6160
+ }
6161
+ const out = Buffer.concat(chunks);
6162
+ if (out.byteLength === 0) {
6163
+ settle(/* @__PURE__ */ new Error("gif derive produced no bytes"));
6164
+ return;
6165
+ }
6166
+ const bytes = new Uint8Array(out.byteLength);
6167
+ bytes.set(out);
6168
+ settle(null, bytes);
6169
+ });
6170
+ proc.stdin?.on("error", () => {});
6171
+ proc.stdin?.write(Buffer.from(input.mp4));
6172
+ proc.stdin?.end();
6173
+ });
6174
+ }
5747
6175
  //#endregion
5748
6176
  //#region src/stream-broker/stream-broker/profile-restreamers.ts
5749
6177
  /**
@@ -12541,79 +12969,6 @@ function hexToBytes(hex) {
12541
12969
  }
12542
12970
  return out;
12543
12971
  }
12544
- /**
12545
- * Minimum gap between two re-dials of the same device. A re-dial costs a
12546
- * ~2 s video interruption on every source of the camera; at the 30 s
12547
- * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
12548
- * cannot come back would be a permanent stutter.
12549
- */
12550
- var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
12551
- var EMPTY_AUDIO_PLANE_STATE = {
12552
- disagreements: 0,
12553
- redials: 0,
12554
- lastRedialMs: 0,
12555
- gaveUp: false
12556
- };
12557
- /**
12558
- * Decide what one pass should do. Pure: no clock, no I/O, no broker.
12559
- *
12560
- * `nowMs` is passed in rather than read so the cooldown is testable without
12561
- * waiting five minutes for it.
12562
- */
12563
- function decideAudioPlaneAction(observation, state, nowMs) {
12564
- switch (observation.kind) {
12565
- case "broker-muted":
12566
- case "no-live-source":
12567
- case "audio-present":
12568
- case "declared-off": return {
12569
- action: { kind: "none" },
12570
- nextState: EMPTY_AUDIO_PLANE_STATE
12571
- };
12572
- case "read-failed":
12573
- case "audio-unknown": return {
12574
- action: { kind: "none" },
12575
- nextState: state
12576
- };
12577
- case "declared-on": {
12578
- if (state.gaveUp) return {
12579
- action: { kind: "none" },
12580
- nextState: state
12581
- };
12582
- const disagreements = state.disagreements + 1;
12583
- if (disagreements < 2) return {
12584
- action: { kind: "await-second-pass" },
12585
- nextState: {
12586
- ...state,
12587
- disagreements
12588
- }
12589
- };
12590
- if (state.redials >= 3) return {
12591
- action: { kind: "give-up" },
12592
- nextState: {
12593
- ...state,
12594
- disagreements,
12595
- gaveUp: true
12596
- }
12597
- };
12598
- if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
12599
- action: { kind: "cooldown" },
12600
- nextState: {
12601
- ...state,
12602
- disagreements: 2
12603
- }
12604
- };
12605
- return {
12606
- action: { kind: "redial" },
12607
- nextState: {
12608
- disagreements: 0,
12609
- redials: state.redials + 1,
12610
- lastRedialMs: nowMs,
12611
- gaveUp: false
12612
- }
12613
- };
12614
- }
12615
- }
12616
- }
12617
12972
  //#endregion
12618
12973
  //#region src/stream-broker/stream-broker/transcode-pipeline.ts
12619
12974
  /**
@@ -13176,13 +13531,6 @@ function formatClockHHMM(at) {
13176
13531
  * reach `streaming` with audio detected, and the stats poll cadence. */
13177
13532
  var PROBE_STREAM_TIMEOUT_MS = 6e3;
13178
13533
  var PROBE_STREAM_POLL_MS = 250;
13179
- /**
13180
- * Seconds of encoded video kept for notification clips, on the cheapest
13181
- * assigned profile only. Sized to cover the longest allowed pre-roll (20 s)
13182
- * plus the seconds a rule takes to reach the dispatcher; the low-profile
13183
- * rendition costs a few MB for that window, and nothing at all while the
13184
- * camera is idle.
13185
- */
13186
13534
  var CLIP_RETENTION_SEC = 25;
13187
13535
  /** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
13188
13536
  var CLIP_PROFILE_BY_COST = [
@@ -14905,6 +15253,160 @@ var StreamBrokerManager = class StreamBrokerManager {
14905
15253
  * of the event, not of the present.
14906
15254
  */
14907
15255
  async renderPreBufferClip(input) {
15256
+ const cut = await this.selectClipWindow({
15257
+ deviceId: input.deviceId,
15258
+ aroundMs: input.aroundMs,
15259
+ preRollSec: input.preRollSec,
15260
+ postRollSec: input.postRollSec,
15261
+ ...input.profile !== void 0 ? { profile: input.profile } : {}
15262
+ });
15263
+ const bytes = await this.muxClipWindow(cut, {
15264
+ format: input.format,
15265
+ maxWidth: input.maxWidth,
15266
+ fps: input.fps,
15267
+ ...input.speed !== void 0 ? { speed: input.speed } : {}
15268
+ });
15269
+ const first = cut.window[0];
15270
+ const last = cut.window[cut.window.length - 1];
15271
+ return {
15272
+ base64: Buffer.from(bytes).toString("base64"),
15273
+ mime: input.format === "gif" ? "image/gif" : "video/mp4",
15274
+ bytes: bytes.byteLength,
15275
+ profile: cut.profile,
15276
+ durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
15277
+ };
15278
+ }
15279
+ /**
15280
+ * ONE cut of an event, in every container the caller asked for — the
15281
+ * production point every consumer of event media goes through.
15282
+ *
15283
+ * See the cap docblock for why this is one method rather than one call per
15284
+ * format. The short version is that `renderPreBufferClip` renders one format
15285
+ * per call, so a caller wanting a gif AND a video made two calls, got two
15286
+ * windows, and shipped a notification whose two attachments showed different
15287
+ * moments.
15288
+ *
15289
+ * The window comes out of the broker's own AnnexB retention ring — packets
15290
+ * that were already in RAM — so a production reaches BACKWARDS from the event
15291
+ * with nothing spawned and nothing kept warm. The mp4 is muxed once, COPYING
15292
+ * the camera's own H.264 whenever nothing is being changed, and any gif is
15293
+ * derived from that same mp4 rather than rendered a second time from the
15294
+ * packets: same window, same pixels, by construction.
15295
+ */
15296
+ async produceEventMedia(input) {
15297
+ const preferred = input.profile ?? this.bestClipProfile(input.deviceId) ?? void 0;
15298
+ const cut = await this.selectClipWindow({
15299
+ deviceId: input.deviceId,
15300
+ aroundMs: input.aroundMs,
15301
+ preRollSec: input.preSeconds,
15302
+ postRollSec: input.postSeconds,
15303
+ ...preferred !== void 0 ? { profile: preferred } : {}
15304
+ });
15305
+ const clipWidth = eventClipEncodeWidth(cut.sourceWidth, input.speed);
15306
+ const mp4 = await this.muxClipWindow(cut, {
15307
+ format: "mp4",
15308
+ maxWidth: clipWidth,
15309
+ fps: 0,
15310
+ speed: input.speed
15311
+ });
15312
+ const media = [];
15313
+ if (input.kinds.includes("mp4")) media.push({
15314
+ kind: "mp4",
15315
+ handle: this.eventMedia.put({
15316
+ deviceId: input.deviceId,
15317
+ kind: "mp4",
15318
+ mime: "video/mp4",
15319
+ bytes: mp4
15320
+ }),
15321
+ nodeId: this.localNodeId ?? "",
15322
+ mime: "video/mp4",
15323
+ bytes: mp4.byteLength,
15324
+ width: clipWidth,
15325
+ height: scaledHeight(cut, clipWidth)
15326
+ });
15327
+ const gifSpeed = input.gifSpeed ?? 4;
15328
+ if (input.kinds.includes("gif")) {
15329
+ const gif = await gifFromMp4({
15330
+ mp4,
15331
+ deviceId: input.deviceId,
15332
+ maxWidth: input.gifMaxWidth,
15333
+ fps: input.gifFps,
15334
+ speed: gifDeriveSpeed(gifSpeed, input.speed),
15335
+ ...this.logger !== void 0 ? { logger: this.logger } : {},
15336
+ spawnFn: node_child_process.spawn,
15337
+ ...this.ffmpegConfig.binaryPath !== void 0 ? { ffmpegPath: this.ffmpegConfig.binaryPath } : {}
15338
+ });
15339
+ if (gif !== null) {
15340
+ const height = scaledHeight(cut, input.gifMaxWidth);
15341
+ media.push({
15342
+ kind: "gif",
15343
+ handle: this.eventMedia.put({
15344
+ deviceId: input.deviceId,
15345
+ kind: "gif",
15346
+ mime: "image/gif",
15347
+ bytes: gif
15348
+ }),
15349
+ nodeId: this.localNodeId ?? "",
15350
+ mime: "image/gif",
15351
+ bytes: gif.byteLength,
15352
+ width: input.gifMaxWidth,
15353
+ height
15354
+ });
15355
+ }
15356
+ }
15357
+ const video = this.clipVideoMode(cut, {
15358
+ format: "mp4",
15359
+ maxWidth: clipWidth,
15360
+ speed: input.speed
15361
+ });
15362
+ this.logger?.info("event media produced — one window, every container", {
15363
+ tags: { deviceId: input.deviceId },
15364
+ meta: {
15365
+ profile: cut.profile,
15366
+ video,
15367
+ resolution: `${cut.sourceWidth}x${cut.sourceHeight}`,
15368
+ packets: cut.window.length,
15369
+ kinds: [...input.kinds].join(","),
15370
+ artifacts: media.map((m) => `${m.kind}:${m.bytes}`).join(" "),
15371
+ speed: input.speed,
15372
+ gifSpeed,
15373
+ startOffsetMs: cut.coverage.fromTs - input.aroundMs,
15374
+ endOffsetMs: cut.coverage.toTs - input.aroundMs
15375
+ }
15376
+ });
15377
+ return {
15378
+ media,
15379
+ coverage: {
15380
+ fromTs: cut.coverage.fromTs,
15381
+ toTs: cut.coverage.toTs,
15382
+ packets: cut.window.length
15383
+ },
15384
+ profile: cut.profile,
15385
+ video
15386
+ };
15387
+ }
15388
+ /** Redeem an artifact handle. `null` once it has expired (see
15389
+ * {@link EventMediaStore}). */
15390
+ async fetchEventMedia(input) {
15391
+ const item = this.eventMedia.get(input.handle);
15392
+ if (item === null) {
15393
+ this.logger?.warn("event media: handle expired or unknown — nothing to fetch", { meta: { handle: input.handle } });
15394
+ return null;
15395
+ }
15396
+ return {
15397
+ base64: Buffer.from(item.bytes).toString("base64"),
15398
+ mime: item.mime,
15399
+ bytes: item.bytes.length
15400
+ };
15401
+ }
15402
+ /**
15403
+ * Wait out the post-roll, then select the retained window around the moment.
15404
+ *
15405
+ * Shared by `renderPreBufferClip` and `produceEventMedia` deliberately: two
15406
+ * copies of "which profile, which packets, which resolution" is exactly how
15407
+ * the two paths would start disagreeing again.
15408
+ */
15409
+ async selectClipWindow(input) {
14908
15410
  const map = this.assignments.get(input.deviceId)?.map ?? {};
14909
15411
  const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
14910
15412
  const wanted = pickClipProfile(assigned, input.profile);
@@ -14915,19 +15417,26 @@ var StreamBrokerManager = class StreamBrokerManager {
14915
15417
  const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
14916
15418
  let profile = null;
14917
15419
  let window = [];
15420
+ let coverage = {
15421
+ fromTs: input.aroundMs,
15422
+ toTs: input.aroundMs
15423
+ };
14918
15424
  for (const candidate of candidates) {
14919
15425
  const broker = this.brokerFor(input.deviceId, map[candidate]);
14920
15426
  if (!broker) continue;
15427
+ const packets = broker.getClipBuffer();
15428
+ const nowMs = Date.now();
14921
15429
  const found = windowAround({
14922
- packets: broker.getClipBuffer(),
15430
+ packets,
14923
15431
  aroundMs: input.aroundMs,
14924
15432
  preRollSec: input.preRollSec,
14925
15433
  postRollSec: input.postRollSec,
14926
- nowMs: Date.now()
15434
+ nowMs
14927
15435
  });
14928
15436
  if (found.length > 0) {
14929
15437
  profile = candidate;
14930
15438
  window = found;
15439
+ coverage = windowCoverage(packets, found, nowMs);
14931
15440
  break;
14932
15441
  }
14933
15442
  }
@@ -14941,29 +15450,77 @@ var StreamBrokerManager = class StreamBrokerManager {
14941
15450
  });
14942
15451
  const broker = this.brokerFor(input.deviceId, map[profile]);
14943
15452
  if (!broker) throw new Error(`No live broker for device ${input.deviceId} (${profile})`);
14944
- const bytes = await renderPreBufferClip({
15453
+ const cam = this.cameraStreams.get(input.deviceId)?.get(map[profile] ?? "");
15454
+ return {
15455
+ deviceId: input.deviceId,
15456
+ profile,
14945
15457
  window,
14946
- format: input.format,
14947
- maxWidth: input.maxWidth,
14948
- fps: input.fps,
14949
- ...input.speed !== void 0 ? { speed: input.speed } : {},
15458
+ coverage,
15459
+ broker,
15460
+ sourceWidth: cam?.resolution?.width ?? 0,
15461
+ sourceHeight: cam?.resolution?.height ?? 0
15462
+ };
15463
+ }
15464
+ /** The rendition an event production prefers for this camera, or null when
15465
+ * nothing qualifies (every slot H.265, or none publishes a resolution). */
15466
+ bestClipProfile(deviceId) {
15467
+ const map = this.assignments.get(deviceId)?.map ?? {};
15468
+ const streams = this.cameraStreams.get(deviceId);
15469
+ const candidates = [];
15470
+ for (const profile of CLIP_PROFILE_BY_COST) {
15471
+ const camStreamId = map[profile];
15472
+ if (camStreamId === void 0) continue;
15473
+ const cam = streams?.get(camStreamId);
15474
+ candidates.push({
15475
+ profile,
15476
+ ...cam?.codec !== void 0 ? { codec: cam.codec } : {},
15477
+ ...cam?.resolution?.width !== void 0 ? { width: cam.resolution.width } : {},
15478
+ ...cam?.resolution?.height !== void 0 ? { height: cam.resolution.height } : {}
15479
+ });
15480
+ }
15481
+ return pickBestClipProfile(candidates);
15482
+ }
15483
+ /** `copy` or `encode` for this cut — reported, never inferred from a size. */
15484
+ clipVideoMode(cut, render) {
15485
+ return clipCanCopy({
15486
+ format: render.format,
15487
+ maxWidth: render.maxWidth,
15488
+ ...render.speed !== void 0 ? { speed: render.speed } : {},
15489
+ hevc: isHevcPacket(cut.window[0]),
15490
+ sourceWidth: cut.sourceWidth
15491
+ }) ? "copy" : "encode";
15492
+ }
15493
+ async muxClipWindow(cut, render) {
15494
+ const bytes = await renderPreBufferClip({
15495
+ window: cut.window,
15496
+ format: render.format,
15497
+ maxWidth: render.maxWidth,
15498
+ fps: render.fps,
15499
+ sourceWidth: cut.sourceWidth,
15500
+ ...render.speed !== void 0 ? { speed: render.speed } : {},
14950
15501
  spawn: (cmd, args) => (0, node_child_process.spawn)(cmd, [...args], { stdio: [
14951
15502
  "pipe",
14952
15503
  "pipe",
14953
15504
  "pipe"
14954
15505
  ] }),
14955
15506
  ffmpegPath: this.ffmpegConfig.binaryPath,
14956
- sdpParameterSets: broker.getSdpParameterSets()
15507
+ sdpParameterSets: cut.broker.getSdpParameterSets()
14957
15508
  });
14958
- const first = window[0];
14959
- const last = window[window.length - 1];
14960
- return {
14961
- base64: bytes.toString("base64"),
14962
- mime: input.format === "gif" ? "image/gif" : "video/mp4",
14963
- bytes: bytes.byteLength,
14964
- profile,
14965
- durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
14966
- };
15509
+ this.logger?.info("pre-buffer clip rendered", {
15510
+ tags: { deviceId: cut.deviceId },
15511
+ meta: {
15512
+ profile: cut.profile,
15513
+ format: render.format,
15514
+ video: this.clipVideoMode(cut, render),
15515
+ sourceWidth: cut.sourceWidth,
15516
+ maxWidth: render.maxWidth,
15517
+ speed: render.speed ?? null,
15518
+ packets: cut.window.length,
15519
+ bytes: bytes.byteLength,
15520
+ coverageMs: cut.coverage.toTs - cut.coverage.fromTs
15521
+ }
15522
+ });
15523
+ return bytes;
14967
15524
  }
14968
15525
  getCameraStreamsForDevice(deviceId) {
14969
15526
  const streamMap = this.cameraStreams.get(deviceId);
@@ -16403,6 +16960,8 @@ var StreamBrokerManager = class StreamBrokerManager {
16403
16960
  * within the minute — a rule asking for `high` fell back to `low` forever
16404
16961
  * and looked like the choice did nothing.
16405
16962
  */
16963
+ /** Produced event artifacts waiting to be fetched by handle (D9/D18). */
16964
+ eventMedia = new EventMediaStore();
16406
16965
  clipRetentionArmed = /* @__PURE__ */ new Map();
16407
16966
  armClipRetention(deviceId, profile) {
16408
16967
  const armed = this.clipRetentionArmed.get(deviceId) ?? /* @__PURE__ */ new Set();
@@ -17068,6 +17627,17 @@ var StreamBrokerManager = class StreamBrokerManager {
17068
17627
  }
17069
17628
  }
17070
17629
  };
17630
+ /**
17631
+ * The height a render lands at, once `scale=W:-2` has picked it.
17632
+ *
17633
+ * `-2` means "keep the aspect, round to an EVEN number" — libx264 rejects an
17634
+ * odd dimension in yuv420p. Reporting the arithmetic without the rounding is
17635
+ * how an artifact ends up one pixel taller than the file it describes.
17636
+ */
17637
+ function scaledHeight(cut, width) {
17638
+ if (cut.sourceWidth <= 0 || cut.sourceHeight <= 0) return 0;
17639
+ return 2 * Math.round(width / cut.sourceWidth * cut.sourceHeight / 2);
17640
+ }
17071
17641
  //#endregion
17072
17642
  //#region src/stream-broker/encode-profile-resolver.ts
17073
17643
  /**