@camstack/addon-post-analysis 1.2.64 → 1.2.66

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.
@@ -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.
@@ -7962,6 +8001,21 @@ var StorageLocationSchema = object({
7962
8001
  nodeId: string().optional(),
7963
8002
  isDefault: boolean().default(false),
7964
8003
  isSystem: boolean().default(false),
8004
+ /**
8005
+ * Operator opt-in: whether consumers that BALANCE across several locations
8006
+ * of a type may write here. Recordings reads it today; event media and
8007
+ * backups are the next consumers, which is why the flag lives on the
8008
+ * location rather than in any one addon's store — nothing has to be
8009
+ * extended to add the next consumer.
8010
+ *
8011
+ * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
8012
+ * flag existed reads back with no flag and keeps working exactly as before;
8013
+ * that is the whole compat story, and it is why no migration ships with it.
8014
+ * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
8015
+ * disk must not silently start writing to it); the default of a type is
8016
+ * always stamped `true`.
8017
+ */
8018
+ enabled: boolean().optional(),
7965
8019
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
7966
8020
  * for node-local locations it can reach) — never persisted, absent when the
7967
8021
  * volume is remote/unreachable. The single capacity truth every UI reads. */
@@ -12501,7 +12555,8 @@ var embeddingEncoderCapability = {
12501
12555
  };
12502
12556
  /**
12503
12557
  * filesystem-browse — per-node capability for browsing the node's local
12504
- * filesystem, sandboxed to operator-configured allowed roots. Used by the
12558
+ * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
12559
+ * are sandboxed to operator-configured allowed roots (D115). Used by the
12505
12560
  * admin "Add filesystem location" flow to pick a node + path. `mode:'per-node'`
12506
12561
  * (one provider per node); the hub calls it with `{nodeId}` so the codegen
12507
12562
  * routes to that exact node (default `nodeIdMode:'routing'`).
@@ -18913,9 +18968,16 @@ var CameraStatusSchema = object({
18913
18968
  audio: CameraAudioStatusSchema.nullable(),
18914
18969
  recording: CameraRecordingStatusSchema.nullable(),
18915
18970
  /**
18916
- * Per-camera function switches an OPERATOR has turned off
18971
+ * Per-camera functions an OPERATOR has turned off
18917
18972
  * ([D61](../../../../docs/decisions/adr-0067.md)).
18918
18973
  *
18974
+ * Composed from the AUTHORITIES themselves — the wrapper bindings,
18975
+ * `RecordingConfig.enabled`, the notification mute, the broker's audio
18976
+ * policy, the camera's own microphone — via `composeSwitchedOff`, not from
18977
+ * the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
18978
+ * The badge outlives the control panel: the panel was a convenience, this is
18979
+ * the difference between a camera being off and a camera being dead.
18980
+ *
18919
18981
  * This is the difference between DISABLED and BROKEN. A camera whose
18920
18982
  * `detection` block reports zero fps and whose `switchedOff` contains
18921
18983
  * `'object-detection'` was switched off by a person; the same camera with an
@@ -25953,7 +26015,7 @@ method(object({
25953
26015
  auth: "admin"
25954
26016
  });
25955
26017
  /**
25956
- * `recordingExport` cap — render a footage time range into a single downloadable
26018
+ * `recording-export` cap — render a footage time range into a single downloadable
25957
26019
  * MP4 (regular / accelerated / decelerated / timelapse, ± audio), kept for a
25958
26020
  * bounded lifetime with a durable history, auto-expiry, and optional
25959
26021
  * delete-after-download.
@@ -25968,10 +26030,42 @@ method(object({
25968
26030
  */
25969
26031
  /** Playback-speed multiplier for the render (1 = realtime). */
25970
26032
  var ExportSpeedSchema = number().min(.25).max(32);
26033
+ /**
26034
+ * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
26035
+ *
26036
+ * Relative and not absolute epoch on purpose: the renderer's frame-select
26037
+ * expression sees ffmpeg's `t`, which starts at 0 for the export's source
26038
+ * playlist. Handing it absolute epochs would make every call site responsible
26039
+ * for the same subtraction, and the one that forgot would emit a filter that
26040
+ * selects nothing — silently, as a uniform timelapse.
26041
+ */
26042
+ var ExportDenseRangeSchema = object({
26043
+ fromSec: number().nonnegative(),
26044
+ toSec: number().nonnegative()
26045
+ }).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
26046
+ /**
26047
+ * Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
26048
+ * listed ranges and at the base `everyMs` everywhere else.
26049
+ *
26050
+ * `everyMs` must be strictly smaller than the base cadence — a dense rate that
26051
+ * is not denser renders a uniform timelapse the operator believes is two-rate.
26052
+ */
26053
+ var ExportDenseSchema = object({
26054
+ everyMs: number().int().positive(),
26055
+ ranges: array(ExportDenseRangeSchema).min(1).max(200)
26056
+ });
25971
26057
  /** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
25972
26058
  var ExportTimelapseSchema = object({
25973
26059
  everyMs: number().int().positive(),
25974
- outputFps: number().int().min(1).max(60).optional()
26060
+ outputFps: number().int().min(1).max(60).optional(),
26061
+ /** Optional second, FASTER rate over the intervals that matter. */
26062
+ dense: ExportDenseSchema.optional()
26063
+ }).superRefine((v, ctx) => {
26064
+ if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
26065
+ code: ZodIssueCode.custom,
26066
+ message: "dense.everyMs must be strictly smaller than the base everyMs",
26067
+ path: ["dense", "everyMs"]
26068
+ });
25975
26069
  });
