@camstack/addon-pipeline 1.2.46 → 1.2.47

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/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-CPfFUIl9.js → dist-C9Q1t0nT.js} +135 -0
  6. package/dist/{dist-CUj9mdhh.mjs → dist-CfxC7XoI.mjs} +135 -0
  7. package/dist/{event-loop-stall-monitor-md__iwWh.mjs → event-loop-stall-monitor-B_XUPTwp.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-BGbgcrEN.js → event-loop-stall-monitor-Luq9AX4t.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-SKj0Zdz2.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-KpixNEma.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-rd7UaqfV.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-CRwWRwJH.mjs → hostInit-D8gyxL0j.mjs} +2 -2
  22. package/dist/stream-broker/index.js +603 -99
  23. package/dist/stream-broker/index.mjs +603 -99
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-hdTR-F5q.js → worker-protocol-BUJpm7l3.js} +1 -1
  26. package/dist/{worker-protocol-CD0M9YD7.mjs → worker-protocol-rqbyKlm9.mjs} +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C8eGXtf-.js → MaskShapeCanvas-DI4BY7W2-DwXIyaHw.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-vvEcAgxS.js → MotionZonesSettings-NcxxQN8r-Cf8EtWEG.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BLhx7VI-.js → PrivacyMaskSettings-APgPLF7p-BvsVXya9.js} +1 -1
  30. package/embed-dist/assets/{index-DvOgpqcE.js → index-BuAeIGv8.js} +12 -12
  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
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
2
- import { $ as errMsg, At as EventCategory, Dt as record, E as createHwAccelCache, Et as object, N as egressTranscodeSharingKey, Ot as string, P as egressTransportFromRequest, Q as webrtcSessionCapability, S as addonWidgetsSourceCapability, St as discriminatedUnion, T as cameraStreamsCapability, Tt as number, X as streamBrokerCapability, _ as RATE_CONTROL_RELAXED, at as BaseAddon, b as RingBuffer, bt as array, ct as DeviceType, d as EncodeProfileSchema, dt as isEvent, et as AUDIO_PRESETS, ft as makeProfileBrokerId, gt as parseProfileBrokerId, it as isSoftwareDecode, kt as union, lt as createEvent, mt as nodePin, nt as buildFfmpegArgs$1, ot as CAM_PROFILE_ORDER, pt as makeSourceBrokerId, q as resolveEgressDecodeHwAccel, rt as invocationFromEncodeProfile, st as DeviceFeature, tt as Fmp4BoxSplitter, v as RATE_CONTROL_TIGHT, wt as literal, xt as boolean, yt as _enum, z as maskUrlCredentials } from "../dist-CUj9mdhh.mjs";
2
+ import { $ as errMsg, At as EventCategory, Dt as record, E as createHwAccelCache, Et as object, N as egressTranscodeSharingKey, Ot as string, P as egressTransportFromRequest, Q as webrtcSessionCapability, S as addonWidgetsSourceCapability, St as discriminatedUnion, T as cameraStreamsCapability, Tt as number, X as streamBrokerCapability, _ as RATE_CONTROL_RELAXED, at as BaseAddon, b as RingBuffer, bt as array, ct as DeviceType, d as EncodeProfileSchema, dt as isEvent, et as AUDIO_PRESETS, ft as makeProfileBrokerId, gt as parseProfileBrokerId, it as isSoftwareDecode, kt as union, lt as createEvent, mt as nodePin, nt as buildFfmpegArgs$1, ot as CAM_PROFILE_ORDER, pt as makeSourceBrokerId, q as resolveEgressDecodeHwAccel, rt as invocationFromEncodeProfile, st as DeviceFeature, tt as Fmp4BoxSplitter, v as RATE_CONTROL_TIGHT, wt as literal, xt as boolean, yt as _enum, z as maskUrlCredentials } from "../dist-CfxC7XoI.mjs";
3
3
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
4
4
  import { n as createFileDataPlaneHandler } from "../model-download-service-D8B-4ktF-BB7oZL3y.mjs";
5
5
  import { createRequire } from "node:module";
@@ -3005,6 +3005,79 @@ function buildAudioCodecCapApi(api) {
3005
3005
  flushEncode: (input) => router.flushEncode.mutate(input)
3006
3006
  };
3007
3007
  }
