@camstack/addon-pipeline 1.2.45 → 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 (36) 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 +43 -13
  4. package/dist/detection-pipeline/index.mjs +43 -13
  5. package/dist/{dist-QyZ7QSRP.js → dist-C9Q1t0nT.js} +514 -3
  6. package/dist/{dist-BDkEhx4Z.mjs → dist-CfxC7XoI.mjs} +514 -3
  7. package/dist/{event-loop-stall-monitor-IAuNEdmG.mjs → event-loop-stall-monitor-B_XUPTwp.mjs} +7 -1
  8. package/dist/{event-loop-stall-monitor-COEKVBQK.js → event-loop-stall-monitor-Luq9AX4t.js} +7 -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 +15 -6
  12. package/dist/pipeline-runner/index.mjs +15 -6
  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-C68YfSUH.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-BMCrULpc.mjs → hostInit-D8gyxL0j.mjs} +2 -2
  22. package/dist/stream-broker/index.js +864 -106
  23. package/dist/stream-broker/index.mjs +864 -106
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-le27Mw_8.js → worker-protocol-BUJpm7l3.js} +1 -1
  26. package/dist/{worker-protocol-DFH_z2-q.mjs → worker-protocol-rqbyKlm9.mjs} +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C7bsz4cP.js → MaskShapeCanvas-DI4BY7W2-DwXIyaHw.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BmhLinUd.js → MotionZonesSettings-NcxxQN8r-Cf8EtWEG.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BJjyGWFy.js → PrivacyMaskSettings-APgPLF7p-BvsVXya9.js} +1 -1
  30. package/embed-dist/assets/{index-a4CMt-Uc.js → index-BuAeIGv8.js} +12 -12
  31. package/embed-dist/assets/index-ICnBEqOy.css +2 -0
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +1 -1
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CT2bGWqM.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B3GxBnXM.mjs +0 -26
  36. package/embed-dist/assets/index-DC63og2c.css +0 -2
@@ -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-QyZ7QSRP.js");
6
+ const require_dist = require("../dist-C9Q1t0nT.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,220 @@ 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
+ }
3087
+ //#endregion
3088
+ //#region src/stream-broker/stream-broker/dead-decode-backends.ts
3089
+ /**
3090
+ * "This decode backend cannot create a device in THIS process" — remembered
3091
+ * across children, not per child.
3092
+ *
3093
+ * ## The thing this stops
3094
+ *
3095
+ * `-hwaccel vaapi` works for the decoder addon on this hub (ten concurrent 4K
3096
+ * decodes) and returns ENOMEM inside the stream-broker runner: `code=244`,
3097
+ * "Device creation failed: -12 … Cannot allocate memory". Every egress child
3098
+ * therefore spends ~3 s dying before its own software retry rescues it.
3099
+ *
3100
+ * That was already reported — `EgressTranscodeManager.onDecodeHwAccelFailed`
3101
+ * poisoned the broker's 60 s `egressHwAccelCache`. The memo is the wrong shape
3102
+ * for the verdict: it exists to avoid a per-session cross-process
3103
+ * `decoder.getInfo`, so it expires on a **read** cadence, and one minute later
3104
+ * the decoder answers `vaapi` again and the next cold child re-pays the death.
3105
+ * Measured on camera 615 on 2026-08-07: the hardware failure recurs at 22:17:07,
3106
+ * 22:18:42, 22:28:48, 22:30:52, 22:32:39, 22:34:07, 22:52:07 — once per memo
3107
+ * window, forever, on a hub where it has never once succeeded.
3108
+ *
3109
+ * ## Why this is a quarantine and not a tombstone
3110
+ *
3111
+ * A permanent verdict is wrong for the failure that is actually observed:
3112
+ * ENOMEM on device creation is a RESOURCE condition, and a resource condition
3113
+ * can clear (the operator gives the container more of the device, another
3114
+ * process releases it). A process that decided "never again" at 03:00 would
3115
+ * still be decoding in software at noon with a working accelerator, and nothing
3116
+ * would ever say so.
3117
+ *
3118
+ * So the verdict LASTS and escalates: each consecutive failure of the same
3119
+ * backend widens its quarantine — 30 min, 2 h, then 6 h — so a hub where the
3120
+ * backend is genuinely dead pays one probe per six hours instead of one per
3121
+ * minute, while a hub where it recovers still finds out.
3122
+ *
3123
+ * The FIRST rung is deliberately not a minute. One report is already a strong
3124
+ * signal: the manager only reports a backend after its child produced ZERO
3125
+ * packets *and* the software retry proved the same argv otherwise works, which
3126
+ * is a statement about the backend and not about the source. And the cost of
3127
+ * being wrong is bounded and already being paid — the egress encodes with
3128
+ * `libx264` either way, so an over-long quarantine costs a software DECODE on a
3129
+ * path that is decoding in software today, 100 % of the time.
3130
+ *
3131
+ * The invalidations are motivated, and there are exactly three:
3132
+ *
3133
+ * 1. **The window elapses.** The next acquire probes hardware once. If it
3134
+ * works, {@link DeadDecodeBackends.markAlive} clears the record and the
3135
+ * escalation starts from zero again.
3136
+ * 2. **The backend changes.** The record is keyed by backend name, so an
3137
+ * operator switching the decoder from vaapi to qsv is obeyed immediately —
3138
+ * qsv has no record.
3139
+ * 3. **The runner respawns.** This is instance state on the broker manager,
3140
+ * never a module global, for the same reason `HwAccelCache` is: a module
3141
+ * global outlives an addon respawn, and an addon respawn is exactly the
3142
+ * event after which a stale verdict must not survive.
3143
+ *
3144
+ * ## Why it lives here and not in `@camstack/types`
3145
+ *
3146
+ * `addon-export-hap` has the identical gap (its `failedBackend` memo forgets on
3147
+ * the same 60 s cadence) and this is deliberately NOT shared with it yet: a
3148
+ * framework package is only live once a `@camstack/server` containing it is
3149
+ * installed, whereas this ships with `camstack deploy packages/addon-pipeline`.
3150
+ * The cross-PROCESS gap D67 names — addons may not import each other and no cap
3151
+ * carries "this backend is dead on this node right now" — is unchanged either
3152
+ * way; what is closed here is the broker's own repetition of the cost.
3153
+ */
3154
+ /**
3155
+ * How long a backend stays quarantined after its 1st, 2nd, 3rd+ consecutive
3156
+ * failure. Escalating rather than fixed, and it starts at HALF AN HOUR: a
3157
+ * client watchdog cycles in seconds, so any first rung measured in minutes is
3158
+ * re-paid by the very churn this exists to stop. The last rung makes a
3159
+ * permanently-broken backend cost ~nothing while still being re-probed.
3160
+ */
3161
+ var DEAD_BACKEND_QUARANTINE_MS = [
3162
+ 18e5,
3163
+ 72e5,
3164
+ 216e5
3165
+ ];
3166
+ /**
3167
+ * The decoder's answer, overruled while the backend it names is quarantined.
3168
+ *
3169
+ * Composed rather than folded into `resolveEgressDecodeHwAccel`: that function
3170
+ * answers "what SHOULD decode this", which is a per-node property of the
3171
+ * hardware; this answers "what has been observed to work in THIS process",
3172
+ * which is a property of the runner. Keeping them apart is what stops a
3173
+ * runner-local ENOMEM from being mistaken for a node that has no accelerator —
3174
+ * the first is a flow bug to fix, the second a capability limit to accept.
3175
+ */
3176
+ async function resolveWithQuarantine(input) {
3177
+ const backend = await input.resolve();
3178
+ if (backend === null || !input.dead.isQuarantined(backend)) return {
3179
+ backend,
3180
+ veto: null
3181
+ };
3182
+ return {
3183
+ backend: null,
3184
+ veto: {
3185
+ backend,
3186
+ failures: input.dead.failuresOf(backend),
3187
+ remainingMs: input.dead.remainingMs(backend)
3188
+ }
3189
+ };
3190
+ }
3191
+ function createDeadDecodeBackends(options = {}) {
3192
+ const now = options.now ?? (() => Date.now());
3193
+ const records = /* @__PURE__ */ new Map();
3194
+ const quarantineFor = (failures) => {
3195
+ return DEAD_BACKEND_QUARANTINE_MS[Math.min(failures, DEAD_BACKEND_QUARANTINE_MS.length) - 1] ?? DEAD_BACKEND_QUARANTINE_MS[0] ?? 0;
3196
+ };
3197
+ return {
3198
+ markDead(backend) {
3199
+ const failures = (records.get(backend)?.failures ?? 0) + 1;
3200
+ const quarantineMs = quarantineFor(failures);
3201
+ records.set(backend, {
3202
+ failures,
3203
+ until: now() + quarantineMs
3204
+ });
3205
+ return {
3206
+ backend,
3207
+ failures,
3208
+ quarantineMs
3209
+ };
3210
+ },
3211
+ markAlive(backend) {
3212
+ records.delete(backend);
3213
+ },
3214
+ isQuarantined(backend) {
3215
+ const record = records.get(backend);
3216
+ return record !== void 0 && now() < record.until;
3217
+ },
3218
+ remainingMs(backend) {
3219
+ const record = records.get(backend);
3220
+ if (record === void 0) return 0;
3221
+ return Math.max(0, record.until - now());
3222
+ },
3223
+ failuresOf(backend) {
3224
+ return records.get(backend)?.failures ?? 0;
3225
+ }
3226
+ };
3227
+ }
3014
3228
  //#endregion
