@camstack/addon-post-analysis 1.2.64 → 1.2.65

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.
@@ -7390,8 +7390,31 @@ var AdoptionJobSchema = object({
7390
7390
  error: string().nullable()
7391
7391
  });
7392
7392
  /**
7393
- * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7394
- * pipeline functions an operator thinks in terms of.
7393
+ * Per-camera FUNCTION SWITCHES.
7394
+ *
7395
+ * ## The aggregate group is being withdrawn — the BADGE is not (D113)
7396
+ *
7397
+ * This file shipped as "the one coherent on/off surface over the pipeline
7398
+ * functions an operator thinks in terms of". The operator's verdict on
7399
+ * 2026-08-12 was that the coherent surface bought complexity and no clarity:
7400
+ * every function already had a settings page of its own, and a second place to
7401
+ * turn it off is a second place to look. Each switch is going back to its own
7402
+ * component's original options — detection to the detection-pipeline wrapper
7403
+ * binding, audio analysis to its own, recording to `RecordingConfig.enabled`
7404
+ * (which was always first-class; the switch was a veneer over
7405
+ * `recording.setDeviceConfig`), notifications to a notification-center
7406
+ * per-device setting, the two camera planes to their own components.
7407
+ *
7408
+ * What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
7409
+ * thing that lets a status surface say DISABLED instead of BROKEN, recomposed
7410
+ * straight from the authorities with no group in the middle. That rule was
7411
+ * never about a control panel.
7412
+ *
7413
+ * Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
7414
+ * {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
7415
+ * `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
7416
+ * viewers (v1.0.305) and the admin UI still call it. It is deleted when they
7417
+ * stop; nothing new may be built on it.
7395
7418
  *
7396
7419
  * ## This file adds no state
7397
7420
  *
@@ -7753,6 +7776,22 @@ var RecordingConfigSchema = object({
7753
7776
  scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7754
7777
  }).strict();
7755
7778
  /**
7779
+ * Derive the {@link RecordingStorageModeSchema} summary from the authoritative
7780
+ * bands: `continuous` when any band records continuously, `events` when a band
7781
+ * exists at all, else `off`. Pure.
7782
+ *
7783
+ * THE one definition: the recorder stamps `config.mode` with it on save and
7784
+ * reports it as the cap's `activeMode`, and the settings UI reads it back to
7785
+ * decide which authoring tab a config opens in. Two copies would drift, and the
7786
+ * symptom (a status dot disagreeing with what is being recorded) is exactly the
7787
+ * bug this field was added to fix.
7788
+ */
7789
+ function deriveRecordingMode(config) {
7790
+ if (!config.enabled || config.bands.length === 0) return "off";
7791
+ if (config.bands.some((band) => band.mode === "continuous")) return "continuous";
7792
+ return "events";
7793
+ }
7794
+ /**
7756
7795
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7757
7796
  *
7758
7797
  * One shape shared by the recorder and pipeline-analytics internal movers.
@@ -18882,9 +18921,16 @@ var CameraStatusSchema = object({
18882
18921
  audio: CameraAudioStatusSchema.nullable(),
18883
18922
  recording: CameraRecordingStatusSchema.nullable(),
18884
18923
  /**
18885
- * Per-camera function switches an OPERATOR has turned off
18924
+ * Per-camera functions an OPERATOR has turned off
18886
18925
  * ([D61](../../../../docs/decisions/adr-0067.md)).
18887
18926
  *
18927
+ * Composed from the AUTHORITIES themselves — the wrapper bindings,
18928
+ * `RecordingConfig.enabled`, the notification mute, the broker's audio
18929
+ * policy, the camera's own microphone — via `composeSwitchedOff`, not from
18930
+ * the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
18931
+ * The badge outlives the control panel: the panel was a convenience, this is
18932
+ * the difference between a camera being off and a camera being dead.
18933
+ *
18888
18934
  * This is the difference between DISABLED and BROKEN. A camera whose
18889
18935
  * `detection` block reports zero fps and whose `switchedOff` contains
18890
18936
  * `'object-detection'` was switched off by a person; the same camera with an
@@ -25937,10 +25983,42 @@ method(object({
25937
25983
  */
25938
25984
  /** Playback-speed multiplier for the render (1 = realtime). */
25939
25985
  var ExportSpeedSchema = number().min(.25).max(32);