3008
+ /**
3009
+ * Minimum gap between two re-dials of the same device. A re-dial costs a
3010
+ * ~2 s video interruption on every source of the camera; at the 30 s
3011
+ * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
3012
+ * cannot come back would be a permanent stutter.
3013
+ */
3014
+ var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
3015
+ var EMPTY_AUDIO_PLANE_STATE = {
3016
+ disagreements: 0,
3017
+ redials: 0,
3018
+ lastRedialMs: 0,
3019
+ gaveUp: false
3020
+ };
3021
+ /**
3022
+ * Decide what one pass should do. Pure: no clock, no I/O, no broker.
3023
+ *
3024
+ * `nowMs` is passed in rather than read so the cooldown is testable without
3025
+ * waiting five minutes for it.
3026
+ */
3027
+ function decideAudioPlaneAction(observation, state, nowMs) {
3028
+ switch (observation.kind) {
3029
+ case "broker-muted":
3030
+ case "no-live-source":
3031
+ case "audio-present":
3032
+ case "declared-off": return {
3033
+ action: { kind: "none" },
3034
+ nextState: EMPTY_AUDIO_PLANE_STATE
3035
+ };
3036
+ case "read-failed":
3037
+ case "audio-unknown": return {
3038
+ action: { kind: "none" },
3039
+ nextState: state
3040
+ };
3041
+ case "declared-on": {
3042
+ if (state.gaveUp) return {
3043
+ action: { kind: "none" },
3044
+ nextState: state
3045
+ };
3046
+ const disagreements = state.disagreements + 1;
3047
+ if (disagreements < 2) return {
3048
+ action: { kind: "await-second-pass" },
3049
+ nextState: {
3050
+ ...state,
3051
+ disagreements
3052
+ }
3053
+ };
3054
+ if (state.redials >= 3) return {
3055
+ action: { kind: "give-up" },
3056
+ nextState: {
3057
+ ...state,
3058
+ disagreements,
3059
+ gaveUp: true
3060
+ }
3061
+ };
3062
+ if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
3063
+ action: { kind: "cooldown" },
3064
+ nextState: {
3065
+ ...state,
3066
+ disagreements: 2
3067
+ }
3068
+ };
3069
+ return {
3070
+ action: { kind: "redial" },
3071
+ nextState: {
3072
+ disagreements: 0,
3073
+ redials: state.redials + 1,
3074
+ lastRedialMs: nowMs,
3075
+ gaveUp: false
3076
+ }
3077
+ };
3078
+ }
3079
+ }
3080
+ }
3008
3081
  //#endregion
3009
3082
  //#region src/stream-broker/stream-broker/dead-decode-backends.ts