3015
3229
  //#region src/stream-broker/stream-broker/ffmpeg-invocation.ts
3016
3230
  /** The full set of hwaccel settings (for validation + option surfaces). */
@@ -4360,6 +4574,7 @@ var EgressTranscodeManager = class {
4360
4574
  }
4361
4575
  const actualHwAccel = child.decodeHwAccel();
4362
4576
  if (decodeHwAccel !== null && !require_dist.isSoftwareDecode(decodeHwAccel) && actualHwAccel === null) this.opts.onDecodeHwAccelFailed?.(decodeHwAccel);
4577
+ else if (actualHwAccel !== null && !require_dist.isSoftwareDecode(actualHwAccel)) this.opts.onDecodeHwAccelSucceeded?.(actualHwAccel);
4363
4578
  const entry = {
4364
4579
  key,
4365
4580
  deviceId,
@@ -4889,6 +5104,190 @@ var EgressTranscodeManager = class {
4889
5104
  };
4890
5105
  }
4891
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
+ }
5170
+ /** A derive that has not finished by now will not finish inside the drain. */
5171
+ var GIF_RENDER_TIMEOUT_MS = 2e4;
5172
+ /**
5173
+ * The filter chain. Extracted so a test can assert the palette pair without
5174
+ * spawning ffmpeg — an argv test cannot see quality, but it can see a filter
5175
+ * that somebody deleted.
5176
+ */
5177
+ function gifFilterComplex(input) {
5178
+ const setpts = input.speed !== 1 ? `,setpts=PTS/${input.speed}` : "";
5179
+ 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`;
5180
+ }
5181
+ /** The full argv, for the same reason. */
5182
+ function gifFromMp4Args(input) {
5183
+ return [
5184
+ "-y",
5185
+ "-v",
5186
+ "error",
5187
+ "-f",
5188
+ "mp4",
5189
+ "-i",
5190
+ "pipe:0",
5191
+ "-filter_complex",
5192
+ gifFilterComplex(input),
5193
+ "-loop",
5194
+ "0",
5195
+ "-f",
5196
+ "gif",
5197
+ "pipe:1"
5198
+ ];
5199
+ }
5200
+ /**
5201
+ * Derive the gif. `null` on every failure — a notification that lost its gif is
5202
+ * a notification, and one that failed is not.
5203
+ *
5204
+ * The child is guaranteed dead before this settles: these run on every
5205
+ * notification that asks for a gif, and a leaked ffmpeg accumulates on the node
5206
+ * until somebody notices the host is slow.
5207
+ */
5208
+ async function gifFromMp4(input) {
5209
+ const args = gifFromMp4Args({
5210
+ maxWidth: input.maxWidth ?? 640,
5211
+ fps: input.fps ?? 8,
5212
+ speed: input.speed ?? 1
5213
+ });
5214
+ const startedAt = Date.now();
5215
+ try {
5216
+ const bytes = await runGif(input, args);
5217
+ input.logger?.info("nc gif: derived from the notification video", {
5218
+ tags: { deviceId: input.deviceId },
5219
+ meta: {
5220
+ sourceBytes: input.mp4.length,
5221
+ gifBytes: bytes.length,
5222
+ width: input.maxWidth ?? 640,
5223
+ fps: input.fps ?? 8,
5224
+ encodeMs: Date.now() - startedAt
5225
+ }
5226
+ });
5227
+ return bytes;
5228
+ } catch (err) {
5229
+ input.logger?.warn("nc gif: the derive failed — this notification ships without a gif", {
5230
+ tags: { deviceId: input.deviceId },
5231
+ meta: {
5232
+ sourceBytes: input.mp4.length,
5233
+ error: err instanceof Error ? err.message : String(err)
5234
+ }
5235
+ });
5236
+ return null;
5237
+ }
5238
+ }
5239
+ function runGif(input, args) {
5240
+ return new Promise((resolve, reject) => {
5241
+ const proc = input.spawnFn(input.ffmpegPath ?? "ffmpeg", [...args], { stdio: [
5242
+ "pipe",
5243
+ "pipe",
5244
+ "pipe"
5245
+ ] });
5246
+ const chunks = [];
5247
+ let stderrTail = "";
5248
+ let done = false;
5249
+ let timer = null;
5250
+ const settle = (err, out) => {
5251
+ if (done) return;
5252
+ done = true;
5253
+ if (timer !== null) clearTimeout(timer);
5254
+ if (proc.exitCode === null && proc.signalCode === null) try {
5255
+ proc.kill("SIGKILL");
5256
+ } catch {}
5257
+ try {
5258
+ proc.stdout?.removeAllListeners();
5259
+ proc.stderr?.removeAllListeners();
5260
+ proc.stdin?.destroy();
5261
+ } catch {}
5262
+ if (err !== null) reject(err);
5263
+ else resolve(out ?? new Uint8Array(0));
5264
+ };
5265
+ timer = setTimeout(() => settle(/* @__PURE__ */ new Error("gif derive timed out")), GIF_RENDER_TIMEOUT_MS);
5266
+ timer.unref?.();
5267
+ proc.stdout?.on("data", (c) => chunks.push(c));
5268
+ proc.stderr?.on("data", (c) => {
5269
+ stderrTail = (stderrTail + c.toString()).slice(-400);
5270
+ });
5271
+ proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
5272
+ proc.on("close", (code) => {
5273
+ if (code !== 0) {
5274
+ settle(/* @__PURE__ */ new Error(`ffmpeg gif derive exited ${code}: ${stderrTail}`));
5275
+ return;
5276
+ }
5277
+ const out = Buffer.concat(chunks);
5278
+ if (out.byteLength === 0) {
5279
+ settle(/* @__PURE__ */ new Error("gif derive produced no bytes"));
5280
+ return;
5281
+ }
5282
+ const bytes = new Uint8Array(out.byteLength);
5283
+ bytes.set(out);
5284
+ settle(null, bytes);
5285
+ });
5286
+ proc.stdin?.on("error", () => {});
5287
+ proc.stdin?.write(Buffer.from(input.mp4));
5288
+ proc.stdin?.end();
5289
+ });
5290
+ }
4892
5291
  //#endregion
4893
5292
  //#region src/stream-broker/webrtc/nal-utils.ts
4894
5293
  var NAL_START_CODE_4B = Buffer.from([
@@ -5413,6 +5812,46 @@ function pickClipProfile(assigned, requested) {
5413
5812
  return null;
5414
5813
  }
5415
5814
  /**
5815
+ * The tallest rendition an event clip is cut from.
5816
+ *
5817
+ * 1080 and not 720: the attachment is native-resolution H.264 that nothing
5818
+ * re-encodes, and the operator asked for a clip they could actually watch. A
5819
+ * few seconds of 4K copy is megabytes, which is the difference between "every
5820
+ * notifier takes it" and "pushover drops it at 5 MiB".
5821
+ */
5822
+ var EVENT_CLIP_MAX_HEIGHT = 1080;
5823
+ /**
5824
+ * The rendition an EVENT production should cut from — the opposite default to
5825
+ * {@link pickClipProfile}.
5826
+ *
5827
+ * `pickClipProfile` takes the cheapest assigned slot, which was right when a
5828
+ * notification attachment was a 480 px thumbnail with motion. It is wrong now:
5829
+ * on this fleet the cheapest slot is a 640×360 substream, and a gif derived
5830
+ * from it is the "de-pixellated" attachment the operator reported. So an event
5831
+ * production prefers the LARGEST H.264 rendition at or below
5832
+ * {@link EVENT_CLIP_MAX_HEIGHT}:
5833
+ *
5834
+ * - **H.264, because that is what can be COPIED.** An H.265 slot forces a
5835
+ * re-encode, which is both expensive and not universally playable on the
5836
+ * phones notifications land on.
5837
+ * - **At or below 1080p**, falling back to the smallest slot above it when a
5838
+ * camera offers nothing else — a 4K-only camera still gets a clip, it just
5839
+ * pays for it.
5840
+ *
5841
+ * `null` when no rendition qualifies; the caller then falls back to
5842
+ * `pickClipProfile` and the answer reports which was used.
5843
+ */
5844
+ function pickBestClipProfile(candidates) {
5845
+ const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
5846
+ if (usable.length === 0) return null;
5847
+ const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
5848
+ 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;
5849
+ }
5850
+ /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
5851
+ function normaliseCodec$1(codec) {
5852
+ return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
5853
+ }
5854
+ /**
5416
5855
  * Placeholder packets ("RECONNECTING"/"SLEEPING" keyframes the broker pumps at
5417
5856
  * 1 Hz while no RTP flows) are a DIFFERENT resolution and parameter set —
5418
5857
  * muxing them into a clip corrupts the decode and produces a file that freezes
@@ -5469,6 +5908,33 @@ function windowAround(input) {
5469
5908
  if (momentPts > newest.pts || momentPts < oldest.pts) return [];
5470
5909
  return sliceFromKeyframe(video, startPts, endPts);
5471
5910
  }
5911
+ /**
5912
+ * What the selected window covers, in WALL CLOCK.
5913
+ *
5914
+ * The same anchoring {@link windowAround} selects by, and for the same reason:
5915
+ * `pts` is a stream clock with an arbitrary origin, so the only fixed point is
5916
+ * that the newest retained packet arrived (near enough) at `nowMs`. Every
5917
+ * earlier packet is `newest.pts − p.pts` milliseconds before it.
5918
+ *
5919
+ * This exists so a caller can say "the clip starts 4.1 s BEFORE the event"
5920
+ * instead of inferring it from a duration and a hope. A production whose
5921
+ * `fromTs` is after the event has no pre-roll, which is precisely the defect
5922
+ * that has to be visible rather than plausible.
5923
+ */
5924
+ function windowCoverage(packets, window, nowMs) {
5925
+ const video = decodableVideo(packets);
5926
+ const newest = video[video.length - 1];
5927
+ const first = window[0];
5928
+ const last = window[window.length - 1];
5929
+ if (newest === void 0 || first === void 0 || last === void 0) return {
5930
+ fromTs: nowMs,
5931
+ toTs: nowMs
5932
+ };
5933
+ return {
5934
+ fromTs: nowMs - (newest.pts - first.pts),
5935
+ toTs: nowMs - (newest.pts - last.pts)
5936
+ };
5937
+ }
5472
5938
  /** True for an HEVC packet — the container flag and the demuxer both differ. */
5473
5939
  function isHevcPacket(packet) {
5474
5940
  const codec = (packet?.codec ?? "h264").toLowerCase();
@@ -5501,6 +5967,36 @@ function toElementaryStream(window, sdpParameterSets) {
5501
5967
  return Buffer.concat(parts);
5502
5968
  }
5503
5969
  var RENDER_TIMEOUT_MS = 2e4;
5970
+ /**
5971
+ * Whether this render can COPY the camera's own H.264 instead of re-encoding it.
5972
+ *
5973
+ * The ring already holds the camera's elementary stream. Re-encoding it is only
5974
+ * ever necessary to change something, so the question is entirely "is anything
5975
+ * being changed":
5976
+ *
5977
+ * - **A scale is a change.** Only when the caller asks for at least the
5978
+ * source's own width is the `scale` filter an identity. `sourceWidth === 0`
5979
+ * means nobody knows the source's width, and a copy would then be a guess —
5980
+ * so it re-encodes, which is what it always did.
5981
+ * - **A speed is a change.** `setpts` rewrites the timeline.
5982
+ * - **H.265 is a change we WANT.** The mp4 lands on phones, and HEVC in an mp4
5983
+ * is not universally playable there; transcoding an H.265 camera's clip to
5984
+ * H.264 is the one case where the re-encode is the feature. (It is also why
5985
+ * the clip ring remains the fallback for an H.265-only camera rather than
5986
+ * something to delete.)
5987
+ * - **A GIF is a re-encode by definition.**
5988
+ *
5989
+ * Copying is what makes a notification clip the camera's own 1280×720 picture
5990
+ * rather than `libx264 -crf 28` at 480 px — measured on 615, the same eleven
5991
+ * seconds are 224 KB copied against a visibly softer re-encode, and the gif
5992
+ * derived from it stops looking pixellated for the same reason.
5993
+ */
5994
+ function clipCanCopy(input) {
5995
+ if (input.format !== "mp4") return false;
5996
+ if (input.hevc === true) return false;
5997
+ if ((input.speed ?? 2) !== 1) return false;
5998
+ return input.sourceWidth > 0 && input.maxWidth >= input.sourceWidth;
5999
+ }
5504
6000
  /** ffmpeg args for the chosen container, reading an annex-B stream from stdin. */
5505
6001
  function clipFfmpegArgs(input) {
5506
6002
  const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
@@ -5521,6 +6017,21 @@ function clipFfmpegArgs(input) {
5521
6017
  "gif",
5522
6018
  "pipe:1"
5523
6019
  ];
6020
+ if (input.copyVideo === true) return [
6021
+ "-y",
6022
+ "-f",
6023
+ demuxer,
6024
+ "-i",
6025
+ "pipe:0",
6026
+ "-c:v",
6027
+ "copy",
6028
+ "-movflags",
6029
+ "frag_keyframe+empty_moov",
6030
+ "-an",
6031
+ "-f",
6032
+ "mp4",
6033
+ "pipe:1"
6034
+ ];
5524
6035
  return [
5525
6036
  "-y",
5526
6037
  "-f",
@@ -5557,9 +6068,17 @@ async function renderPreBufferClip(input) {
5557
6068
  if (window.length === 0) throw new Error("pre-buffer holds no decodable video for this window");
5558
6069
  return new Promise((resolve, reject) => {
5559
6070
  const hevc = isHevcPacket(window[0]);
6071
+ const copyVideo = clipCanCopy({
6072
+ format: input.format,
6073
+ maxWidth: input.maxWidth,
6074
+ ...input.speed !== void 0 ? { speed: input.speed } : {},
6075
+ hevc,
6076
+ sourceWidth: input.sourceWidth ?? 0
6077
+ });
5560
6078
  const proc = input.spawn(input.ffmpegPath ?? "ffmpeg", clipFfmpegArgs({
5561
6079
  ...input,
5562
- hevc
6080
+ hevc,
6081
+ copyVideo
5563
6082
  }));
5564
6083
  const chunks = [];
5565
6084
  let stderrTail = "";
@@ -12399,79 +12918,6 @@ function hexToBytes(hex) {
12399
12918
  }
12400
12919
  return out;
12401
12920
  }
12402
- /**
12403
- * Minimum gap between two re-dials of the same device. A re-dial costs a
12404
- * ~2 s video interruption on every source of the camera; at the 30 s
12405
- * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
12406
- * cannot come back would be a permanent stutter.
12407
- */
12408
- var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
12409
- var EMPTY_AUDIO_PLANE_STATE = {
12410
- disagreements: 0,
12411
- redials: 0,
12412
- lastRedialMs: 0,
12413
- gaveUp: false
12414
- };
12415
- /**
12416
- * Decide what one pass should do. Pure: no clock, no I/O, no broker.
12417
- *
12418
- * `nowMs` is passed in rather than read so the cooldown is testable without
12419
- * waiting five minutes for it.
12420
- */
12421
- function decideAudioPlaneAction(observation, state, nowMs) {
12422
- switch (observation.kind) {
12423
- case "broker-muted":
12424
- case "no-live-source":
12425
- case "audio-present":
12426
- case "declared-off": return {
12427
- action: { kind: "none" },
12428
- nextState: EMPTY_AUDIO_PLANE_STATE
12429
- };
12430
- case "read-failed":
12431
- case "audio-unknown": return {
12432
- action: { kind: "none" },
12433
- nextState: state
12434
- };
12435
- case "declared-on": {
12436
- if (state.gaveUp) return {
12437
- action: { kind: "none" },
12438
- nextState: state
12439
- };
12440
- const disagreements = state.disagreements + 1;
12441
- if (disagreements < 2) return {
12442
- action: { kind: "await-second-pass" },
12443
- nextState: {
12444
- ...state,
12445
- disagreements
12446
- }
12447
- };
12448
- if (state.redials >= 3) return {
12449
- action: { kind: "give-up" },
12450
- nextState: {
12451
- ...state,
12452
- disagreements,
12453
- gaveUp: true
12454
- }
12455
- };
12456
- if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
12457
- action: { kind: "cooldown" },
12458
- nextState: {
12459
- ...state,
12460
- disagreements: 2
12461
- }
12462
- };
12463
- return {
12464
- action: { kind: "redial" },
12465
- nextState: {
12466
- disagreements: 0,
12467
- redials: state.redials + 1,
12468
- lastRedialMs: nowMs,
12469
- gaveUp: false
12470
- }
12471
- };
12472
- }
12473
- }
12474
- }
12475
12921
  //#endregion
12476
12922
  //#region src/stream-broker/stream-broker/transcode-pipeline.ts
12477
12923
  /**
@@ -13034,13 +13480,6 @@ function formatClockHHMM(at) {
13034
13480
  * reach `streaming` with audio detected, and the stats poll cadence. */
13035
13481
  var PROBE_STREAM_TIMEOUT_MS = 6e3;
13036
13482
  var PROBE_STREAM_POLL_MS = 250;
13037
- /**
13038
- * Seconds of encoded video kept for notification clips, on the cheapest
13039
- * assigned profile only. Sized to cover the longest allowed pre-roll (20 s)
13040
- * plus the seconds a rule takes to reach the dispatcher; the low-profile
13041
- * rendition costs a few MB for that window, and nothing at all while the
13042
- * camera is idle.
13043
- */
13044
13483
  var CLIP_RETENTION_SEC = 25;
13045
13484
  /** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
13046
13485
  var CLIP_PROFILE_BY_COST = [
@@ -13316,6 +13755,15 @@ var StreamBrokerManager = class StreamBrokerManager {
13316
13755
  */
13317
13756
  egressHwAccelCache = require_dist.createHwAccelCache({ ttlMs: 6e4 });
13318
13757
  /**
13758
+ * Backends that could not create a device in THIS process. Separate from the
13759
+ * memo above on purpose: the memo answers "what should decode this" on a read
13760
+ * cadence and expires in a minute, so folding the failure into it meant the
13761
+ * next cold child one minute later re-paid the same ~3 s death — measured
13762
+ * seven times on 615 between 22:17 and 22:52 on 2026-08-07. See
13763
+ * `dead-decode-backends.ts`.
13764
+ */
13765
+ deadDecodeBackends = createDeadDecodeBackends();
13766
+ /**
13319
13767
  * Snapshot of the system `ffmpeg` config section (binaryPath / hwAccel /
13320
13768
  * threadCount). Refreshed by the host addon via `setFfmpegConfig`; the
13321
13769
  * transcode manager reads it live per spawn. Defaults are safe (PATH ffmpeg,
@@ -13515,8 +13963,17 @@ var StreamBrokerManager = class StreamBrokerManager {
13515
13963
  };
13516
13964
  },
13517
13965
  onDecodeHwAccelFailed: (backend) => {
13518
- this.egressHwAccelCache.write(null);
13519
- this.logger.warn("egress transcode: decode backend FAILED in this process — software until the memo expires", { meta: { backend } });
13966
+ const verdict = this.deadDecodeBackends.markDead(backend);
13967
+ this.logger.warn("egress transcode: decode backend FAILED in this process — QUARANTINED, software until it is re-probed", { meta: {
13968
+ backend,
13969
+ failures: verdict.failures,
13970
+ quarantineMs: verdict.quarantineMs
13971
+ } });
13972
+ },
13973
+ onDecodeHwAccelSucceeded: (backend) => {
13974
+ if (this.deadDecodeBackends.failuresOf(backend) === 0) return;
13975
+ this.deadDecodeBackends.markAlive(backend);
13976
+ this.logger.info("egress transcode: decode backend RECOVERED in this process — the quarantine is lifted", { meta: { backend } });
13520
13977
  }
13521
13978
  });
13522
13979
  }
@@ -13539,6 +13996,22 @@ var StreamBrokerManager = class StreamBrokerManager {
13539
13996
  * hub).
13540
13997
  */
13541
13998
  async resolveEgressHwAccel(deviceId, override) {
13999
+ const resolved = await resolveWithQuarantine({
14000
+ resolve: () => this.readEgressHwAccel(deviceId, override),
14001
+ dead: this.deadDecodeBackends
14002
+ });
14003
+ if (resolved.veto !== null) this.logger.warn("egress transcode: decoding in SOFTWARE — the backend is QUARANTINED after failing in this process", {
14004
+ tags: { deviceId },
14005
+ meta: {
14006
+ backend: resolved.veto.backend,
14007
+ failures: resolved.veto.failures,
14008
+ remainingMs: resolved.veto.remainingMs
14009
+ }
14010
+ });
14011
+ return resolved.backend;
14012
+ }
14013
+ /** The decoder addon's answer for this node — before the process quarantine. */
14014
+ async readEgressHwAccel(deviceId, override) {
13542
14015
  const decoderApi = this.buildDecoderCapApi(deviceId);
13543
14016
  return require_dist.resolveEgressDecodeHwAccel({
13544
14017
  readDecoderBackend: async () => {
@@ -14729,6 +15202,156 @@ var StreamBrokerManager = class StreamBrokerManager {
14729
15202
  * of the event, not of the present.
14730
15203
  */
14731
15204
  async renderPreBufferClip(input) {
15205
+ const cut = await this.selectClipWindow({
15206
+ deviceId: input.deviceId,
15207
+ aroundMs: input.aroundMs,
15208
+ preRollSec: input.preRollSec,
15209
+ postRollSec: input.postRollSec,
15210
+ ...input.profile !== void 0 ? { profile: input.profile } : {}
15211
+ });
15212
+ const bytes = await this.muxClipWindow(cut, {
15213
+ format: input.format,
15214
+ maxWidth: input.maxWidth,
15215
+ fps: input.fps,
15216
+ ...input.speed !== void 0 ? { speed: input.speed } : {}
15217
+ });
15218
+ const first = cut.window[0];
15219
+ const last = cut.window[cut.window.length - 1];
15220
+ return {
15221
+ base64: Buffer.from(bytes).toString("base64"),
15222
+ mime: input.format === "gif" ? "image/gif" : "video/mp4",
15223
+ bytes: bytes.byteLength,
15224
+ profile: cut.profile,
15225
+ durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
15226
+ };
15227
+ }
15228
+ /**
15229
+ * ONE cut of an event, in every container the caller asked for — the
15230
+ * production point every consumer of event media goes through.
15231
+ *
15232
+ * See the cap docblock for why this is one method rather than one call per
15233
+ * format. The short version is that `renderPreBufferClip` renders one format
15234
+ * per call, so a caller wanting a gif AND a video made two calls, got two
15235
+ * windows, and shipped a notification whose two attachments showed different
15236
+ * moments.
15237
+ *
15238
+ * The window comes out of the broker's own AnnexB retention ring — packets
15239
+ * that were already in RAM — so a production reaches BACKWARDS from the event
15240
+ * with nothing spawned and nothing kept warm. The mp4 is muxed once, COPYING
15241
+ * the camera's own H.264 whenever nothing is being changed, and any gif is
15242
+ * derived from that same mp4 rather than rendered a second time from the
15243
+ * packets: same window, same pixels, by construction.
15244
+ */
15245
+ async produceEventMedia(input) {
15246
+ const preferred = input.profile ?? this.bestClipProfile(input.deviceId) ?? void 0;
15247
+ const cut = await this.selectClipWindow({
15248
+ deviceId: input.deviceId,
15249
+ aroundMs: input.aroundMs,
15250
+ preRollSec: input.preSeconds,
15251
+ postRollSec: input.postSeconds,
15252
+ ...preferred !== void 0 ? { profile: preferred } : {}
15253
+ });
15254
+ const mp4 = await this.muxClipWindow(cut, {
15255
+ format: "mp4",
15256
+ maxWidth: Math.max(1, cut.sourceWidth),
15257
+ fps: 0,
15258
+ speed: input.speed
15259
+ });
15260
+ const media = [];
15261
+ if (input.kinds.includes("mp4")) media.push({
15262
+ kind: "mp4",
15263
+ handle: this.eventMedia.put({
15264
+ deviceId: input.deviceId,
15265
+ kind: "mp4",
15266
+ mime: "video/mp4",
15267
+ bytes: mp4
15268
+ }),
15269
+ nodeId: this.localNodeId ?? "",
15270
+ mime: "video/mp4",
15271
+ bytes: mp4.byteLength,
15272
+ width: cut.sourceWidth,
15273
+ height: cut.sourceHeight
15274
+ });
15275
+ if (input.kinds.includes("gif")) {
15276
+ const gif = await gifFromMp4({
15277
+ mp4,
15278
+ deviceId: input.deviceId,
15279
+ maxWidth: input.gifMaxWidth,
15280
+ fps: input.gifFps,
15281
+ speed: 1,
15282
+ ...this.logger !== void 0 ? { logger: this.logger } : {},
15283
+ spawnFn: node_child_process.spawn,
15284
+ ...this.ffmpegConfig.binaryPath !== void 0 ? { ffmpegPath: this.ffmpegConfig.binaryPath } : {}
15285
+ });
15286
+ if (gif !== null) {
15287
+ const height = cut.sourceWidth > 0 ? Math.round(input.gifMaxWidth / cut.sourceWidth * cut.sourceHeight) : 0;
15288
+ media.push({
15289
+ kind: "gif",
15290
+ handle: this.eventMedia.put({
15291
+ deviceId: input.deviceId,
15292
+ kind: "gif",
15293
+ mime: "image/gif",
15294
+ bytes: gif
15295
+ }),
15296
+ nodeId: this.localNodeId ?? "",
15297
+ mime: "image/gif",
15298
+ bytes: gif.byteLength,
15299
+ width: input.gifMaxWidth,
15300
+ height
15301
+ });
15302
+ }
15303
+ }
15304
+ const video = this.clipVideoMode(cut, {
15305
+ format: "mp4",
15306
+ maxWidth: Math.max(1, cut.sourceWidth),
15307
+ speed: input.speed
15308
+ });
15309
+ this.logger?.info("event media produced — one window, every container", {
15310
+ tags: { deviceId: input.deviceId },
15311
+ meta: {
15312
+ profile: cut.profile,
15313
+ video,
15314
+ resolution: `${cut.sourceWidth}x${cut.sourceHeight}`,
15315
+ packets: cut.window.length,
15316
+ kinds: [...input.kinds].join(","),
15317
+ artifacts: media.map((m) => `${m.kind}:${m.bytes}`).join(" "),
15318
+ startOffsetMs: cut.coverage.fromTs - input.aroundMs,
15319
+ endOffsetMs: cut.coverage.toTs - input.aroundMs
15320
+ }
15321
+ });
15322
+ return {
15323
+ media,
15324
+ coverage: {
15325
+ fromTs: cut.coverage.fromTs,
15326
+ toTs: cut.coverage.toTs,
15327
+ packets: cut.window.length
15328
+ },
15329
+ profile: cut.profile,
15330
+ video
15331
+ };
15332
+ }
15333
+ /** Redeem an artifact handle. `null` once it has expired (see
15334
+ * {@link EventMediaStore}). */
15335
+ async fetchEventMedia(input) {
15336
+ const item = this.eventMedia.get(input.handle);
15337
+ if (item === null) {
15338
+ this.logger?.warn("event media: handle expired or unknown — nothing to fetch", { meta: { handle: input.handle } });
15339
+ return null;
15340
+ }
15341
+ return {
15342
+ base64: Buffer.from(item.bytes).toString("base64"),
15343
+ mime: item.mime,
15344
+ bytes: item.bytes.length
15345
+ };
15346
+ }
15347
+ /**
15348
+ * Wait out the post-roll, then select the retained window around the moment.
15349
+ *
15350
+ * Shared by `renderPreBufferClip` and `produceEventMedia` deliberately: two
15351
+ * copies of "which profile, which packets, which resolution" is exactly how
15352
+ * the two paths would start disagreeing again.
15353
+ */
15354
+ async selectClipWindow(input) {
14732
15355
  const map = this.assignments.get(input.deviceId)?.map ?? {};
14733
15356
  const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
14734
15357
  const wanted = pickClipProfile(assigned, input.profile);
@@ -14739,19 +15362,26 @@ var StreamBrokerManager = class StreamBrokerManager {
14739
15362
  const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
14740
15363
  let profile = null;
14741
15364
  let window = [];
15365
+ let coverage = {
15366
+ fromTs: input.aroundMs,
15367
+ toTs: input.aroundMs
15368
+ };
14742
15369
  for (const candidate of candidates) {
14743
15370
  const broker = this.brokerFor(input.deviceId, map[candidate]);
14744
15371
  if (!broker) continue;
15372
+ const packets = broker.getClipBuffer();
15373
+ const nowMs = Date.now();
14745
15374
  const found = windowAround({
14746
- packets: broker.getClipBuffer(),
15375
+ packets,
14747
15376
  aroundMs: input.aroundMs,
14748
15377
  preRollSec: input.preRollSec,
14749
15378
  postRollSec: input.postRollSec,
14750
- nowMs: Date.now()
15379
+ nowMs
14751
15380
  });
14752
15381
  if (found.length > 0) {
14753
15382
  profile = candidate;
14754
15383
  window = found;
15384
+ coverage = windowCoverage(packets, found, nowMs);
14755
15385
  break;
14756
15386
  }
14757
15387
  }
@@ -14765,29 +15395,77 @@ var StreamBrokerManager = class StreamBrokerManager {
14765
15395
  });
14766
15396
  const broker = this.brokerFor(input.deviceId, map[profile]);
14767
15397
  if (!broker) throw new Error(`No live broker for device ${input.deviceId} (${profile})`);
14768
- const bytes = await renderPreBufferClip({
15398
+ const cam = this.cameraStreams.get(input.deviceId)?.get(map[profile] ?? "");
15399
+ return {
15400
+ deviceId: input.deviceId,
15401
+ profile,
14769
15402
  window,
14770
- format: input.format,
14771
- maxWidth: input.maxWidth,
14772
- fps: input.fps,
14773
- ...input.speed !== void 0 ? { speed: input.speed } : {},
15403
+ coverage,
15404
+ broker,
15405
+ sourceWidth: cam?.resolution?.width ?? 0,
15406
+ sourceHeight: cam?.resolution?.height ?? 0
15407
+ };
15408
+ }
15409
+ /** The rendition an event production prefers for this camera, or null when
15410
+ * nothing qualifies (every slot H.265, or none publishes a resolution). */
15411
+ bestClipProfile(deviceId) {
15412
+ const map = this.assignments.get(deviceId)?.map ?? {};
15413
+ const streams = this.cameraStreams.get(deviceId);
15414
+ const candidates = [];
15415
+ for (const profile of CLIP_PROFILE_BY_COST) {
15416
+ const camStreamId = map[profile];
15417
+ if (camStreamId === void 0) continue;
15418
+ const cam = streams?.get(camStreamId);
15419
+ candidates.push({
15420
+ profile,
15421
+ ...cam?.codec !== void 0 ? { codec: cam.codec } : {},
15422
+ ...cam?.resolution?.width !== void 0 ? { width: cam.resolution.width } : {},
15423
+ ...cam?.resolution?.height !== void 0 ? { height: cam.resolution.height } : {}
15424
+ });
15425
+ }
15426
+ return pickBestClipProfile(candidates);
15427
+ }
15428
+ /** `copy` or `encode` for this cut — reported, never inferred from a size. */
15429
+ clipVideoMode(cut, render) {
15430
+ return clipCanCopy({
15431
+ format: render.format,
15432
+ maxWidth: render.maxWidth,
15433
+ ...render.speed !== void 0 ? { speed: render.speed } : {},
15434
+ hevc: isHevcPacket(cut.window[0]),
15435
+ sourceWidth: cut.sourceWidth
15436
+ }) ? "copy" : "encode";
15437
+ }
15438
+ async muxClipWindow(cut, render) {
15439
+ const bytes = await renderPreBufferClip({
15440
+ window: cut.window,
15441
+ format: render.format,
15442
+ maxWidth: render.maxWidth,
15443
+ fps: render.fps,
15444
+ sourceWidth: cut.sourceWidth,
15445
+ ...render.speed !== void 0 ? { speed: render.speed } : {},
14774
15446
  spawn: (cmd, args) => (0, node_child_process.spawn)(cmd, [...args], { stdio: [
14775
15447
  "pipe",
14776
15448
  "pipe",
14777
15449
  "pipe"
14778
15450
  ] }),
14779
15451
  ffmpegPath: this.ffmpegConfig.binaryPath,
14780
- sdpParameterSets: broker.getSdpParameterSets()
15452
+ sdpParameterSets: cut.broker.getSdpParameterSets()
14781
15453
  });
14782
- const first = window[0];
14783
- const last = window[window.length - 1];
14784
- return {
14785
- base64: bytes.toString("base64"),
14786
- mime: input.format === "gif" ? "image/gif" : "video/mp4",
14787
- bytes: bytes.byteLength,
14788
- profile,
14789
- durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
14790
- };
15454
+ this.logger?.info("pre-buffer clip rendered", {
15455
+ tags: { deviceId: cut.deviceId },
15456
+ meta: {
15457
+ profile: cut.profile,
15458
+ format: render.format,
15459
+ video: this.clipVideoMode(cut, render),
15460
+ sourceWidth: cut.sourceWidth,
15461
+ maxWidth: render.maxWidth,
15462
+ speed: render.speed ?? null,
15463
+ packets: cut.window.length,
15464
+ bytes: bytes.byteLength,
15465
+ coverageMs: cut.coverage.toTs - cut.coverage.fromTs
15466
+ }
15467
+ });
15468
+ return bytes;
14791
15469
  }
14792
15470
  getCameraStreamsForDevice(deviceId) {
14793
15471
  const streamMap = this.cameraStreams.get(deviceId);
@@ -16227,6 +16905,8 @@ var StreamBrokerManager = class StreamBrokerManager {
16227
16905
  * within the minute — a rule asking for `high` fell back to `low` forever
16228
16906
  * and looked like the choice did nothing.
16229
16907
  */
16908
+ /** Produced event artifacts waiting to be fetched by handle (D9/D18). */
16909
+ eventMedia = new EventMediaStore();
16230
16910
  clipRetentionArmed = /* @__PURE__ */ new Map();
16231
16911
  armClipRetention(deviceId, profile) {
16232
16912
  const armed = this.clipRetentionArmed.get(deviceId) ?? /* @__PURE__ */ new Set();
@@ -18247,6 +18927,17 @@ var AdaptiveSession = class AdaptiveSession {
18247
18927
  acquireTranscodeEgress;
18248
18928
  /** The acquired egress, while this session holds a reference to it. */
18249
18929
  transcodeEgressLeg = null;
18930
+ /**
18931
+ * An acquire started at the ANSWER and not yet consumed by the feed.
18932
+ *
18933
+ * Read-and-null-out is the whole ownership protocol: whichever of
18934
+ * `startTranscodeFeed` and `releaseTranscodeEgress` reads a non-null value
18935
+ * owns the release, and since both read it synchronously exactly one can.
18936
+ * Without that, a session closing while its prewarm is in flight either
18937
+ * double-releases (the manager logs it as a consumer bookkeeping bug) or
18938
+ * leaks a child the primitive has no TTL to reclaim.
18939
+ */
18940
+ transcodeEgressPending = null;
18250
18941
  /** Unsubscribe from the acquired egress's video plane. */
18251
18942
  detachTranscodeEgress = null;
18252
18943
  /** Codec actually negotiated with the browser after SDP answer. */
@@ -18886,6 +19577,7 @@ var AdaptiveSession = class AdaptiveSession {
18886
19577
  negotiated: this.negotiatedCodec,
18887
19578
  needsTranscode: this.needsTranscode
18888
19579
  } });
19580
+ this.prewarmTranscodeEgress();
18889
19581
  if (this.debug) this.logger.info("Codec negotiation detail", { meta: {
18890
19582
  phase: "session",
18891
19583
  sessionId: this.sessionId,
@@ -19084,6 +19776,7 @@ var AdaptiveSession = class AdaptiveSession {
19084
19776
  setRemoteMs,
19085
19777
  createAnswerMs
19086
19778
  } });
19779
+ this.prewarmTranscodeEgress();
19087
19780
  if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
19088
19781
  phase: "session",
19089
19782
  sessionId: this.sessionId,
@@ -19804,6 +20497,51 @@ var AdaptiveSession = class AdaptiveSession {
19804
20497
  * unchanged — the request asks for `audio: 'passthrough'` precisely so the
19805
20498
  * child does not encode a plane this session never reads.
19806
20499
  */
20500
+ /**
20501
+ * Start the egress acquire NOW, in parallel with ICE and DTLS.
20502
+ *
20503
+ * `needsTranscode` is final the moment the answer's codec is known — the
20504
+ * "Codec negotiated" line already prints it — but the acquire used to happen
20505
+ * inside `startFeedingFrames`, which runs only after the DTLS wait. On camera
20506
+ * 615 that put the leg ~2 s after session creation and the first packet ~5 s
20507
+ * after that, against a browser watchdog that re-offers every ~5 s: the viewer
20508
+ * was always gone before the media existed, and the next session paid for a
20509
+ * cold child again. Three minutes of that on 2026-08-07 22:17–22:20.
20510
+ *
20511
+ * Only the ffmpeg moves. The ATTACH stays behind DTLS, because `sendRtp`
20512
+ * silently drops before the transport is connected.
20513
+ *
20514
+ * Idempotent and safe to call from any negotiation path; a session that needs
20515
+ * no transcode does nothing. Public for the same reason
20516
+ * {@link armSourceSilenceWatchdog} is — the acquire/release contract is
20517
+ * testable without standing up werift.
20518
+ */
20519
+ prewarmTranscodeEgress() {
20520
+ if (this.closed) return;
20521
+ if (!this.needsTranscode) return;
20522
+ if (this.transcodeEgressPending !== null || this.transcodeEgressLeg !== null) return;
20523
+ const acquire = this.acquireTranscodeEgress;
20524
+ if (acquire === null) return;
20525
+ this.logger.info("Transcode leg: prewarming the egress while ICE/DTLS negotiate", { meta: {
20526
+ phase: "session",
20527
+ sessionId: this.sessionId,
20528
+ deviceId: this.deviceId,
20529
+ sourceCodec: this.sourceCodec
20530
+ } });
20531
+ this.transcodeEgressPending = acquire({
20532
+ profile: this.transcodeProfile,
20533
+ sessionId: this.sessionId
20534
+ });
20535
+ }
20536
+ /**
20537
+ * Take ownership of the in-flight prewarm, if there is one. Nulls the field
20538
+ * synchronously so the release path can never also claim it — see the field.
20539
+ */
20540
+ claimPrewarmedEgress() {
20541
+ const pending = this.transcodeEgressPending;
20542
+ this.transcodeEgressPending = null;
20543
+ return pending;
20544
+ }
19807
20545
  startTranscodeFeed(signal) {
19808
20546
  const acquire = this.acquireTranscodeEgress;
19809
20547
  if (acquire === null) {
@@ -19815,20 +20553,32 @@ var AdaptiveSession = class AdaptiveSession {
19815
20553
  if (!this.closed) this.close();
19816
20554
  return;
19817
20555
  }
20556
+ const prewarmed = this.claimPrewarmedEgress();
19818
20557
  this.logger.info(`Starting ${this.sourceCodec}→H.264 Baseline transcode leg`, { meta: {
19819
20558
  phase: "session",
19820
20559
  sessionId: this.sessionId,
19821
20560
  deviceId: this.deviceId,
19822
20561
  sourceCodec: this.sourceCodec,
19823
- mode: "egress-primitive"
20562
+ mode: "egress-primitive",
20563
+ prewarmed: prewarmed !== null
19824
20564
  } });
20565
+ const acquireNow = () => acquire({
20566
+ profile: this.transcodeProfile,
20567
+ sessionId: this.sessionId
20568
+ });
19825
20569
  (async () => {
19826
20570
  let leg;
19827
20571
  try {
19828
- leg = await acquire({
19829
- profile: this.transcodeProfile,
19830
- sessionId: this.sessionId
19831
- });
20572
+ leg = await (prewarmed === null ? acquireNow() : prewarmed.catch((err) => {
20573
+ if (signal.aborted || this.closed) throw err;
20574
+ this.logger.warn("Transcode leg: the prewarmed acquire failed — retrying now that the feed has started", { meta: {
20575
+ phase: "session",
20576
+ sessionId: this.sessionId,
20577
+ deviceId: this.deviceId,
20578
+ error: require_dist.errMsg(err)
20579
+ } });
20580
+ return acquireNow();
20581
+ }));
19832
20582
  } catch (err) {
19833
20583
  this.logger.error("Transcode feed: acquiring the egress transcode FAILED", { meta: {
19834
20584
  phase: "session",
@@ -19896,6 +20646,14 @@ var AdaptiveSession = class AdaptiveSession {
19896
20646
  try {
19897
20647
  detach?.();
19898
20648
  } catch {}
20649
+ const orphan = this.claimPrewarmedEgress();
20650
+ if (orphan !== null) orphan.then((prewarmedLeg) => prewarmedLeg.release()).catch((err) => {
20651
+ this.logger.debug("Transcode leg: the unclaimed prewarm ended without a handle", { meta: {
20652
+ phase: "session",
20653
+ sessionId: this.sessionId,
20654
+ error: require_dist.errMsg(err)
20655
+ } });
20656
+ });
19899
20657
  const leg = this.transcodeEgressLeg;
19900
20658
  this.transcodeEgressLeg = null;
19901
20659
  if (!leg) return;