25976
26070
  /**
25977
26071
  * Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
@@ -26029,6 +26123,38 @@ var ExportDownloadSchema = object({
26029
26123
  url: string(),
26030
26124
  endpoints: array(string())
26031
26125
  });
26126
+ /**
26127
+ * Hard ceiling on ONE {@link recordingExportCapability} byte read — 50 MiB.
26128
+ *
26129
+ * Two independent reasons land on the same number, which is why it is this one
26130
+ * and not a rounder guess:
26131
+ *
26132
+ * - **Nobody would accept more.** The roomiest byte cap any notifier backend
26133
+ * declares is telegram's 50 MiB, and the degrade engine DROPS an over-cap
26134
+ * attachment outright rather than degrading it to a link. Bytes above this
26135
+ * are read, encoded and moved to be thrown away at the last step.
26136
+ * - **The envelope is unary.** A base64 payload is held whole, ~1.33× its
26137
+ * size, in the provider AND in the caller — on a hub this repo has already
26138
+ * OOM'd once (D9/D18). A bounded on-demand read at human speed is the shape
26139
+ * those records permit; an unbounded one is the shape they forbid.
26140
+ *
26141
+ * Above it the provider REFUSES with a log line rather than truncating: half a
26142
+ * video is worse than a notification that says there is no attachment.
26143
+ */
26144
+ var RECORDING_EXPORT_MAX_READ_BYTES = 50 * 1024 * 1024;
26145
+ /**
26146
+ * A finished export's bytes, inline.
26147
+ *
26148
+ * `bytes` is the DECODED length — the number the caller bounds and logs
26149
+ * against, so nobody has to infer it from the base64 length.
26150
+ */
26151
+ var ExportBytesSchema = object({
26152
+ base64: string(),
26153
+ contentType: string(),
26154
+ /** Suggested filename, extension included. */
26155
+ name: string(),
26156
+ bytes: number().int().nonnegative()
26157
+ });
26032
26158
  method(object({
26033
26159
  deviceId: number(),
26034
26160
  profile: string(),
@@ -26053,6 +26179,9 @@ method(object({
26053
26179
  }), method(object({ exportId: string() }), ExportDownloadSchema, {
26054
26180
  kind: "query",
26055
26181
  auth: "protected"
26182
+ }), method(object({ exportId: string() }), ExportBytesSchema, {
26183
+ kind: "query",
26184
+ auth: "protected"
26056
26185
  });
26057
26186
  /**
26058
26187
  * scene-monitor — device-scoped reference-region state cap. An operator marks
@@ -28519,9 +28648,10 @@ var DeclaredDevices = class {
28519
28648
  }
28520
28649
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
28521
28650
  const index = await this.readIndex();
28651
+ const live = await this.readLiveByStableId();
28522
28652
  const outcomes = [];
28523
28653
  for (const declaration of spec.devices) {
28524
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
28654
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
28525
28655
  if (outcome !== null) outcomes.push(outcome);
28526
28656
  }
28527
28657
  return {
@@ -28567,6 +28697,26 @@ var DeclaredDevices = class {
28567
28697
  return new Map(rows.map((row) => [row.stableId, row]));
28568
28698
  }
28569
28699
  /**
28700
+ * Devices this kernel already has CONSTRUCTED, by stableId.
28701
+ *
28702
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
28703
+ * is persisted rows, this is live objects. A row without an object must be
28704
+ * adopted; an object must be left exactly as it is.
28705
+ *
28706
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
28707
+ * previous behaviour (attempt the adopt) rather than skipping a device that
28708
+ * genuinely needs bringing up.
28709
+ */
28710
+ async readLiveByStableId() {
28711
+ try {
28712
+ const devices = await this.ports.devices.getAll();
28713
+ return new Map(devices.map((device) => [device.stableId, device]));
28714
+ } catch (err) {
28715
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
28716
+ return /* @__PURE__ */ new Map();
28717
+ }
28718
+ }
28719
+ /**
28570
28720
  * One declaration: adopt what exists, create what does not.
28571
28721
  *
28572
28722
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -28575,8 +28725,15 @@ var DeclaredDevices = class {
28575
28725
  * the declared name over the operator's rename. D49: that branch needs a
28576
28726
  * second read to agree.
28577
28727
  */
28578
- async applyDeclaration(declaration, integrationId, index) {
28728
+ async applyDeclaration(declaration, integrationId, index, live) {
28579
28729
  try {
28730
+ const alreadyLive = live.get(declaration.stableId);
28731
+ if (alreadyLive !== void 0) return {
28732
+ stableId: declaration.stableId,
28733
+ deviceId: alreadyLive.id,
28734
+ device: alreadyLive,
28735
+ created: false
28736
+ };
28580
28737
  let existing = index.get(declaration.stableId);
28581
28738
  if (existing === void 0) {
28582
28739
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -32882,37 +33039,43 @@ Object.freeze({
32882
33039
  access: "create"
32883
33040
  },
32884
33041
  "recordingExport.cancelExport": {
32885
- capName: "recordingExport",
33042
+ capName: "recording-export",
32886
33043
  capScope: "system",
32887
33044
  addonId: null,
32888
33045
  access: "create"
32889
33046
  },
32890
33047
  "recordingExport.createExport": {
32891
- capName: "recordingExport",
33048
+ capName: "recording-export",
32892
33049
  capScope: "system",
32893
33050
  addonId: null,
32894
33051
  access: "create"
32895
33052
  },
32896
33053
  "recordingExport.deleteExport": {
32897
- capName: "recordingExport",
33054
+ capName: "recording-export",
32898
33055
  capScope: "system",
32899
33056
  addonId: null,
32900
33057
  access: "delete"
32901
33058
  },
32902
33059
  "recordingExport.getDownloadUrl": {
32903
- capName: "recordingExport",
33060
+ capName: "recording-export",
32904
33061
  capScope: "system",
32905
33062
  addonId: null,
32906
33063
  access: "view"
32907
33064
  },
32908
33065
  "recordingExport.getExport": {
32909
- capName: "recordingExport",
33066
+ capName: "recording-export",
32910
33067
  capScope: "system",
32911
33068
  addonId: null,
32912
33069
  access: "view"
32913
33070
  },
32914
33071
  "recordingExport.listExports": {
32915
- capName: "recordingExport",
33072
+ capName: "recording-export",
33073
+ capScope: "system",
33074
+ addonId: null,
33075
+ access: "view"
33076
+ },
33077
+ "recordingExport.readExportBytes": {
33078
+ capName: "recording-export",
32916
33079
  capScope: "system",
32917
33080
  addonId: null,
32918
33081
  access: "view"
@@ -34374,7 +34537,23 @@ var TimelapseRuleInputSchema = object({
34374
34537
  /** Canonical notification priority ordinal (1..5); per-target overridable. */
34375
34538
  priority: PriorityField.default(3)
34376
34539
  });
34377
- object({
34540
+ /**
34541
+ * Partial patch for an update — any subset of the INPUT fields, with NO
34542
+ * defaults (an absent key means "leave unchanged", never "reset to default").
34543
+ * Provenance and ownership are absent by construction: a patch can rename or
34544
+ * retune a rule, never re-own it or forge its generation state.
34545
+ *
34546
+ * CLEAR SIGNAL: `template` is the one OPTIONAL field an editor can REMOVE, so
34547
+ * it is `.nullable()` here and NOWHERE else. Wire representation:
34548
+ * - key absent → leave the template unchanged
34549
+ * - `"template": null` → CLEAR it (the persisted rule loses the key)
34550
+ * - `"template": {...}` → replace it
34551
+ * `undefined` is deliberately NOT the clear signal: it does not survive
34552
+ * `JSON.stringify`, so a viewer editor could not express "remove". The
34553
+ * PERSISTED rule never carries `null` — the store drops the key (see
34554
+ * `TimelapseStore.update`).
34555
+ */
34556
+ var TimelapseRulePatchSchema = object({
34378
34557
  name: NameField.optional(),
34379
34558
  enabled: boolean().optional(),
34380
34559
  deviceIds: DeviceIdsField.optional(),
@@ -34395,15 +34574,50 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
34395
34574
  */
34396
34575
  ownerUserId: string().optional(),
34397
34576
  /**
34398
- * Epoch-ms of the last successful generation — the 1-hour re-generation
34399
- * guard's durable state (predecessor parity). Absent = never generated.
34577
+ * Epoch-ms of the NEWEST successful generation across every camera of this
34578
+ * rule. What a UI shows, and the compatibility floor for
34579
+ * {@link readTimelapseGeneratedAt}. Absent = never generated.
34400
34580
  */
34401
34581
  lastGeneratedAt: number().optional(),
34582
+ /**
34583
+ * PER-CAMERA generation state, keyed by `String(deviceId)` — the
34584
+ * re-generation guard's real durable state.
34585
+ *
34586
+ * One rule covers several cameras and each renders its own video, so a rule
34587
+ * -wide stamp is wrong in the direction that DESTROYS work: camera A
34588
+ * succeeding at 06:05 tells camera B, whose render failed, that it is
34589
+ * already done — and B's night is gone for good, because the window will not
34590
+ * come back.
34591
+ *
34592
+ * ADDITIVE, so the migration is free: a row written before this field simply
34593
+ * has no map, and {@link readTimelapseGeneratedAt} falls back to
34594
+ * {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
34595
+ * "never generated" would re-render and re-notify every camera of every rule
34596
+ * once, on the deploy that shipped the map.
34597
+ */
34598
+ generatedByDevice: record(string(), number()).optional(),
34402
34599
  /** userId of the caller who created the rule (server-stamped). */
34403
34600
  createdBy: string(),
34404
34601
  createdAt: number(),
34405
34602
  updatedAt: number()
34406
34603
  });
34604
+ /**
34605
+ * The last successful generation for ONE camera of a rule, epoch-ms.
34606
+ *
34607
+ * The per-device map wins; a rule with no map falls back to the rule-wide
34608
+ * `lastGeneratedAt` (the compatible-migration path — see
34609
+ * {@link TimelapseRuleSchema}); a rule with neither returns 0, which every
34610
+ * guard reads as "never generated".
34611
+ *
34612
+ * Read through this helper and never off the field directly: the fallback is
34613
+ * the whole migration, and a call site that forgot it would re-render an
34614
+ * entire rule set once.
34615
+ */
34616
+ function readTimelapseGeneratedAt(rule, deviceId) {
34617
+ const map = rule.generatedByDevice;
34618
+ if (map !== void 0) return map[String(deviceId)] ?? 0;
34619
+ return rule.lastGeneratedAt ?? 0;
34620
+ }
34407
34621
  object({
34408
34622
  /**
34409
34623
  * Fraction of the box's own size added on EACH side before cutting.
@@ -34682,6 +34896,12 @@ Object.defineProperty(exports, "OpsLogEntrySchema", {
34682
34896
  return OpsLogEntrySchema;
34683
34897
  }
34684
34898
  });
34899
+ Object.defineProperty(exports, "RECORDING_EXPORT_MAX_READ_BYTES", {
34900
+ enumerable: true,
34901
+ get: function() {
34902
+ return RECORDING_EXPORT_MAX_READ_BYTES;
34903
+ }
34904
+ });
34685
34905
  Object.defineProperty(exports, "RetrainStatusSchema", {
34686
34906
  enumerable: true,
34687
34907
  get: function() {
@@ -34694,6 +34914,12 @@ Object.defineProperty(exports, "TimelapseRuleInputSchema", {
34694
34914
  return TimelapseRuleInputSchema;
34695
34915
  }
34696
34916
  });
34917
+ Object.defineProperty(exports, "TimelapseRulePatchSchema", {
34918
+ enumerable: true,
34919
+ get: function() {
34920
+ return TimelapseRulePatchSchema;
34921
+ }
34922
+ });
34697
34923
  Object.defineProperty(exports, "TimelapseRuleSchema", {
34698
34924
  enumerable: true,
34699
34925
  get: function() {
@@ -34784,6 +35010,12 @@ Object.defineProperty(exports, "defineCustomActions", {
34784
35010
  return defineCustomActions;
34785
35011
  }
34786
35012
  });
35013
+ Object.defineProperty(exports, "deriveRecordingMode", {
35014
+ enumerable: true,
35015
+ get: function() {
35016
+ return deriveRecordingMode;
35017
+ }
35018
+ });
34787
35019
  Object.defineProperty(exports, "embeddingEncoderCapability", {
34788
35020
  enumerable: true,
34789
35021
  get: function() {
@@ -34886,6 +35118,12 @@ Object.defineProperty(exports, "readDeviceStateFrom", {
34886
35118
  return readDeviceStateFrom;
34887
35119
  }
34888
35120
  });
35121
+ Object.defineProperty(exports, "readTimelapseGeneratedAt", {
35122
+ enumerable: true,
35123
+ get: function() {
35124
+ return readTimelapseGeneratedAt;
35125
+ }
35126
+ });
34889
35127
  Object.defineProperty(exports, "record", {
34890
35128
  enumerable: true,
34891
35129
  get: function() {