3010
3083
  /**
@@ -5025,6 +5098,190 @@ var EgressTranscodeManager = class {
5025
5098
  };
5026
5099
  }
5027
5100
  };
5101
+ var EventMediaStore = class {
5102
+ deps;
5103
+ /** Insertion-ordered — `Map` iteration order IS the eviction order. */
5104
+ items = /* @__PURE__ */ new Map();
5105
+ totalBytes = 0;
5106
+ constructor(deps = {}) {
5107
+ this.deps = deps;
5108
+ }
5109
+ get size() {
5110
+ return this.items.size;
5111
+ }
5112
+ get bytes() {
5113
+ return this.totalBytes;
5114
+ }
5115
+ /** Store one artifact and return its handle. */
5116
+ put(input) {
5117
+ const now = this.deps.now ?? Date.now;
5118
+ this.sweep();
5119
+ const handle = `evm_${input.deviceId}_${(this.deps.makeId ?? defaultId)()}`;
5120
+ this.items.set(handle, {
5121
+ handle,
5122
+ storedAt: now(),
5123
+ ...input
5124
+ });
5125
+ this.totalBytes += input.bytes.length;
5126
+ const maxBytes = this.deps.maxBytes ?? 67108864;
5127
+ while (this.totalBytes > maxBytes && this.items.size > 1) {
5128
+ const oldest = this.items.keys().next().value;
5129
+ if (oldest === void 0) break;
5130
+ this.drop(oldest);
5131
+ }
5132
+ return handle;
5133
+ }
5134
+ /** Redeem a handle. `null` when it never existed or has expired. */
5135
+ get(handle) {
5136
+ this.sweep();
5137
+ return this.items.get(handle) ?? null;
5138
+ }
5139
+ /** Drop everything — shutdown. */
5140
+ clear() {
5141
+ this.items.clear();
5142
+ this.totalBytes = 0;
5143
+ }
5144
+ sweep() {
5145
+ const now = (this.deps.now ?? Date.now)();
5146
+ const ttl = this.deps.ttlMs ?? 3e5;
5147
+ for (const [handle, item] of this.items) {
5148
+ if (now - item.storedAt < ttl) break;
5149
+ this.drop(handle);
5150
+ }
5151
+ }
5152
+ drop(handle) {
5153
+ const item = this.items.get(handle);
5154
+ if (item === void 0) return;
5155
+ this.items.delete(handle);
5156
+ this.totalBytes -= item.bytes.length;
5157
+ }
5158
+ };
5159
+ var counter = 0;
5160
+ function defaultId() {
5161
+ counter += 1;
5162
+ return `${Date.now().toString(36)}${counter.toString(36)}`;
5163
+ }
5164
+ /** A derive that has not finished by now will not finish inside the drain. */
5165
+ var GIF_RENDER_TIMEOUT_MS = 2e4;
5166
+ /**
5167
+ * The filter chain. Extracted so a test can assert the palette pair without
5168
+ * spawning ffmpeg — an argv test cannot see quality, but it can see a filter
5169
+ * that somebody deleted.
5170
+ */
5171
+ function gifFilterComplex(input) {
5172
+ const setpts = input.speed !== 1 ? `,setpts=PTS/${input.speed}` : "";
5173
+ return `[0:v]fps=${input.fps}${setpts},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`;
5174
+ }
5175
+ /** The full argv, for the same reason. */
5176
+ function gifFromMp4Args(input) {
5177
+ return [
5178
+ "-y",
5179
+ "-v",
5180
+ "error",
5181
+ "-f",
5182
+ "mp4",
5183
+ "-i",
5184
+ "pipe:0",
5185
+ "-filter_complex",
5186
+ gifFilterComplex(input),
5187
+ "-loop",
5188
+ "0",
5189
+ "-f",
5190
+ "gif",
5191
+ "pipe:1"
5192
+ ];
5193
+ }
5194
+ /**
5195
+ * Derive the gif. `null` on every failure — a notification that lost its gif is
5196
+ * a notification, and one that failed is not.
5197
+ *
5198
+ * The child is guaranteed dead before this settles: these run on every
5199
+ * notification that asks for a gif, and a leaked ffmpeg accumulates on the node
5200
+ * until somebody notices the host is slow.
5201
+ */
5202
+ async function gifFromMp4(input) {
5203
+ const args = gifFromMp4Args({
5204
+ maxWidth: input.maxWidth ?? 640,
5205
+ fps: input.fps ?? 8,
5206
+ speed: input.speed ?? 1
5207
+ });
5208
+ const startedAt = Date.now();
5209
+ try {
5210
+ const bytes = await runGif(input, args);
5211
+ input.logger?.info("nc gif: derived from the notification video", {
5212
+ tags: { deviceId: input.deviceId },
5213
+ meta: {
5214
+ sourceBytes: input.mp4.length,
5215
+ gifBytes: bytes.length,
5216
+ width: input.maxWidth ?? 640,
5217
+ fps: input.fps ?? 8,
5218
+ encodeMs: Date.now() - startedAt
5219
+ }
5220
+ });
5221
+ return bytes;
5222
+ } catch (err) {
5223
+ input.logger?.warn("nc gif: the derive failed — this notification ships without a gif", {
5224
+ tags: { deviceId: input.deviceId },
5225
+ meta: {
5226
+ sourceBytes: input.mp4.length,
5227
+ error: err instanceof Error ? err.message : String(err)
5228
+ }
5229
+ });
5230
+ return null;
5231
+ }
5232
+ }
5233
+ function runGif(input, args) {
5234
+ return new Promise((resolve, reject) => {
5235
+ const proc = input.spawnFn(input.ffmpegPath ?? "ffmpeg", [...args], { stdio: [
5236
+ "pipe",
5237
+ "pipe",
5238
+ "pipe"
5239
+ ] });
5240
+ const chunks = [];
5241
+ let stderrTail = "";
5242
+ let done = false;
5243
+ let timer = null;
5244
+ const settle = (err, out) => {
5245
+ if (done) return;
5246
+ done = true;
5247
+ if (timer !== null) clearTimeout(timer);
5248
+ if (proc.exitCode === null && proc.signalCode === null) try {
5249
+ proc.kill("SIGKILL");
5250
+ } catch {}
5251
+ try {
5252
+ proc.stdout?.removeAllListeners();
5253
+ proc.stderr?.removeAllListeners();
5254
+ proc.stdin?.destroy();
5255
+ } catch {}
5256
+ if (err !== null) reject(err);
5257
+ else resolve(out ?? new Uint8Array(0));
5258
+ };
5259
+ timer = setTimeout(() => settle(/* @__PURE__ */ new Error("gif derive timed out")), GIF_RENDER_TIMEOUT_MS);
5260
+ timer.unref?.();
5261
+ proc.stdout?.on("data", (c) => chunks.push(c));
5262
+ proc.stderr?.on("data", (c) => {
5263
+ stderrTail = (stderrTail + c.toString()).slice(-400);
5264
+ });
5265
+ proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
5266
+ proc.on("close", (code) => {
5267
+ if (code !== 0) {
5268
+ settle(/* @__PURE__ */ new Error(`ffmpeg gif derive exited ${code}: ${stderrTail}`));
5269
+ return;
5270
+ }
5271
+ const out = Buffer.concat(chunks);
5272
+ if (out.byteLength === 0) {
5273
+ settle(/* @__PURE__ */ new Error("gif derive produced no bytes"));
5274
+ return;
5275
+ }
5276
+ const bytes = new Uint8Array(out.byteLength);
5277
+ bytes.set(out);
5278
+ settle(null, bytes);
5279
+ });
5280
+ proc.stdin?.on("error", () => {});
5281
+ proc.stdin?.write(Buffer.from(input.mp4));
5282
+ proc.stdin?.end();
5283
+ });
5284
+ }
5028
5285
  //#endregion
5029
5286
  //#region src/stream-broker/webrtc/nal-utils.ts