25986
+ /**
25987
+ * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
25988
+ *
25989
+ * Relative and not absolute epoch on purpose: the renderer's frame-select
25990
+ * expression sees ffmpeg's `t`, which starts at 0 for the export's source
25991
+ * playlist. Handing it absolute epochs would make every call site responsible
25992
+ * for the same subtraction, and the one that forgot would emit a filter that
25993
+ * selects nothing — silently, as a uniform timelapse.
25994
+ */
25995
+ var ExportDenseRangeSchema = object({
25996
+ fromSec: number().nonnegative(),
25997
+ toSec: number().nonnegative()
25998
+ }).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
25999
+ /**
26000
+ * Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
26001
+ * listed ranges and at the base `everyMs` everywhere else.
26002
+ *
26003
+ * `everyMs` must be strictly smaller than the base cadence — a dense rate that
26004
+ * is not denser renders a uniform timelapse the operator believes is two-rate.
26005
+ */
26006
+ var ExportDenseSchema = object({
26007
+ everyMs: number().int().positive(),
26008
+ ranges: array(ExportDenseRangeSchema).min(1).max(200)
26009
+ });
25940
26010
  /** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
25941
26011
  var ExportTimelapseSchema = object({
25942
26012
  everyMs: number().int().positive(),
25943
- outputFps: number().int().min(1).max(60).optional()
26013
+ outputFps: number().int().min(1).max(60).optional(),
26014
+ /** Optional second, FASTER rate over the intervals that matter. */
26015
+ dense: ExportDenseSchema.optional()
26016
+ }).superRefine((v, ctx) => {
26017
+ if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
26018
+ code: ZodIssueCode.custom,
26019
+ message: "dense.everyMs must be strictly smaller than the base everyMs",
26020
+ path: ["dense", "everyMs"]
26021
+ });
25944
26022
  });