5030
5287
  var NAL_START_CODE_4B = Buffer.from([
@@ -5549,6 +5806,46 @@ function pickClipProfile(assigned, requested) {
5549
5806
  return null;
5550
5807
  }
5551
5808
  /**
5809
+ * The tallest rendition an event clip is cut from.
5810
+ *
5811
+ * 1080 and not 720: the attachment is native-resolution H.264 that nothing
5812
+ * re-encodes, and the operator asked for a clip they could actually watch. A
5813
+ * few seconds of 4K copy is megabytes, which is the difference between "every
5814
+ * notifier takes it" and "pushover drops it at 5 MiB".
5815
+ */
5816
+ var EVENT_CLIP_MAX_HEIGHT = 1080;
5817
+ /**
5818
+ * The rendition an EVENT production should cut from — the opposite default to
5819
+ * {@link pickClipProfile}.
5820
+ *
5821
+ * `pickClipProfile` takes the cheapest assigned slot, which was right when a
5822
+ * notification attachment was a 480 px thumbnail with motion. It is wrong now:
5823
+ * on this fleet the cheapest slot is a 640×360 substream, and a gif derived
5824
+ * from it is the "de-pixellated" attachment the operator reported. So an event
5825
+ * production prefers the LARGEST H.264 rendition at or below
5826
+ * {@link EVENT_CLIP_MAX_HEIGHT}:
5827
+ *
5828
+ * - **H.264, because that is what can be COPIED.** An H.265 slot forces a
5829
+ * re-encode, which is both expensive and not universally playable on the
5830
+ * phones notifications land on.
5831
+ * - **At or below 1080p**, falling back to the smallest slot above it when a
5832
+ * camera offers nothing else — a 4K-only camera still gets a clip, it just
5833
+ * pays for it.
5834
+ *
5835
+ * `null` when no rendition qualifies; the caller then falls back to
5836
+ * `pickClipProfile` and the answer reports which was used.
5837
+ */
5838
+ function pickBestClipProfile(candidates) {
5839
+ const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
5840
+ if (usable.length === 0) return null;
5841
+ const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
5842
+ 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;
5843
+ }
5844
+ /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
5845
+ function normaliseCodec$1(codec) {
5846
+ return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
5847
+ }
5848
+ /**
5552
5849
  * Placeholder packets ("RECONNECTING"/"SLEEPING" keyframes the broker pumps at
5553
5850
  * 1 Hz while no RTP flows) are a DIFFERENT resolution and parameter set —
5554
5851
  * muxing them into a clip corrupts the decode and produces a file that freezes
@@ -5605,6 +5902,33 @@ function windowAround(input) {
5605
5902
  if (momentPts > newest.pts || momentPts < oldest.pts) return [];
5606
5903
  return sliceFromKeyframe(video, startPts, endPts);
5607
5904
  }
5905
+ /**
5906
+ * What the selected window covers, in WALL CLOCK.
5907
+ *
5908
+ * The same anchoring {@link windowAround} selects by, and for the same reason:
5909
+ * `pts` is a stream clock with an arbitrary origin, so the only fixed point is
5910
+ * that the newest retained packet arrived (near enough) at `nowMs`. Every
5911
+ * earlier packet is `newest.pts − p.pts` milliseconds before it.
5912
+ *
5913
+ * This exists so a caller can say "the clip starts 4.1 s BEFORE the event"
5914
+ * instead of inferring it from a duration and a hope. A production whose
5915
+ * `fromTs` is after the event has no pre-roll, which is precisely the defect
5916
+ * that has to be visible rather than plausible.
5917
+ */
5918
+ function windowCoverage(packets, window, nowMs) {
5919
+ const video = decodableVideo(packets);
5920
+ const newest = video[video.length - 1];
5921
+ const first = window[0];
5922
+ const last = window[window.length - 1];
5923
+ if (newest === void 0 || first === void 0 || last === void 0) return {
5924
+ fromTs: nowMs,
5925
+ toTs: nowMs
5926
+ };
5927
+ return {
5928
+ fromTs: nowMs - (newest.pts - first.pts),
5929
+ toTs: nowMs - (newest.pts - last.pts)
5930
+ };
5931
+ }
5608
5932
  /** True for an HEVC packet — the container flag and the demuxer both differ. */
5609
5933
  function isHevcPacket(packet) {
5610
5934
  const codec = (packet?.codec ?? "h264").toLowerCase();
@@ -5637,6 +5961,36 @@ function toElementaryStream(window, sdpParameterSets) {
5637
5961
  return Buffer.concat(parts);
5638
5962
  }
5639
5963
  var RENDER_TIMEOUT_MS = 2e4;
5964
+ /**
5965
+ * Whether this render can COPY the camera's own H.264 instead of re-encoding it.
5966
+ *
5967
+ * The ring already holds the camera's elementary stream. Re-encoding it is only
5968
+ * ever necessary to change something, so the question is entirely "is anything
5969
+ * being changed":
5970
+ *
5971
+ * - **A scale is a change.** Only when the caller asks for at least the
5972
+ * source's own width is the `scale` filter an identity. `sourceWidth === 0`
5973
+ * means nobody knows the source's width, and a copy would then be a guess —
5974
+ * so it re-encodes, which is what it always did.
5975
+ * - **A speed is a change.** `setpts` rewrites the timeline.
5976
+ * - **H.265 is a change we WANT.** The mp4 lands on phones, and HEVC in an mp4
5977
+ * is not universally playable there; transcoding an H.265 camera's clip to
5978
+ * H.264 is the one case where the re-encode is the feature. (It is also why
5979
+ * the clip ring remains the fallback for an H.265-only camera rather than
5980
+ * something to delete.)
5981
+ * - **A GIF is a re-encode by definition.**
5982
+ *
5983
+ * Copying is what makes a notification clip the camera's own 1280×720 picture
5984
+ * rather than `libx264 -crf 28` at 480 px — measured on 615, the same eleven
5985
+ * seconds are 224 KB copied against a visibly softer re-encode, and the gif
5986
+ * derived from it stops looking pixellated for the same reason.
5987
+ */
5988
+ function clipCanCopy(input) {
5989
+ if (input.format !== "mp4") return false;
5990
+ if (input.hevc === true) return false;
5991
+ if ((input.speed ?? 2) !== 1) return false;
5992
+ return input.sourceWidth > 0 && input.maxWidth >= input.sourceWidth;
5993
+ }
5640
5994
  /** ffmpeg args for the chosen container, reading an annex-B stream from stdin. */
5641
5995
  function clipFfmpegArgs(input) {
5642
5996
  const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
@@ -5657,6 +6011,21 @@ function clipFfmpegArgs(input) {
5657
6011
  "gif",
5658
6012
  "pipe:1"
5659
6013
  ];
6014
+ if (input.copyVideo === true) return [
6015
+ "-y",
6016
+ "-f",
6017
+ demuxer,
6018
+ "-i",
6019
+ "pipe:0",
6020
+ "-c:v",
6021
+ "copy",
6022
+ "-movflags",
6023
+ "frag_keyframe+empty_moov",
6024
+ "-an",
6025
+ "-f",
6026
+ "mp4",
6027
+ "pipe:1"
6028
+ ];
5660
6029
  return [
5661
6030
  "-y",
5662
6031
  "-f",
@@ -5693,9 +6062,17 @@ async function renderPreBufferClip(input) {
5693
6062
  if (window.length === 0) throw new Error("pre-buffer holds no decodable video for this window");
5694
6063
  return new Promise((resolve, reject) => {
5695
6064
  const hevc = isHevcPacket(window[0]);
6065
+ const copyVideo = clipCanCopy({
6066
+ format: input.format,
6067
+ maxWidth: input.maxWidth,
6068
+ ...input.speed !== void 0 ? { speed: input.speed } : {},
6069
+ hevc,
6070
+ sourceWidth: input.sourceWidth ?? 0
6071
+ });
5696
6072
  const proc = input.spawn(input.ffmpegPath ?? "ffmpeg", clipFfmpegArgs({
5697
6073
  ...input,
5698
- hevc
6074
+ hevc,
6075
+ copyVideo
5699
6076
  }));
5700
6077
  const chunks = [];
5701
6078
  let stderrTail = "";
@@ -12535,79 +12912,6 @@ function hexToBytes(hex) {
12535
12912
  }
12536
12913
  return out;
12537
12914
  }
12538
- /**
12539
- * Minimum gap between two re-dials of the same device. A re-dial costs a
12540
- * ~2 s video interruption on every source of the camera; at the 30 s
12541
- * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
12542
- * cannot come back would be a permanent stutter.
12543
- */
12544
- var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
12545
- var EMPTY_AUDIO_PLANE_STATE = {
12546
- disagreements: 0,
12547
- redials: 0,
12548
- lastRedialMs: 0,
12549
- gaveUp: false
12550
- };
12551
- /**
12552
- * Decide what one pass should do. Pure: no clock, no I/O, no broker.
12553
- *
12554
- * `nowMs` is passed in rather than read so the cooldown is testable without
12555
- * waiting five minutes for it.
12556
- */
12557
- function decideAudioPlaneAction(observation, state, nowMs) {
12558
- switch (observation.kind) {
12559
- case "broker-muted":
12560
- case "no-live-source":
12561
- case "audio-present":
12562
- case "declared-off": return {
12563
- action: { kind: "none" },
12564
- nextState: EMPTY_AUDIO_PLANE_STATE
12565
- };
12566
- case "read-failed":
12567
- case "audio-unknown": return {
12568
- action: { kind: "none" },
12569
- nextState: state
12570
- };
12571
- case "declared-on": {
12572
- if (state.gaveUp) return {
12573
- action: { kind: "none" },
12574
- nextState: state
12575
- };
12576
- const disagreements = state.disagreements + 1;
12577
- if (disagreements < 2) return {
12578
- action: { kind: "await-second-pass" },
12579
- nextState: {
12580
- ...state,
12581
- disagreements
12582
- }
12583
- };
12584
- if (state.redials >= 3) return {
12585
- action: { kind: "give-up" },
12586
- nextState: {
12587
- ...state,
12588
- disagreements,
12589
- gaveUp: true
12590
- }
12591
- };
12592
- if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
12593
- action: { kind: "cooldown" },
12594
- nextState: {
12595
- ...state,
12596
- disagreements: 2
12597
- }
12598
- };
12599
- return {
12600
- action: { kind: "redial" },
12601
- nextState: {
12602
- disagreements: 0,
12603
- redials: state.redials + 1,
12604
- lastRedialMs: nowMs,
12605
- gaveUp: false
12606
- }
12607
- };
12608
- }
12609
- }
12610
- }
12611
12915
  //#endregion
12612
12916
  //#region src/stream-broker/stream-broker/transcode-pipeline.ts
12613
12917
  /**
@@ -13170,13 +13474,6 @@ function formatClockHHMM(at) {
13170
13474
  * reach `streaming` with audio detected, and the stats poll cadence. */
13171
13475
  var PROBE_STREAM_TIMEOUT_MS = 6e3;
13172
13476
  var PROBE_STREAM_POLL_MS = 250;
13173
- /**
13174
- * Seconds of encoded video kept for notification clips, on the cheapest
13175
- * assigned profile only. Sized to cover the longest allowed pre-roll (20 s)
13176
- * plus the seconds a rule takes to reach the dispatcher; the low-profile
13177
- * rendition costs a few MB for that window, and nothing at all while the
13178
- * camera is idle.
13179
- */
13180
13477
  var CLIP_RETENTION_SEC = 25;
13181
13478
  /** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
13182
13479
  var CLIP_PROFILE_BY_COST = [
@@ -14899,6 +15196,156 @@ var StreamBrokerManager = class StreamBrokerManager {
14899
15196
  * of the event, not of the present.
14900
15197
  */
14901
15198
  async renderPreBufferClip(input) {
15199
+ const cut = await this.selectClipWindow({
15200
+ deviceId: input.deviceId,
15201
+ aroundMs: input.aroundMs,
15202
+ preRollSec: input.preRollSec,
15203
+ postRollSec: input.postRollSec,
15204
+ ...input.profile !== void 0 ? { profile: input.profile } : {}
15205
+ });
15206
+ const bytes = await this.muxClipWindow(cut, {
15207
+ format: input.format,
15208
+ maxWidth: input.maxWidth,
15209
+ fps: input.fps,
15210
+ ...input.speed !== void 0 ? { speed: input.speed } : {}
15211
+ });
15212
+ const first = cut.window[0];
15213
+ const last = cut.window[cut.window.length - 1];
15214
+ return {
15215
+ base64: Buffer.from(bytes).toString("base64"),
15216
+ mime: input.format === "gif" ? "image/gif" : "video/mp4",
15217
+ bytes: bytes.byteLength,
15218
+ profile: cut.profile,
15219
+ durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
15220
+ };
15221
+ }
15222
+ /**
15223
+ * ONE cut of an event, in every container the caller asked for — the
15224
+ * production point every consumer of event media goes through.
15225
+ *
15226
+ * See the cap docblock for why this is one method rather than one call per
15227
+ * format. The short version is that `renderPreBufferClip` renders one format
15228
+ * per call, so a caller wanting a gif AND a video made two calls, got two
15229
+ * windows, and shipped a notification whose two attachments showed different
15230
+ * moments.
15231
+ *
15232
+ * The window comes out of the broker's own AnnexB retention ring — packets
15233
+ * that were already in RAM — so a production reaches BACKWARDS from the event
15234
+ * with nothing spawned and nothing kept warm. The mp4 is muxed once, COPYING
15235
+ * the camera's own H.264 whenever nothing is being changed, and any gif is
15236
+ * derived from that same mp4 rather than rendered a second time from the
15237
+ * packets: same window, same pixels, by construction.
15238
+ */
15239
+ async produceEventMedia(input) {
15240
+ const preferred = input.profile ?? this.bestClipProfile(input.deviceId) ?? void 0;
15241
+ const cut = await this.selectClipWindow({
15242
+ deviceId: input.deviceId,
15243
+ aroundMs: input.aroundMs,
15244
+ preRollSec: input.preSeconds,
15245
+ postRollSec: input.postSeconds,
15246
+ ...preferred !== void 0 ? { profile: preferred } : {}
15247
+ });
15248
+ const mp4 = await this.muxClipWindow(cut, {
15249
+ format: "mp4",
15250
+ maxWidth: Math.max(1, cut.sourceWidth),
15251
+ fps: 0,
15252
+ speed: input.speed
15253
+ });
15254
+ const media = [];
15255
+ if (input.kinds.includes("mp4")) media.push({
15256
+ kind: "mp4",
15257
+ handle: this.eventMedia.put({
15258
+ deviceId: input.deviceId,
15259
+ kind: "mp4",
15260
+ mime: "video/mp4",
15261
+ bytes: mp4
15262
+ }),
15263
+ nodeId: this.localNodeId ?? "",
15264
+ mime: "video/mp4",
15265
+ bytes: mp4.byteLength,
15266
+ width: cut.sourceWidth,
15267
+ height: cut.sourceHeight
15268
+ });
15269
+ if (input.kinds.includes("gif")) {
15270
+ const gif = await gifFromMp4({
15271
+ mp4,
15272
+ deviceId: input.deviceId,
15273
+ maxWidth: input.gifMaxWidth,
15274
+ fps: input.gifFps,
15275
+ speed: 1,
15276
+ ...this.logger !== void 0 ? { logger: this.logger } : {},
15277
+ spawnFn: spawn,
15278
+ ...this.ffmpegConfig.binaryPath !== void 0 ? { ffmpegPath: this.ffmpegConfig.binaryPath } : {}
15279
+ });
15280
+ if (gif !== null) {
15281
+ const height = cut.sourceWidth > 0 ? Math.round(input.gifMaxWidth / cut.sourceWidth * cut.sourceHeight) : 0;
15282
+ media.push({
15283
+ kind: "gif",
15284
+ handle: this.eventMedia.put({
15285
+ deviceId: input.deviceId,
15286
+ kind: "gif",
15287
+ mime: "image/gif",
15288
+ bytes: gif
15289
+ }),
15290
+ nodeId: this.localNodeId ?? "",
15291
+ mime: "image/gif",
15292
+ bytes: gif.byteLength,
15293
+ width: input.gifMaxWidth,
15294
+ height
15295
+ });
15296
+ }
15297
+ }
15298
+ const video = this.clipVideoMode(cut, {
15299
+ format: "mp4",
15300
+ maxWidth: Math.max(1, cut.sourceWidth),
15301
+ speed: input.speed
15302
+ });
15303
+ this.logger?.info("event media produced — one window, every container", {
15304
+ tags: { deviceId: input.deviceId },
15305
+ meta: {
15306
+ profile: cut.profile,
15307
+ video,
15308
+ resolution: `${cut.sourceWidth}x${cut.sourceHeight}`,
15309
+ packets: cut.window.length,
15310
+ kinds: [...input.kinds].join(","),
15311
+ artifacts: media.map((m) => `${m.kind}:${m.bytes}`).join(" "),
15312
+ startOffsetMs: cut.coverage.fromTs - input.aroundMs,
15313
+ endOffsetMs: cut.coverage.toTs - input.aroundMs
15314
+ }
15315
+ });
15316
+ return {
15317
+ media,
15318
+ coverage: {
15319
+ fromTs: cut.coverage.fromTs,
15320
+ toTs: cut.coverage.toTs,
15321
+ packets: cut.window.length
15322
+ },
15323
+ profile: cut.profile,
15324
+ video
15325
+ };
15326
+ }
15327
+ /** Redeem an artifact handle. `null` once it has expired (see
15328
+ * {@link EventMediaStore}). */
15329
+ async fetchEventMedia(input) {
15330
+ const item = this.eventMedia.get(input.handle);
15331
+ if (item === null) {
15332
+ this.logger?.warn("event media: handle expired or unknown — nothing to fetch", { meta: { handle: input.handle } });
15333
+ return null;
15334
+ }
15335
+ return {
15336
+ base64: Buffer.from(item.bytes).toString("base64"),
15337
+ mime: item.mime,
15338
+ bytes: item.bytes.length
15339
+ };
15340
+ }
15341
+ /**
15342
+ * Wait out the post-roll, then select the retained window around the moment.
15343
+ *
15344
+ * Shared by `renderPreBufferClip` and `produceEventMedia` deliberately: two
15345
+ * copies of "which profile, which packets, which resolution" is exactly how
15346
+ * the two paths would start disagreeing again.
15347
+ */
15348
+ async selectClipWindow(input) {
14902
15349
  const map = this.assignments.get(input.deviceId)?.map ?? {};
14903
15350
  const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
14904
15351
  const wanted = pickClipProfile(assigned, input.profile);
@@ -14909,19 +15356,26 @@ var StreamBrokerManager = class StreamBrokerManager {
14909
15356
  const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
14910
15357
  let profile = null;
14911
15358
  let window = [];
15359
+ let coverage = {
15360
+ fromTs: input.aroundMs,
15361
+ toTs: input.aroundMs
15362
+ };
14912
15363
  for (const candidate of candidates) {
14913
15364
  const broker = this.brokerFor(input.deviceId, map[candidate]);
14914
15365
  if (!broker) continue;
15366
+ const packets = broker.getClipBuffer();
15367
+ const nowMs = Date.now();
14915
15368
  const found = windowAround({
14916
- packets: broker.getClipBuffer(),
15369
+ packets,
14917
15370
  aroundMs: input.aroundMs,
14918
15371
  preRollSec: input.preRollSec,
14919
15372
  postRollSec: input.postRollSec,
14920
- nowMs: Date.now()
15373
+ nowMs
14921
15374
  });
14922
15375
  if (found.length > 0) {
14923
15376
  profile = candidate;
14924
15377
  window = found;
15378
+ coverage = windowCoverage(packets, found, nowMs);
14925
15379
  break;
14926
15380
  }
14927
15381
  }
@@ -14935,29 +15389,77 @@ var StreamBrokerManager = class StreamBrokerManager {
14935
15389
  });
14936
15390
  const broker = this.brokerFor(input.deviceId, map[profile]);
14937
15391
  if (!broker) throw new Error(`No live broker for device ${input.deviceId} (${profile})`);
14938
- const bytes = await renderPreBufferClip({
15392
+ const cam = this.cameraStreams.get(input.deviceId)?.get(map[profile] ?? "");
15393
+ return {
15394
+ deviceId: input.deviceId,
15395
+ profile,
14939
15396
  window,
14940
- format: input.format,
14941
- maxWidth: input.maxWidth,
14942
- fps: input.fps,
14943
- ...input.speed !== void 0 ? { speed: input.speed } : {},
15397
+ coverage,
15398
+ broker,
15399
+ sourceWidth: cam?.resolution?.width ?? 0,
15400
+ sourceHeight: cam?.resolution?.height ?? 0
15401
+ };
15402
+ }
15403
+ /** The rendition an event production prefers for this camera, or null when
15404
+ * nothing qualifies (every slot H.265, or none publishes a resolution). */
15405
+ bestClipProfile(deviceId) {
15406
+ const map = this.assignments.get(deviceId)?.map ?? {};
15407
+ const streams = this.cameraStreams.get(deviceId);
15408
+ const candidates = [];
15409
+ for (const profile of CLIP_PROFILE_BY_COST) {
15410
+ const camStreamId = map[profile];
15411
+ if (camStreamId === void 0) continue;
15412
+ const cam = streams?.get(camStreamId);
15413
+ candidates.push({
15414
+ profile,
15415
+ ...cam?.codec !== void 0 ? { codec: cam.codec } : {},
15416
+ ...cam?.resolution?.width !== void 0 ? { width: cam.resolution.width } : {},
15417
+ ...cam?.resolution?.height !== void 0 ? { height: cam.resolution.height } : {}
15418
+ });
15419
+ }
15420
+ return pickBestClipProfile(candidates);
15421
+ }
15422
+ /** `copy` or `encode` for this cut — reported, never inferred from a size. */
15423
+ clipVideoMode(cut, render) {
15424
+ return clipCanCopy({
15425
+ format: render.format,
15426
+ maxWidth: render.maxWidth,
15427
+ ...render.speed !== void 0 ? { speed: render.speed } : {},
15428
+ hevc: isHevcPacket(cut.window[0]),
15429
+ sourceWidth: cut.sourceWidth
15430
+ }) ? "copy" : "encode";
15431
+ }
15432
+ async muxClipWindow(cut, render) {
15433
+ const bytes = await renderPreBufferClip({
15434
+ window: cut.window,
15435
+ format: render.format,
15436
+ maxWidth: render.maxWidth,
15437
+ fps: render.fps,
15438
+ sourceWidth: cut.sourceWidth,
15439
+ ...render.speed !== void 0 ? { speed: render.speed } : {},
14944
15440
  spawn: (cmd, args) => spawn(cmd, [...args], { stdio: [
14945
15441
  "pipe",
14946
15442
  "pipe",
14947
15443
  "pipe"
14948
15444
  ] }),
14949
15445
  ffmpegPath: this.ffmpegConfig.binaryPath,
14950
- sdpParameterSets: broker.getSdpParameterSets()
15446
+ sdpParameterSets: cut.broker.getSdpParameterSets()
14951
15447
  });
14952
- const first = window[0];
14953
- const last = window[window.length - 1];
14954
- return {
14955
- base64: bytes.toString("base64"),
14956
- mime: input.format === "gif" ? "image/gif" : "video/mp4",
14957
- bytes: bytes.byteLength,
14958
- profile,
14959
- durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
14960
- };
15448
+ this.logger?.info("pre-buffer clip rendered", {
15449
+ tags: { deviceId: cut.deviceId },
15450
+ meta: {
15451
+ profile: cut.profile,
15452
+ format: render.format,
15453
+ video: this.clipVideoMode(cut, render),
15454
+ sourceWidth: cut.sourceWidth,
15455
+ maxWidth: render.maxWidth,
15456
+ speed: render.speed ?? null,
15457
+ packets: cut.window.length,
15458
+ bytes: bytes.byteLength,
15459
+ coverageMs: cut.coverage.toTs - cut.coverage.fromTs
15460
+ }
15461
+ });
15462
+ return bytes;
14961
15463
  }
14962
15464
  getCameraStreamsForDevice(deviceId) {
14963
15465
  const streamMap = this.cameraStreams.get(deviceId);
@@ -16397,6 +16899,8 @@ var StreamBrokerManager = class StreamBrokerManager {
16397
16899
  * within the minute — a rule asking for `high` fell back to `low` forever
16398
16900
  * and looked like the choice did nothing.
16399
16901
  */
16902
+ /** Produced event artifacts waiting to be fetched by handle (D9/D18). */
16903
+ eventMedia = new EventMediaStore();
16400
16904
  clipRetentionArmed = /* @__PURE__ */ new Map();
16401
16905
  armClipRetention(deviceId, profile) {
16402
16906
  const armed = this.clipRetentionArmed.get(deviceId) ?? /* @__PURE__ */ new Set();