25945
26023
  /**
25946
26024
  * Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
@@ -25998,6 +26076,38 @@ var ExportDownloadSchema = object({
25998
26076
  url: string(),
25999
26077
  endpoints: array(string())
26000
26078
  });
26079
+ /**
26080
+ * Hard ceiling on ONE {@link recordingExportCapability} byte read — 50 MiB.
26081
+ *
26082
+ * Two independent reasons land on the same number, which is why it is this one
26083
+ * and not a rounder guess:
26084
+ *
26085
+ * - **Nobody would accept more.** The roomiest byte cap any notifier backend
26086
+ * declares is telegram's 50 MiB, and the degrade engine DROPS an over-cap
26087
+ * attachment outright rather than degrading it to a link. Bytes above this
26088
+ * are read, encoded and moved to be thrown away at the last step.
26089
+ * - **The envelope is unary.** A base64 payload is held whole, ~1.33× its
26090
+ * size, in the provider AND in the caller — on a hub this repo has already
26091
+ * OOM'd once (D9/D18). A bounded on-demand read at human speed is the shape
26092
+ * those records permit; an unbounded one is the shape they forbid.
26093
+ *
26094
+ * Above it the provider REFUSES with a log line rather than truncating: half a
26095
+ * video is worse than a notification that says there is no attachment.
26096
+ */
26097
+ var RECORDING_EXPORT_MAX_READ_BYTES = 50 * 1024 * 1024;
26098
+ /**
26099
+ * A finished export's bytes, inline.
26100
+ *
26101
+ * `bytes` is the DECODED length — the number the caller bounds and logs
26102
+ * against, so nobody has to infer it from the base64 length.
26103
+ */
26104
+ var ExportBytesSchema = object({
26105
+ base64: string(),
26106
+ contentType: string(),
26107
+ /** Suggested filename, extension included. */
26108
+ name: string(),
26109
+ bytes: number().int().nonnegative()
26110
+ });
26001
26111
  method(object({
26002
26112
  deviceId: number(),
26003
26113
  profile: string(),
@@ -26022,6 +26132,9 @@ method(object({
26022
26132
  }), method(object({ exportId: string() }), ExportDownloadSchema, {
26023
26133
  kind: "query",
26024
26134
  auth: "protected"
26135
+ }), method(object({ exportId: string() }), ExportBytesSchema, {
26136
+ kind: "query",
26137
+ auth: "protected"
26025
26138
  });
26026
26139
  /**
26027
26140
  * scene-monitor — device-scoped reference-region state cap. An operator marks
@@ -28488,9 +28601,10 @@ var DeclaredDevices = class {
28488
28601
  }
28489
28602
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
28490
28603
  const index = await this.readIndex();
28604
+ const live = await this.readLiveByStableId();
28491
28605
  const outcomes = [];
28492
28606
  for (const declaration of spec.devices) {
28493
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
28607
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
28494
28608
  if (outcome !== null) outcomes.push(outcome);
28495
28609
  }
28496
28610
  return {
@@ -28536,6 +28650,26 @@ var DeclaredDevices = class {
28536
28650
  return new Map(rows.map((row) => [row.stableId, row]));
28537
28651
  }
28538
28652
  /**
28653
+ * Devices this kernel already has CONSTRUCTED, by stableId.
28654
+ *
28655
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
28656
+ * is persisted rows, this is live objects. A row without an object must be
28657
+ * adopted; an object must be left exactly as it is.
28658
+ *
28659
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
28660
+ * previous behaviour (attempt the adopt) rather than skipping a device that
28661
+ * genuinely needs bringing up.
28662
+ */
28663
+ async readLiveByStableId() {
28664
+ try {
28665
+ const devices = await this.ports.devices.getAll();
28666
+ return new Map(devices.map((device) => [device.stableId, device]));
28667
+ } catch (err) {
28668
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
28669
+ return /* @__PURE__ */ new Map();
28670
+ }
28671
+ }
28672
+ /**
28539
28673
  * One declaration: adopt what exists, create what does not.
28540
28674
  *
28541
28675
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -28544,8 +28678,15 @@ var DeclaredDevices = class {
28544
28678
  * the declared name over the operator's rename. D49: that branch needs a
28545
28679
  * second read to agree.
28546
28680
  */
28547
- async applyDeclaration(declaration, integrationId, index) {
28681
+ async applyDeclaration(declaration, integrationId, index, live) {
28548
28682
  try {
28683
+ const alreadyLive = live.get(declaration.stableId);
28684
+ if (alreadyLive !== void 0) return {
28685
+ stableId: declaration.stableId,
28686
+ deviceId: alreadyLive.id,
28687
+ device: alreadyLive,
28688
+ created: false
28689
+ };
28549
28690
  let existing = index.get(declaration.stableId);
28550
28691
  if (existing === void 0) {
28551
28692
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -32886,6 +33027,12 @@ Object.freeze({
32886
33027
  addonId: null,
32887
33028
  access: "view"
32888
33029
  },
33030
+ "recordingExport.readExportBytes": {
33031
+ capName: "recordingExport",
33032
+ capScope: "system",
33033
+ addonId: null,
33034
+ access: "view"
33035
+ },
32889
33036
  "sceneMonitor.captureReference": {
32890
33037
  capName: "scene-monitor",
32891
33038
  capScope: "device",
@@ -34343,7 +34490,23 @@ var TimelapseRuleInputSchema = object({
34343
34490
  /** Canonical notification priority ordinal (1..5); per-target overridable. */
34344
34491
  priority: PriorityField.default(3)
34345
34492
  });
34346
- object({
34493
+ /**
34494
+ * Partial patch for an update — any subset of the INPUT fields, with NO
34495
+ * defaults (an absent key means "leave unchanged", never "reset to default").
34496
+ * Provenance and ownership are absent by construction: a patch can rename or
34497
+ * retune a rule, never re-own it or forge its generation state.
34498
+ *
34499
+ * CLEAR SIGNAL: `template` is the one OPTIONAL field an editor can REMOVE, so
34500
+ * it is `.nullable()` here and NOWHERE else. Wire representation:
34501
+ * - key absent → leave the template unchanged
34502
+ * - `"template": null` → CLEAR it (the persisted rule loses the key)
34503
+ * - `"template": {...}` → replace it
34504
+ * `undefined` is deliberately NOT the clear signal: it does not survive
34505
+ * `JSON.stringify`, so a viewer editor could not express "remove". The
34506
+ * PERSISTED rule never carries `null` — the store drops the key (see
34507
+ * `TimelapseStore.update`).
34508
+ */
34509
+ var TimelapseRulePatchSchema = object({
34347
34510
  name: NameField.optional(),
34348
34511
  enabled: boolean().optional(),
34349
34512
  deviceIds: DeviceIdsField.optional(),
@@ -34364,15 +34527,50 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
34364
34527
  */
34365
34528
  ownerUserId: string().optional(),
34366
34529
  /**
34367
- * Epoch-ms of the last successful generation — the 1-hour re-generation
34368
- * guard's durable state (predecessor parity). Absent = never generated.
34530
+ * Epoch-ms of the NEWEST successful generation across every camera of this
34531
+ * rule. What a UI shows, and the compatibility floor for
34532
+ * {@link readTimelapseGeneratedAt}. Absent = never generated.
34369
34533
  */
34370
34534
  lastGeneratedAt: number().optional(),
34535
+ /**
34536
+ * PER-CAMERA generation state, keyed by `String(deviceId)` — the
34537
+ * re-generation guard's real durable state.
34538
+ *
34539
+ * One rule covers several cameras and each renders its own video, so a rule
34540
+ * -wide stamp is wrong in the direction that DESTROYS work: camera A
34541
+ * succeeding at 06:05 tells camera B, whose render failed, that it is
34542
+ * already done — and B's night is gone for good, because the window will not
34543
+ * come back.
34544
+ *
34545
+ * ADDITIVE, so the migration is free: a row written before this field simply
34546
+ * has no map, and {@link readTimelapseGeneratedAt} falls back to
34547
+ * {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
34548
+ * "never generated" would re-render and re-notify every camera of every rule
34549
+ * once, on the deploy that shipped the map.
34550
+ */
34551
+ generatedByDevice: record(string(), number()).optional(),
34371
34552
  /** userId of the caller who created the rule (server-stamped). */
34372
34553
  createdBy: string(),
34373
34554
  createdAt: number(),
34374
34555
  updatedAt: number()
34375
34556
  });
34557
+ /**
34558
+ * The last successful generation for ONE camera of a rule, epoch-ms.
34559
+ *
34560
+ * The per-device map wins; a rule with no map falls back to the rule-wide
34561
+ * `lastGeneratedAt` (the compatible-migration path — see
34562
+ * {@link TimelapseRuleSchema}); a rule with neither returns 0, which every
34563
+ * guard reads as "never generated".
34564
+ *
34565
+ * Read through this helper and never off the field directly: the fallback is
34566
+ * the whole migration, and a call site that forgot it would re-render an
34567
+ * entire rule set once.
34568
+ */
34569
+ function readTimelapseGeneratedAt(rule, deviceId) {
34570
+ const map = rule.generatedByDevice;
34571
+ if (map !== void 0) return map[String(deviceId)] ?? 0;
34572
+ return rule.lastGeneratedAt ?? 0;
34573
+ }
34376
34574
  object({
34377
34575
  /**
34378
34576
  * Fraction of the box's own size added on EACH side before cutting.
@@ -34525,4 +34723,4 @@ function vectorDimFromBase64(encoded) {
34525
34723
  return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
34526
34724
  }
34527
34725
  //#endregion
34528
- export { array as $, embeddingEncoderCapability as A, subKindsOf as B, addonWidgetsSourceCapability as C, cosineSimilarity as D, buildEventKindDescriptor as E, kebabToCamel as F, BaseAddon as G, videoclipsCapability as H, notificationRulesCapability as I, hydrateSchema as J, DeviceType as K, pipelineAnalyticsCapability as L, faceGalleryCapability as M, hfModelUrl as N, customAction as O, isScheduleActive as P, _enum as Q, plateGalleryCapability as R, TrackSourceSchema as S, audioMetricsCapability as T, zoneAnalyticsCapability as U, vectorDimFromBase64 as V, errMsg as W, nodePin as X, isDeviceScopedCap as Y, sleep as Z, NcTaxonomySchema as _, EVENT_PAD_MS as a, string as at, TimelapseRuleInputSchema as b, NC_CONDITION_CATALOG as c, NcRuleInputSchema as d, boolean as et, NcRulePatchSchema as f, NcSnoozeSuppressedSchema as g, NcSnoozeSchema as h, EVENT_KIND_BY_CAP as i, record as it, encodeVectorBase64 as j, defineCustomActions as k, NC_TAXONOMY as l, NcSnoozeInputSchema as m, DEFAULT_EVENT_COLOR as n, number as nt, LabelAttributionSchema as o, unknown as ot, NcRuleSchema as p, createEvent as q, DeclaredDevices as r, object as rt, MACRO_LABELS as s, EventCategory as st, BaseDevice as t, literal as tt, NcConditionDescriptorSchema as u, OpsLogEntrySchema as v, alarmPanelCapability as w, TimelapseRuleSchema as x, RetrainStatusSchema as y, readDeviceStateFrom as z };
34726
+ export { isDeviceScopedCap as $, customAction as A, pipelineAnalyticsCapability as B, TimelapseRuleSchema as C, audioMetricsCapability as D, alarmPanelCapability as E, faceGalleryCapability as F, vectorDimFromBase64 as G, readDeviceStateFrom as H, hfModelUrl as I, errMsg as J, videoclipsCapability as K, isScheduleActive as L, deriveRecordingMode as M, embeddingEncoderCapability as N, buildEventKindDescriptor as O, encodeVectorBase64 as P, hydrateSchema as Q, kebabToCamel as R, TimelapseRulePatchSchema as S, addonWidgetsSourceCapability as T, readTimelapseGeneratedAt as U, plateGalleryCapability as V, subKindsOf as W, DeviceType as X, BaseAddon as Y, createEvent as Z, NcTaxonomySchema as _, EVENT_PAD_MS as a, literal as at, RetrainStatusSchema as b, NC_CONDITION_CATALOG as c, record as ct, NcRuleInputSchema as d, EventCategory as dt, nodePin as et, NcRulePatchSchema as f, NcSnoozeSuppressedSchema as g, NcSnoozeSchema as h, EVENT_KIND_BY_CAP as i, boolean as it, defineCustomActions as j, cosineSimilarity as k, NC_TAXONOMY as l, string as lt, NcSnoozeInputSchema as m, DEFAULT_EVENT_COLOR as n, _enum as nt, LabelAttributionSchema as o, number as ot, NcRuleSchema as p, zoneAnalyticsCapability as q, DeclaredDevices as r, array as rt, MACRO_LABELS as s, object as st, BaseDevice as t, sleep as tt, NcConditionDescriptorSchema as u, unknown as ut, OpsLogEntrySchema as v, TrackSourceSchema as w, TimelapseRuleInputSchema as x, RECORDING_EXPORT_MAX_READ_BYTES as y, notificationRulesCapability as z };
@@ -7421,8 +7421,31 @@ var AdoptionJobSchema = object({
7421
7421
  error: string().nullable()
7422
7422
  });
7423
7423
  /**
7424
- * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7425
- * pipeline functions an operator thinks in terms of.
7424
+ * Per-camera FUNCTION SWITCHES.
7425
+ *
7426
+ * ## The aggregate group is being withdrawn — the BADGE is not (D113)
7427
+ *
7428
+ * This file shipped as "the one coherent on/off surface over the pipeline
7429
+ * functions an operator thinks in terms of". The operator's verdict on
7430
+ * 2026-08-12 was that the coherent surface bought complexity and no clarity:
7431
+ * every function already had a settings page of its own, and a second place to
7432
+ * turn it off is a second place to look. Each switch is going back to its own
7433
+ * component's original options — detection to the detection-pipeline wrapper
7434
+ * binding, audio analysis to its own, recording to `RecordingConfig.enabled`
7435
+ * (which was always first-class; the switch was a veneer over
7436
+ * `recording.setDeviceConfig`), notifications to a notification-center
7437
+ * per-device setting, the two camera planes to their own components.
7438
+ *
7439
+ * What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
7440
+ * thing that lets a status surface say DISABLED instead of BROKEN, recomposed
7441
+ * straight from the authorities with no group in the middle. That rule was
7442
+ * never about a control panel.
7443
+ *
7444
+ * Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
7445
+ * {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
7446
+ * `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
7447
+ * viewers (v1.0.305) and the admin UI still call it. It is deleted when they
7448
+ * stop; nothing new may be built on it.
7426
7449
  *
7427
7450
  * ## This file adds no state
7428
7451
  *
@@ -7784,6 +7807,22 @@ var RecordingConfigSchema = object({
7784
7807
  scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7785
7808
  }).strict();
7786
7809
  /**
7810
+ * Derive the {@link RecordingStorageModeSchema} summary from the authoritative
7811
+ * bands: `continuous` when any band records continuously, `events` when a band
7812
+ * exists at all, else `off`. Pure.
7813
+ *
7814
+ * THE one definition: the recorder stamps `config.mode` with it on save and
7815
+ * reports it as the cap's `activeMode`, and the settings UI reads it back to
7816
+ * decide which authoring tab a config opens in. Two copies would drift, and the
7817
+ * symptom (a status dot disagreeing with what is being recorded) is exactly the
7818
+ * bug this field was added to fix.
7819
+ */
7820
+ function deriveRecordingMode(config) {
7821
+ if (!config.enabled || config.bands.length === 0) return "off";
7822
+ if (config.bands.some((band) => band.mode === "continuous")) return "continuous";
7823
+ return "events";
7824
+ }
7825
+ /**
7787
7826
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7788
7827
  *
7789
7828
  * One shape shared by the recorder and pipeline-analytics internal movers.
@@ -18913,9 +18952,16 @@ var CameraStatusSchema = object({
18913
18952
  audio: CameraAudioStatusSchema.nullable(),
18914
18953
  recording: CameraRecordingStatusSchema.nullable(),
18915
18954
  /**
18916
- * Per-camera function switches an OPERATOR has turned off
18955
+ * Per-camera functions an OPERATOR has turned off
18917
18956
  * ([D61](../../../../docs/decisions/adr-0067.md)).
18918
18957
  *
18958
+ * Composed from the AUTHORITIES themselves — the wrapper bindings,
18959
+ * `RecordingConfig.enabled`, the notification mute, the broker's audio
18960
+ * policy, the camera's own microphone — via `composeSwitchedOff`, not from
18961
+ * the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
18962
+ * The badge outlives the control panel: the panel was a convenience, this is
18963
+ * the difference between a camera being off and a camera being dead.
18964
+ *
18919
18965
  * This is the difference between DISABLED and BROKEN. A camera whose
18920
18966
  * `detection` block reports zero fps and whose `switchedOff` contains
18921
18967
  * `'object-detection'` was switched off by a person; the same camera with an
@@ -25968,10 +26014,42 @@ method(object({
25968
26014
  */
25969
26015
  /** Playback-speed multiplier for the render (1 = realtime). */
25970
26016
  var ExportSpeedSchema = number().min(.25).max(32);
26017
+ /**
26018
+ * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
26019
+ *
26020
+ * Relative and not absolute epoch on purpose: the renderer's frame-select
26021
+ * expression sees ffmpeg's `t`, which starts at 0 for the export's source
26022
+ * playlist. Handing it absolute epochs would make every call site responsible
26023
+ * for the same subtraction, and the one that forgot would emit a filter that
26024
+ * selects nothing — silently, as a uniform timelapse.
26025
+ */
26026
+ var ExportDenseRangeSchema = object({
26027
+ fromSec: number().nonnegative(),
26028
+ toSec: number().nonnegative()
26029
+ }).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
26030
+ /**
26031
+ * Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
26032
+ * listed ranges and at the base `everyMs` everywhere else.
26033
+ *
26034
+ * `everyMs` must be strictly smaller than the base cadence — a dense rate that
26035
+ * is not denser renders a uniform timelapse the operator believes is two-rate.
26036
+ */
26037
+ var ExportDenseSchema = object({
26038
+ everyMs: number().int().positive(),
26039
+ ranges: array(ExportDenseRangeSchema).min(1).max(200)
26040
+ });
25971
26041
  /** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
25972
26042
  var ExportTimelapseSchema = object({
25973
26043
  everyMs: number().int().positive(),
25974
- outputFps: number().int().min(1).max(60).optional()
26044
+ outputFps: number().int().min(1).max(60).optional(),
26045
+ /** Optional second, FASTER rate over the intervals that matter. */
26046
+ dense: ExportDenseSchema.optional()
26047
+ }).superRefine((v, ctx) => {
26048
+ if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
26049
+ code: ZodIssueCode.custom,
26050
+ message: "dense.everyMs must be strictly smaller than the base everyMs",
26051
+ path: ["dense", "everyMs"]
26052
+ });
25975
26053
  });
25976
26054
  /**
25977
26055
  * Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
@@ -26029,6 +26107,38 @@ var ExportDownloadSchema = object({
26029
26107
  url: string(),
26030
26108
  endpoints: array(string())
26031
26109
  });
26110
+ /**
26111
+ * Hard ceiling on ONE {@link recordingExportCapability} byte read — 50 MiB.
26112
+ *
26113
+ * Two independent reasons land on the same number, which is why it is this one
26114
+ * and not a rounder guess:
26115
+ *
26116
+ * - **Nobody would accept more.** The roomiest byte cap any notifier backend
26117
+ * declares is telegram's 50 MiB, and the degrade engine DROPS an over-cap
26118
+ * attachment outright rather than degrading it to a link. Bytes above this
26119
+ * are read, encoded and moved to be thrown away at the last step.
26120
+ * - **The envelope is unary.** A base64 payload is held whole, ~1.33× its
26121
+ * size, in the provider AND in the caller — on a hub this repo has already
26122
+ * OOM'd once (D9/D18). A bounded on-demand read at human speed is the shape
26123
+ * those records permit; an unbounded one is the shape they forbid.
26124
+ *
26125
+ * Above it the provider REFUSES with a log line rather than truncating: half a
26126
+ * video is worse than a notification that says there is no attachment.
26127
+ */
26128
+ var RECORDING_EXPORT_MAX_READ_BYTES = 50 * 1024 * 1024;
26129
+ /**
26130
+ * A finished export's bytes, inline.
26131
+ *
26132
+ * `bytes` is the DECODED length — the number the caller bounds and logs
26133
+ * against, so nobody has to infer it from the base64 length.
26134
+ */
26135
+ var ExportBytesSchema = object({
26136
+ base64: string(),
26137
+ contentType: string(),
26138
+ /** Suggested filename, extension included. */
26139
+ name: string(),
26140
+ bytes: number().int().nonnegative()
26141
+ });
26032
26142
  method(object({
26033
26143
  deviceId: number(),
26034
26144
  profile: string(),
@@ -26053,6 +26163,9 @@ method(object({
26053
26163
  }), method(object({ exportId: string() }), ExportDownloadSchema, {
26054
26164
  kind: "query",
26055
26165
  auth: "protected"
26166
+ }), method(object({ exportId: string() }), ExportBytesSchema, {
26167
+ kind: "query",
26168
+ auth: "protected"
26056
26169
  });
26057
26170
  /**
26058
26171
  * scene-monitor — device-scoped reference-region state cap. An operator marks
@@ -28519,9 +28632,10 @@ var DeclaredDevices = class {
28519
28632
  }
28520
28633
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
28521
28634
  const index = await this.readIndex();
28635
+ const live = await this.readLiveByStableId();
28522
28636
  const outcomes = [];
28523
28637
  for (const declaration of spec.devices) {
28524
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
28638
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
28525
28639
  if (outcome !== null) outcomes.push(outcome);
28526
28640
  }
28527
28641
  return {
@@ -28567,6 +28681,26 @@ var DeclaredDevices = class {
28567
28681
  return new Map(rows.map((row) => [row.stableId, row]));
28568
28682
  }
28569
28683
  /**
28684
+ * Devices this kernel already has CONSTRUCTED, by stableId.
28685
+ *
28686
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
28687
+ * is persisted rows, this is live objects. A row without an object must be
28688
+ * adopted; an object must be left exactly as it is.
28689
+ *
28690
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
28691
+ * previous behaviour (attempt the adopt) rather than skipping a device that
28692
+ * genuinely needs bringing up.
28693
+ */
28694
+ async readLiveByStableId() {
28695
+ try {
28696
+ const devices = await this.ports.devices.getAll();
28697
+ return new Map(devices.map((device) => [device.stableId, device]));
28698
+ } catch (err) {
28699
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
28700
+ return /* @__PURE__ */ new Map();
28701
+ }
28702
+ }
28703
+ /**
28570
28704
  * One declaration: adopt what exists, create what does not.
28571
28705
  *
28572
28706
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -28575,8 +28709,15 @@ var DeclaredDevices = class {
28575
28709
  * the declared name over the operator's rename. D49: that branch needs a
28576
28710
  * second read to agree.
28577
28711
  */
28578
- async applyDeclaration(declaration, integrationId, index) {
28712
+ async applyDeclaration(declaration, integrationId, index, live) {
28579
28713
  try {
28714
+ const alreadyLive = live.get(declaration.stableId);
28715
+ if (alreadyLive !== void 0) return {
28716
+ stableId: declaration.stableId,
28717
+ deviceId: alreadyLive.id,
28718
+ device: alreadyLive,
28719
+ created: false
28720
+ };
28580
28721
  let existing = index.get(declaration.stableId);
28581
28722
  if (existing === void 0) {
28582
28723
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -32917,6 +33058,12 @@ Object.freeze({
32917
33058
  addonId: null,
32918
33059
  access: "view"
32919
33060
  },
33061
+ "recordingExport.readExportBytes": {
33062
+ capName: "recordingExport",
33063
+ capScope: "system",
33064
+ addonId: null,
33065
+ access: "view"
33066
+ },
32920
33067
  "sceneMonitor.captureReference": {
32921
33068
  capName: "scene-monitor",
32922
33069
  capScope: "device",
@@ -34374,7 +34521,23 @@ var TimelapseRuleInputSchema = object({
34374
34521
  /** Canonical notification priority ordinal (1..5); per-target overridable. */
34375
34522
  priority: PriorityField.default(3)
34376
34523
  });
34377
- object({
34524
+ /**
34525
+ * Partial patch for an update — any subset of the INPUT fields, with NO
34526
+ * defaults (an absent key means "leave unchanged", never "reset to default").
34527
+ * Provenance and ownership are absent by construction: a patch can rename or
34528
+ * retune a rule, never re-own it or forge its generation state.
34529
+ *
34530
+ * CLEAR SIGNAL: `template` is the one OPTIONAL field an editor can REMOVE, so
34531
+ * it is `.nullable()` here and NOWHERE else. Wire representation:
34532
+ * - key absent → leave the template unchanged
34533
+ * - `"template": null` → CLEAR it (the persisted rule loses the key)
34534
+ * - `"template": {...}` → replace it
34535
+ * `undefined` is deliberately NOT the clear signal: it does not survive
34536
+ * `JSON.stringify`, so a viewer editor could not express "remove". The
34537
+ * PERSISTED rule never carries `null` — the store drops the key (see
34538
+ * `TimelapseStore.update`).
34539
+ */
34540
+ var TimelapseRulePatchSchema = object({
34378
34541
  name: NameField.optional(),
34379
34542
  enabled: boolean().optional(),
34380
34543
  deviceIds: DeviceIdsField.optional(),
@@ -34395,15 +34558,50 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
34395
34558
  */
34396
34559
  ownerUserId: string().optional(),
34397
34560
  /**
34398
- * Epoch-ms of the last successful generation — the 1-hour re-generation
34399
- * guard's durable state (predecessor parity). Absent = never generated.
34561
+ * Epoch-ms of the NEWEST successful generation across every camera of this
34562
+ * rule. What a UI shows, and the compatibility floor for
34563
+ * {@link readTimelapseGeneratedAt}. Absent = never generated.
34400
34564
  */
34401
34565
  lastGeneratedAt: number().optional(),
34566
+ /**
34567
+ * PER-CAMERA generation state, keyed by `String(deviceId)` — the
34568
+ * re-generation guard's real durable state.
34569
+ *
34570
+ * One rule covers several cameras and each renders its own video, so a rule
34571
+ * -wide stamp is wrong in the direction that DESTROYS work: camera A
34572
+ * succeeding at 06:05 tells camera B, whose render failed, that it is
34573
+ * already done — and B's night is gone for good, because the window will not
34574
+ * come back.
34575
+ *
34576
+ * ADDITIVE, so the migration is free: a row written before this field simply
34577
+ * has no map, and {@link readTimelapseGeneratedAt} falls back to
34578
+ * {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
34579
+ * "never generated" would re-render and re-notify every camera of every rule
34580
+ * once, on the deploy that shipped the map.
34581
+ */
34582
+ generatedByDevice: record(string(), number()).optional(),
34402
34583
  /** userId of the caller who created the rule (server-stamped). */
34403
34584
  createdBy: string(),
34404
34585
  createdAt: number(),
34405
34586
  updatedAt: number()
34406
34587
  });
34588
+ /**
34589
+ * The last successful generation for ONE camera of a rule, epoch-ms.
34590
+ *
34591
+ * The per-device map wins; a rule with no map falls back to the rule-wide
34592
+ * `lastGeneratedAt` (the compatible-migration path — see
34593
+ * {@link TimelapseRuleSchema}); a rule with neither returns 0, which every
34594
+ * guard reads as "never generated".
34595
+ *
34596
+ * Read through this helper and never off the field directly: the fallback is
34597
+ * the whole migration, and a call site that forgot it would re-render an
34598
+ * entire rule set once.
34599
+ */
34600
+ function readTimelapseGeneratedAt(rule, deviceId) {
34601
+ const map = rule.generatedByDevice;
34602
+ if (map !== void 0) return map[String(deviceId)] ?? 0;
34603
+ return rule.lastGeneratedAt ?? 0;
34604
+ }
34407
34605
  object({
34408
34606
  /**
34409
34607
  * Fraction of the box's own size added on EACH side before cutting.
@@ -34682,6 +34880,12 @@ Object.defineProperty(exports, "OpsLogEntrySchema", {
34682
34880
  return OpsLogEntrySchema;
34683
34881
  }
34684
34882
  });
34883
+ Object.defineProperty(exports, "RECORDING_EXPORT_MAX_READ_BYTES", {
34884
+ enumerable: true,
34885
+ get: function() {
34886
+ return RECORDING_EXPORT_MAX_READ_BYTES;
34887
+ }
34888
+ });
34685
34889
  Object.defineProperty(exports, "RetrainStatusSchema", {
34686
34890
  enumerable: true,
34687
34891
  get: function() {
@@ -34694,6 +34898,12 @@ Object.defineProperty(exports, "TimelapseRuleInputSchema", {
34694
34898
  return TimelapseRuleInputSchema;
34695
34899
  }
34696
34900
  });
34901
+ Object.defineProperty(exports, "TimelapseRulePatchSchema", {
34902
+ enumerable: true,
34903
+ get: function() {
34904
+ return TimelapseRulePatchSchema;
34905
+ }
34906
+ });
34697
34907
  Object.defineProperty(exports, "TimelapseRuleSchema", {
34698
34908
  enumerable: true,
34699
34909
  get: function() {
@@ -34784,6 +34994,12 @@ Object.defineProperty(exports, "defineCustomActions", {
34784
34994
  return defineCustomActions;
34785
34995
  }
34786
34996
  });
34997
+ Object.defineProperty(exports, "deriveRecordingMode", {
34998
+ enumerable: true,
34999
+ get: function() {
35000
+ return deriveRecordingMode;
35001
+ }
35002
+ });
34787
35003
  Object.defineProperty(exports, "embeddingEncoderCapability", {
34788
35004
  enumerable: true,
34789
35005
  get: function() {
@@ -34886,6 +35102,12 @@ Object.defineProperty(exports, "readDeviceStateFrom", {
34886
35102
  return readDeviceStateFrom;
34887
35103
  }
34888
35104
  });
35105
+ Object.defineProperty(exports, "readTimelapseGeneratedAt", {
35106
+ enumerable: true,
35107
+ get: function() {
35108
+ return readTimelapseGeneratedAt;
35109
+ }
35110
+ });
34889
35111
  Object.defineProperty(exports, "record", {
34890
35112
  enumerable: true,
34891
35113
  get: function() {