camstack 1.2.21 → 1.2.23

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.
@@ -15626,6 +15626,7 @@ var RecordingConfigSchema = external_exports.object({
15626
15626
  scrubThumbnails: ScrubThumbnailPresetSchema.optional()
15627
15627
  }).strict();
15628
15628
  var RelocateJobStateSchema = external_exports.enum([
15629
+ "queued",
15629
15630
  "running",
15630
15631
  "done",
15631
15632
  "failed",
@@ -15658,6 +15659,15 @@ var RelocateFootageInputSchema = external_exports.object({
15658
15659
  /** Limits relocation to the logical profile class. Omit only for the
15659
15660
  * pre-orchestration compatibility path. */
15660
15661
  footageClass: RelocateFootageClassSchema.optional(),
15662
+ /** Scope the move to ONE camera. Absent = every camera on the source, which
15663
+ * is what a whole-disk drain means. The rebalance path always sets it: its
15664
+ * unit is a (camera, profile) pile, not a disk. */
15665
+ deviceId: external_exports.number().int().optional(),
15666
+ /** Scope the move to specific segment profiles (`high` / `mid` / `low`).
15667
+ * Finer than `footageClass`, which cannot separate high from mid — and the
15668
+ * placement plan assigns those two independently, so a rebalance that could
15669
+ * only say "recordings" would move footage the plan never asked to move. */
15670
+ profiles: external_exports.array(external_exports.string()).optional(),
15661
15671
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
15662
15672
  * never allowed to starve live writers. */
15663
15673
  throttleMbps: external_exports.number().min(1).max(1e3).optional()
@@ -15748,6 +15758,21 @@ var StorageLocationSchema = external_exports.object({
15748
15758
  nodeId: external_exports.string().optional(),
15749
15759
  isDefault: external_exports.boolean().default(false),
15750
15760
  isSystem: external_exports.boolean().default(false),
15761
+ /**
15762
+ * Operator opt-in: whether consumers that BALANCE across several locations
15763
+ * of a type may write here. Recordings reads it today; event media and
15764
+ * backups are the next consumers, which is why the flag lives on the
15765
+ * location rather than in any one addon's store — nothing has to be
15766
+ * extended to add the next consumer.
15767
+ *
15768
+ * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
15769
+ * flag existed reads back with no flag and keeps working exactly as before;
15770
+ * that is the whole compat story, and it is why no migration ships with it.
15771
+ * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
15772
+ * disk must not silently start writing to it); the default of a type is
15773
+ * always stamped `true`.
15774
+ */
15775
+ enabled: external_exports.boolean().optional(),
15751
15776
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
15752
15777
  * for node-local locations it can reach) — never persisted, absent when the
15753
15778
  * volume is remote/unreachable. The single capacity truth every UI reads. */
@@ -20642,9 +20667,9 @@ var filesystemBrowseCapability = {
20642
20667
  mode: "singleton",
20643
20668
  internal: true,
20644
20669
  methods: {
20645
- /** The allowed roots browsing is sandboxed to on this node. */
20670
+ /** The roots `createDir` is sandboxed to on this node. Browsing is not confined to them. */
20646
20671
  listAllowedRoots: method(external_exports.void(), external_exports.array(external_exports.string()).readonly(), { auth: "admin" }),
20647
- /** Immediate subdirectories of `path` (must be within an allowed root) + free/total bytes. */
20672
+ /** Immediate subdirectories of any absolute `path` + free/total bytes. Read-only. */
20648
20673
  browse: method(external_exports.object({ path: external_exports.string() }), BrowseResultSchema, { auth: "admin" }),
20649
20674
  /** Create a subdirectory (within an allowed root). Returns its absolute path. */
20650
20675
  createDir: method(external_exports.object({ path: external_exports.string() }), external_exports.object({ path: external_exports.string() }), {
@@ -22359,18 +22384,46 @@ var NcDeliverySchema = external_exports.enum([
22359
22384
  "system-event"
22360
22385
  ]);
22361
22386
  var NcSystemEventKindSchema = external_exports.enum([
22362
- "camera-online",
22363
- "camera-offline",
22387
+ "device-online",
22388
+ "device-offline",
22389
+ "device-disabled",
22390
+ "device-enabled",
22364
22391
  "stream-online",
22365
22392
  "stream-offline",
22366
22393
  "node-online",
22367
22394
  "node-offline",
22368
22395
  "addon-update-available",
22369
- "server-update-available"
22396
+ "server-update-available",
22397
+ "alarm-triggered",
22398
+ "alarm-armed",
22399
+ "alarm-disarmed",
22400
+ "camera-online",
22401
+ "camera-offline",
22402
+ "camera-disabled",
22403
+ "camera-enabled"
22370
22404
  ]);
22405
+ var NC_LEGACY_SYSTEM_EVENT_KINDS = /* @__PURE__ */ new Set([
22406
+ "camera-online",
22407
+ "camera-offline",
22408
+ "camera-disabled",
22409
+ "camera-enabled"
22410
+ ]);
22411
+ var NC_AUTHORABLE_SYSTEM_EVENT_KINDS = NcSystemEventKindSchema.options.filter((kind) => !NC_LEGACY_SYSTEM_EVENT_KINDS.has(kind));
22371
22412
  var NcSystemEventConditionSchema = external_exports.object({
22372
22413
  kinds: external_exports.array(NcSystemEventKindSchema).min(1),
22373
22414
  deviceIds: external_exports.array(external_exports.number().int()).min(1).optional(),
22415
+ /**
22416
+ * Narrow a `device-*` kind to these device TYPES (`DeviceType` values —
22417
+ * `camera`, `switch`, `sensor`, `container`, …). Absent = every type, which
22418
+ * is what a liveness rule means when nobody said otherwise.
22419
+ *
22420
+ * This is where "only my cameras" is expressed, and it lives on the rule for
22421
+ * one reason: the intake cannot know which devices this household cares
22422
+ * about, and a producer-side filter is one no operator can change. Fails
22423
+ * CLOSED — a subject whose device type is unknown (an id the device mirror
22424
+ * does not carry) matches no `deviceTypes` list.
22425
+ */
22426
+ deviceTypes: external_exports.array(external_exports.string().min(1)).min(1).optional(),
22374
22427
  nodeIds: external_exports.array(external_exports.string().min(1)).min(1).optional(),
22375
22428
  packageNames: external_exports.array(external_exports.string().min(1)).min(1).optional()
22376
22429
  });
@@ -22406,6 +22459,16 @@ var NcOccupancyConditionSchema = external_exports.object({
22406
22459
  count: external_exports.number().int().min(0).default(1),
22407
22460
  sustainSeconds: external_exports.number().int().min(0).max(3600).default(15)
22408
22461
  });
22462
+ var NcAudioConditionSchema = external_exports.object({
22463
+ /** Audio macro labels; absent = any sound (level-only rule). */
22464
+ labels: external_exports.array(external_exports.string().min(1)).min(1).optional(),
22465
+ /** Level floor in dBFS (negative-going, `0` = full scale); absent = any level. */
22466
+ dbThreshold: external_exports.number().min(-96).max(0).optional(),
22467
+ /** Percentage of the window's samples that must be hits (1–100). */
22468
+ hitPercent: external_exports.number().int().min(1).max(100).default(60),
22469
+ /** Length of the sampling window in seconds. */
22470
+ samplingSeconds: external_exports.number().int().min(1).max(300).default(10)
22471
+ });
22409
22472
  var NcCrossingSchema = external_exports.enum([
22410
22473
  "enter",
22411
22474
  "exit",
@@ -22604,7 +22667,33 @@ var NcConditionsSchema = external_exports.object({
22604
22667
  * threshold and holds for `sustainSeconds`. Fail-closed on missing
22605
22668
  * substrate (no snapshot / missing zone). See {@link NcOccupancyCondition}.
22606
22669
  */
22607
- occupancy: NcOccupancyConditionSchema.optional()
22670
+ occupancy: NcOccupancyConditionSchema.optional(),
22671
+ /**
22672
+ * IMMEDIATE only. Sustained-sound matcher — `hitPercent` of the samples in a
22673
+ * `samplingSeconds` window clear the optional `dbThreshold` and carry one of
22674
+ * the optional `labels`. Fail-closed on missing substrate (no audio samples,
22675
+ * a window that is not full yet, neither filter given). See
22676
+ * {@link NcAudioCondition}.
22677
+ *
22678
+ * Presence of this key is what makes a rule an AUDIO rule: the engine fires
22679
+ * it ONLY on a confirmed audio window, and a rule carrying it never fires on
22680
+ * a detection, a track or a device event (the same fail-closed pairing
22681
+ * `occupancy` has with the `device-event` trigger). That is how audio labels
22682
+ * leave `classes`: an audio rule names its sounds HERE, and the legacy path
22683
+ * (an `immediate` rule naming an `audio-*` class, one notification per
22684
+ * classified sample) stays exactly as it was for rules that already use it.
22685
+ *
22686
+ * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
22687
+ * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
22688
+ * (`camstack/src/data/notification-center.ts`, guarded by
22689
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
22690
+ * condition fields it does not know when a rule is saved from the phone.
22691
+ * Publishing an editor for a condition the app cannot round-trip is how an
22692
+ * operator loses a rule's conditions by opening it — so the descriptor, the
22693
+ * admin widget and the viewer mirror land together (P2 + P3), and only then
22694
+ * does an audio rule become authorable.
22695
+ */
22696
+ audio: NcAudioConditionSchema.optional()
22608
22697
  });
22609
22698
  var NcRuleTargetSchema = external_exports.object({
22610
22699
  /** `notification-output` Target id. */
@@ -22681,6 +22770,44 @@ var NcThrottleSchema = external_exports.object({
22681
22770
  */
22682
22771
  granularity: NcThrottleGranularitySchema.optional()
22683
22772
  });
22773
+ var NC_CONFIRM_MIN_TIMEOUT_MS = 1e3;
22774
+ var NC_CONFIRM_MAX_TIMEOUT_MS = 2e4;
22775
+ var NC_CONFIRM_DEFAULT_TIMEOUT_MS = 8e3;
22776
+ var NcConfirmExpectSchema = external_exports.object({
22777
+ op: external_exports.enum([
22778
+ ">=",
22779
+ ">",
22780
+ "<=",
22781
+ "<",
22782
+ "=="
22783
+ ]),
22784
+ count: external_exports.number().int().min(0).max(1e3)
22785
+ });
22786
+ var NcConfirmSchema = external_exports.object({
22787
+ /** Off unless asked for. An absent `confirm` and `enabled:false` are the
22788
+ * same thing, and both mean "deliver exactly as before". */
22789
+ enabled: external_exports.boolean().default(false),
22790
+ /** Explicit vision profile; absent = the `purpose:'vision'` cluster default. */
22791
+ profileId: external_exports.string().optional(),
22792
+ /**
22793
+ * The operator's question, in his own words. Absent = a question derived
22794
+ * from the rule (its class and its expectation).
22795
+ *
22796
+ * NEVER composed with text READ OUT OF THE FRAME. The model reads OSD
22797
+ * banners, signage and plates as instructions if you let them reach the
22798
+ * prompt — proven live — so the authoritative contract stays in the system
22799
+ * turn and only rule-authored words land here.
22800
+ */
22801
+ prompt: external_exports.string().max(1e3).optional(),
22802
+ /** Fire only when the model's count satisfies this. Absent = the model's
22803
+ * own boolean verdict decides. */
22804
+ expect: NcConfirmExpectSchema.optional(),
22805
+ timeoutMs: external_exports.number().int().min(NC_CONFIRM_MIN_TIMEOUT_MS).max(NC_CONFIRM_MAX_TIMEOUT_MS).default(NC_CONFIRM_DEFAULT_TIMEOUT_MS),
22806
+ /** What a timeout / unreachable model MEANS. `fire` (default) = fail-open. */
22807
+ onTimeout: external_exports.enum(["fire", "suppress"]).default("fire"),
22808
+ /** Longest edge the judged image is downscaled to before it is sent. */
22809
+ maxImagePx: external_exports.number().int().min(64).max(2048).default(448)
22810
+ });
22684
22811
  var NcRuleInputSchema = external_exports.object({
22685
22812
  name: external_exports.string().min(1).max(200),
22686
22813
  enabled: external_exports.boolean().default(true),
@@ -22740,9 +22867,45 @@ var NcRuleInputSchema = external_exports.object({
22740
22867
  * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
22741
22868
  * shape as every other actuation.
22742
22869
  */
22743
- actions: NcRuleActionsSchema.optional()
22870
+ actions: NcRuleActionsSchema.optional(),
22871
+ /**
22872
+ * AI CONFIRM — see {@link NcConfirmSchema}. `.optional()`, never defaulted:
22873
+ * a rule that predates the gate must keep delivering byte-for-byte as it
22874
+ * did, and absent is the only way to say that without a migration.
22875
+ */
22876
+ confirm: NcConfirmSchema.optional()
22877
+ });
22878
+ var NcRulePatchSchema = NcRuleInputSchema.partial().extend({
22879
+ disabledTargetIds: external_exports.array(external_exports.string()).optional(),
22880
+ /**
22881
+ * `.partial()` DOES NOT REMOVE A FIELD'S `.default()`.
22882
+ *
22883
+ * It makes the key optional to SUPPLY; the parse still materialises the
22884
+ * default when the key is absent. And `NcRuleStore.update` merges with
22885
+ * `{ ...existing, ...patch }`, so a materialised key OVERWRITES the stored
22886
+ * one — which made every partial edit destructive:
22887
+ *
22888
+ * nc.updateRule({ throttle }) → conditions reset to `{}`
22889
+ * nc.setRuleTargetEnabled(...) → conditions reset to `{}`
22890
+ * setEnabled(ruleId, false) → conditions reset to `{}`
22891
+ *
22892
+ * A rule scoped to one camera and one zone silently became a rule that
22893
+ * matches EVERY event on EVERY camera, and lost its `media` policy
22894
+ * (zoneCrop / gif / clip / frame) and its `priority` at the same time. Seen
22895
+ * live on 2026-08-12: a rule scoped to device 617 fired on 590 and 615
22896
+ * within a minute of a two-field patch.
22897
+ *
22898
+ * So every defaulted field is re-declared here WITHOUT its default. The
22899
+ * inner defaults still apply when the caller DOES send the key — `{}` for
22900
+ * conditions remains a real instruction ("clear them") — and only the
22901
+ * absent key is now genuinely absent.
22902
+ */
22903
+ enabled: external_exports.boolean().optional(),
22904
+ conditions: NcConditionsSchema.optional(),
22905
+ media: NcMediaPolicySchema.optional(),
22906
+ throttle: NcThrottleSchema.optional(),
22907
+ priority: external_exports.number().int().min(1).max(5).optional()
22744
22908
  });
22745
- var NcRulePatchSchema = NcRuleInputSchema.partial().extend({ disabledTargetIds: external_exports.array(external_exports.string()).optional() });
22746
22909
  var NcRuleSchema = NcRuleInputSchema.extend({
22747
22910
  id: external_exports.string(),
22748
22911
  /** userId of the admin who created the rule (server-stamped caller). */
@@ -22985,6 +23148,15 @@ var NcAlarmSettingsSchema = external_exports.object({
22985
23148
  announceTargets: external_exports.array(external_exports.string().min(1)).max(16)
22986
23149
  });
22987
23150
  var NcAlarmSettingsPatchSchema = NcAlarmSettingsSchema.partial();
23151
+ var NcAlarmSkipReasonSchema = external_exports.enum([
23152
+ "muted",
23153
+ "detection-off",
23154
+ "offline"
23155
+ ]);
23156
+ var NcAlarmSkippedDeviceSchema = external_exports.object({
23157
+ deviceId: external_exports.number().int(),
23158
+ reason: NcAlarmSkipReasonSchema
23159
+ });
22988
23160
  var NcAlarmModeCoverageSchema = external_exports.object({
22989
23161
  mode: AlarmArmModeSchema,
22990
23162
  /** Enabled rules gated on `armed_<mode>`. Zero means the mode does nothing. */
@@ -22992,7 +23164,18 @@ var NcAlarmModeCoverageSchema = external_exports.object({
22992
23164
  /** At least one covering rule has no device scope, so the mode covers all. */
22993
23165
  allDevices: external_exports.boolean(),
22994
23166
  /** Ids named by the covering rules. A SUBSET when `allDevices` is true. */
22995
- deviceIds: external_exports.array(external_exports.number().int())
23167
+ deviceIds: external_exports.array(external_exports.number().int()),
23168
+ /**
23169
+ * Devices this mode NAMES but cannot actually arm, each with the switch that
23170
+ * excludes it.
23171
+ *
23172
+ * "Away armed — 12 cameras" is a promise, and a muted camera among those
23173
+ * twelve makes it false in exactly the way nobody notices until an incident.
23174
+ * Defaulted to `[]` so a coverage answer computed before this field existed
23175
+ * still parses as "nothing known to be skipped" rather than failing the whole
23176
+ * alarm tab.
23177
+ */
23178
+ skippedDevices: external_exports.array(NcAlarmSkippedDeviceSchema).default([])
22996
23179
  });
22997
23180
  var NcAlarmConfigSchema = external_exports.object({
22998
23181
  /**
@@ -26110,9 +26293,16 @@ var CameraStatusSchema = external_exports.object({
26110
26293
  audio: CameraAudioStatusSchema.nullable(),
26111
26294
  recording: CameraRecordingStatusSchema.nullable(),
26112
26295
  /**
26113
- * Per-camera function switches an OPERATOR has turned off
26296
+ * Per-camera functions an OPERATOR has turned off
26114
26297
  * ([D61](../../../../docs/decisions/adr-0067.md)).
26115
26298
  *
26299
+ * Composed from the AUTHORITIES themselves — the wrapper bindings,
26300
+ * `RecordingConfig.enabled`, the notification mute, the broker's audio
26301
+ * policy, the camera's own microphone — via `composeSwitchedOff`, not from
26302
+ * the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
26303
+ * The badge outlives the control panel: the panel was a convenience, this is
26304
+ * the difference between a camera being off and a camera being dead.
26305
+ *
26116
26306
  * This is the difference between DISABLED and BROKEN. A camera whose
26117
26307
  * `detection` block reports zero fps and whose `switchedOff` contains
26118
26308
  * `'object-detection'` was switched off by a person; the same camera with an
@@ -26539,6 +26729,10 @@ var pipelineOrchestratorCapability = {
26539
26729
  agentNodeId: external_exports.string().optional()
26540
26730
  }), CameraPipelineConfigSchema),
26541
26731
  /**
26732
+ * @deprecated The aggregate switch group is being withdrawn
26733
+ * ([D113](../../../../docs/decisions/adr-0113.md)). Build nothing new on
26734
+ * this pair; read the authority directly.
26735
+ *
26542
26736
  * The whole per-camera function switch group, DERIVED — never a stored
26543
26737
  * list ([D61](../../../../docs/decisions/adr-0067.md)).
26544
26738
  *
@@ -26552,9 +26746,23 @@ var pipelineOrchestratorCapability = {
26552
26746
  * `auth: 'view'` deliberately — a NON-admin must be able to see that a
26553
26747
  * camera is quiet because somebody switched it off. Only the mutation is
26554
26748
  * admin-gated.
26749
+ *
26750
+ * **Removal plan.** It stays and it KEEPS WORKING while shipped viewers
26751
+ * (v1.0.305) and the admin UI still call it — removing it now is a broken
26752
+ * app on a device nobody can redeploy from here. It is served by a thin
26753
+ * shim over the same authorities (`camera-switch-service.ts`), so the
26754
+ * behaviour of the pair is the behaviour of the authorities by
26755
+ * construction. It is deleted once every surface reaches its own
26756
+ * component's options and the last caller is gone. Nothing on this server
26757
+ * reads it: `CameraStatus.switchedOff` is composed from the authorities
26758
+ * directly via `composeSwitchedOff`.
26555
26759
  */
26556
26760
  getCameraSwitches: method(external_exports.object({ deviceId: external_exports.number() }), CameraSwitchGroupSchema),
26557
26761
  /**
26762
+ * @deprecated See {@link getCameraSwitches}. Write the authority — the
26763
+ * wrapper binding, `RecordingConfig.enabled`, the notification mute — not
26764
+ * this ([D113](../../../../docs/decisions/adr-0113.md)).
26765
+ *
26558
26766
  * Flip ONE switch, routed to its existing authority.
26559
26767
  *
26560
26768
  * Never writes a parallel map: `recording` patches `RecordingConfig.enabled`
@@ -26932,24 +27140,28 @@ var snapshotCapability = {
26932
27140
  *
26933
27141
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
26934
27142
  * the wrapper happens to hold and never captures. Under D93 the client
26935
- * versions its image URL on that answer, and an image REQUEST is what enrols
26936
- * a camera in the keep-warm loop. Both of those are satisfiable by the
26937
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
26938
- * — so a URL painted in a previous session comes off disk with no network,
26939
- * no enrolment, and nothing warming. Measured on the live hub: reopening
26940
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
26941
- * HTTP requests, and the fleet only recovered because a later poll happened
26942
- * to observe a different identity.
27143
+ * versions its image URL on that answer, and an image REQUEST was the only
27144
+ * demand signal. Both of those are satisfiable by the client's own image
27145
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
27146
+ * in a previous session comes off disk with no network, no demand, and no
27147
+ * capture. Measured on the live hub: reopening after two minutes idle
27148
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
27149
+ * fleet only recovered because a later poll happened to observe a different
27150
+ * identity.
26943
27151
  *
26944
27152
  * ## The two properties that fix it
26945
27153
  *
26946
27154
  * **It is an RPC, so no client cache can answer it.** The demand signal
26947
- * always reaches the wrapper. This method therefore MAY create keep-warm
26948
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
26949
- * distinction is not "one is newer" but that the overview poll is app-wide
26950
- * (a creating overview would warm every camera on the install) while this is
26951
- * called by a rendered surface naming the tiles it is actually painting, at
26952
- * the width it is painting them.
27155
+ * always reaches the wrapper. This method therefore CAPTURES, where
27156
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
27157
+ * newer" but that the overview poll is app-wide (a capturing overview would
27158
+ * dial every camera on the install) while this is called by a rendered
27159
+ * surface naming the tiles it is actually painting, at the width it is
27160
+ * painting them.
27161
+ *
27162
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
27163
+ * server-side keep-warm loop was removed (operator directive — on-demand,
27164
+ * always), so a camera nobody is looking at costs nothing at all.
26953
27165
  *
26954
27166
  * **It waits, briefly and boundedly, for the capture it triggered.** The
26955
27167
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -32987,7 +33199,19 @@ var RecordingManifestSchema = external_exports.object({
32987
33199
  });
32988
33200
  var RecordingDeviceUsageSchema = external_exports.object({
32989
33201
  deviceId: external_exports.number(),
32990
- usedBytes: external_exports.number()
33202
+ usedBytes: external_exports.number(),
33203
+ /**
33204
+ * Start of this camera's OLDEST indexed segment, across every profile and
33205
+ * location — the "Oldest footage" column in Recordings → Storage, and the
33206
+ * only honest answer to "is retention actually holding?" per camera.
33207
+ *
33208
+ * `null` = the camera has no footage. OPTIONAL because a recorder that
33209
+ * predates this field omits it entirely, and a hub whose types carry the
33210
+ * field must keep validating that older provider's payload: the framework
33211
+ * (types) and the addon ship on different trains, and the addon is usually
33212
+ * the later of the two.
33213
+ */
33214
+ oldestMs: external_exports.number().nullable().optional()
32991
33215
  });
32992
33216
  var RecordingLocationUsageSchema = external_exports.object({
32993
33217
  /** StorageLocation id; null for the legacy/degraded single-root fallback. */
@@ -33010,6 +33234,42 @@ var RecordingStorageUsageSchema = external_exports.object({
33010
33234
  devices: external_exports.array(RecordingDeviceUsageSchema),
33011
33235
  locations: external_exports.array(RecordingLocationUsageSchema)
33012
33236
  });
33237
+ var RecordingRebalanceMoveSchema = external_exports.object({
33238
+ deviceId: external_exports.number(),
33239
+ profile: external_exports.string(),
33240
+ fromLocationId: external_exports.string(),
33241
+ toLocationId: external_exports.string(),
33242
+ bytes: external_exports.number(),
33243
+ files: external_exports.number().int()
33244
+ });
33245
+ var RecordingRebalanceSkipReasonSchema = external_exports.enum([
33246
+ "unassigned",
33247
+ "target-not-writable",
33248
+ "below-threshold",
33249
+ "no-headroom"
33250
+ ]);
33251
+ var RecordingRebalanceSkipSchema = external_exports.object({
33252
+ deviceId: external_exports.number(),
33253
+ profile: external_exports.string(),
33254
+ fromLocationId: external_exports.string(),
33255
+ /** The location the plan wants; null when the camera has no assignment. */
33256
+ toLocationId: external_exports.string().nullable(),
33257
+ bytes: external_exports.number(),
33258
+ reason: RecordingRebalanceSkipReasonSchema
33259
+ });
33260
+ var RecordingRebalancePlanSchema = external_exports.object({
33261
+ moves: external_exports.array(RecordingRebalanceMoveSchema),
33262
+ skipped: external_exports.array(RecordingRebalanceSkipSchema),
33263
+ bytesToMove: external_exports.number(),
33264
+ /** Relocate job ids enqueued. Always empty for the plan (dry-run) call. */
33265
+ jobIds: external_exports.array(external_exports.string())
33266
+ });
33267
+ var RecordingRebalanceInputSchema = external_exports.object({
33268
+ /** Copy throttle in MB/s (default 40) — a rebalance is a background chore. */
33269
+ throttleMbps: external_exports.number().min(1).max(1e3).optional(),
33270
+ /** Ignore piles smaller than this (default 1 GB). */
33271
+ minMoveGb: external_exports.number().min(0).optional()
33272
+ });
33013
33273
  var LocateSegmentResultSchema = external_exports.discriminatedUnion("kind", [external_exports.object({
33014
33274
  kind: external_exports.literal("segment"),
33015
33275
  startMs: external_exports.number(),
@@ -33235,13 +33495,59 @@ var recordingCapability = {
33235
33495
  cancelStorageMigrationMove: method(external_exports.object({ jobId: external_exports.string() }), external_exports.object({ cancelled: external_exports.boolean() }), {
33236
33496
  kind: "mutation",
33237
33497
  auth: "admin"
33498
+ }),
33499
+ /**
33500
+ * Move footage between locations — the OPERATOR's mover, scoped to one
33501
+ * camera (and optionally to specific profiles) rather than a whole disk.
33502
+ * Queued FIFO behind the single-flight engine, so arming several is safe.
33503
+ */
33504
+ relocateFootage: method(RelocateFootageInputSchema, external_exports.object({ jobId: external_exports.string() }), {
33505
+ kind: "mutation",
33506
+ auth: "admin"
33507
+ }),
33508
+ /** Every relocate job this recorder knows about, newest first (in RAM: the
33509
+ * move is resumable, so a lost list costs nothing but the display). */
33510
+ listRelocateJobs: method(external_exports.object({}), external_exports.array(RelocateJobSchema).readonly(), {
33511
+ kind: "query",
33512
+ auth: "admin"
33513
+ }),
33514
+ /** Cancel a running or queued relocate job. A queued job never runs. */
33515
+ cancelRelocateJob: method(external_exports.object({ jobId: external_exports.string() }), external_exports.object({ cancelled: external_exports.boolean() }), {
33516
+ kind: "mutation",
33517
+ auth: "admin"
33518
+ }),
33519
+ /** What a rebalance WOULD move, and what it would refuse. Moves nothing. */
33520
+ planStorageRebalance: method(RecordingRebalanceInputSchema, RecordingRebalancePlanSchema, {
33521
+ kind: "query",
33522
+ auth: "admin"
33523
+ }),
33524
+ /** Arm the rebalance: enqueue one relocate job per planned move. */
33525
+ startStorageRebalance: method(RecordingRebalanceInputSchema, RecordingRebalancePlanSchema, {
33526
+ kind: "mutation",
33527
+ auth: "admin"
33238
33528
  })
33239
33529
  }
33240
33530
  };
33241
33531
  var ExportSpeedSchema = external_exports.number().min(0.25).max(32);
33532
+ var ExportDenseRangeSchema = external_exports.object({
33533
+ fromSec: external_exports.number().nonnegative(),
33534
+ toSec: external_exports.number().nonnegative()
33535
+ }).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
33536
+ var ExportDenseSchema = external_exports.object({
33537
+ everyMs: external_exports.number().int().positive(),
33538
+ ranges: external_exports.array(ExportDenseRangeSchema).min(1).max(200)
33539
+ });
33242
33540
  var ExportTimelapseSchema = external_exports.object({
33243
33541
  everyMs: external_exports.number().int().positive(),
33244
- outputFps: external_exports.number().int().min(1).max(60).optional()
33542
+ outputFps: external_exports.number().int().min(1).max(60).optional(),
33543
+ /** Optional second, FASTER rate over the intervals that matter. */
33544
+ dense: ExportDenseSchema.optional()
33545
+ }).superRefine((v, ctx) => {
33546
+ if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
33547
+ code: external_exports.ZodIssueCode.custom,
33548
+ message: "dense.everyMs must be strictly smaller than the base everyMs",
33549
+ path: ["dense", "everyMs"]
33550
+ });
33245
33551
  });
33246
33552
  var ExportOptionsSchema = external_exports.object({
33247
33553
  speed: ExportSpeedSchema.optional(),
@@ -33291,8 +33597,16 @@ var ExportDownloadSchema = external_exports.object({
33291
33597
  url: external_exports.string(),
33292
33598
  endpoints: external_exports.array(external_exports.string())
33293
33599
  });
33600
+ var RECORDING_EXPORT_MAX_READ_BYTES = 50 * 1024 * 1024;
33601
+ var ExportBytesSchema = external_exports.object({
33602
+ base64: external_exports.string(),
33603
+ contentType: external_exports.string(),
33604
+ /** Suggested filename, extension included. */
33605
+ name: external_exports.string(),
33606
+ bytes: external_exports.number().int().nonnegative()
33607
+ });
33294
33608
  var recordingExportCapability = {
33295
- name: "recordingExport",
33609
+ name: "recording-export",
33296
33610
  scope: "system",
33297
33611
  mode: "singleton",
33298
33612
  methods: {
@@ -33330,6 +33644,27 @@ var recordingExportCapability = {
33330
33644
  getDownloadUrl: method(external_exports.object({ exportId: external_exports.string() }), ExportDownloadSchema, {
33331
33645
  kind: "query",
33332
33646
  auth: "protected"
33647
+ }),
33648
+ /**
33649
+ * The finished file's BYTES, base64, for a caller that must republish them
33650
+ * somewhere a session-less fetcher can reach.
33651
+ *
33652
+ * `getDownloadUrl` is the right answer for a human: the download route is
33653
+ * served `access: 'authenticated'`, which a browser satisfies and a
33654
+ * notifier BACKEND does not. It answers a RELATIVE path, so it is not even
33655
+ * a URL an outside fetcher could try. This method exists for the one case
33656
+ * that needs the other thing — a scheduled timelapse whose video has to
33657
+ * become a public attachment on the notification artifact plane.
33658
+ *
33659
+ * Deliberately narrow: `ready` only (a queued, rendering, failed, expired
33660
+ * or deleted export has no file, and answering "0 bytes" for one is how a
33661
+ * caller ships an empty attachment), still inside its lifetime, and under
33662
+ * {@link RECORDING_EXPORT_MAX_READ_BYTES}. Every refusal throws with the
33663
+ * reason — none of them is silent.
33664
+ */
33665
+ readExportBytes: method(external_exports.object({ exportId: external_exports.string() }), ExportBytesSchema, {
33666
+ kind: "query",
33667
+ auth: "protected"
33333
33668
  })
33334
33669
  }
33335
33670
  };
@@ -38757,6 +39092,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
38757
39092
  addonId: null,
38758
39093
  access: "create"
38759
39094
  },
39095
+ "recording.cancelRelocateJob": {
39096
+ capName: "recording",
39097
+ capScope: "system",
39098
+ addonId: null,
39099
+ access: "create"
39100
+ },
38760
39101
  "recording.cancelStorageMigrationMove": {
38761
39102
  capName: "recording",
38762
39103
  capScope: "system",
@@ -38811,6 +39152,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
38811
39152
  addonId: null,
38812
39153
  access: "view"
38813
39154
  },
39155
+ "recording.listRelocateJobs": {
39156
+ capName: "recording",
39157
+ capScope: "system",
39158
+ addonId: null,
39159
+ access: "view"
39160
+ },
38814
39161
  "recording.locateSegment": {
38815
39162
  capName: "recording",
38816
39163
  capScope: "system",
@@ -38823,6 +39170,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
38823
39170
  addonId: null,
38824
39171
  access: "create"
38825
39172
  },
39173
+ "recording.planStorageRebalance": {
39174
+ capName: "recording",
39175
+ capScope: "system",
39176
+ addonId: null,
39177
+ access: "view"
39178
+ },
38826
39179
  "recording.pruneFootage": {
38827
39180
  capName: "recording",
38828
39181
  capScope: "system",
@@ -38847,6 +39200,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
38847
39200
  addonId: null,
38848
39201
  access: "create"
38849
39202
  },
39203
+ "recording.relocateFootage": {
39204
+ capName: "recording",
39205
+ capScope: "system",
39206
+ addonId: null,
39207
+ access: "create"
39208
+ },
38850
39209
  "recording.renderClip": {
38851
39210
  capName: "recording",
38852
39211
  capScope: "system",
@@ -38883,38 +39242,50 @@ var METHOD_ACCESS_MAP = Object.freeze({
38883
39242
  addonId: null,
38884
39243
  access: "create"
38885
39244
  },
39245
+ "recording.startStorageRebalance": {
39246
+ capName: "recording",
39247
+ capScope: "system",
39248
+ addonId: null,
39249
+ access: "create"
39250
+ },
38886
39251
  "recordingExport.cancelExport": {
38887
- capName: "recordingExport",
39252
+ capName: "recording-export",
38888
39253
  capScope: "system",
38889
39254
  addonId: null,
38890
39255
  access: "create"
38891
39256
  },
38892
39257
  "recordingExport.createExport": {
38893
- capName: "recordingExport",
39258
+ capName: "recording-export",
38894
39259
  capScope: "system",
38895
39260
  addonId: null,
38896
39261
  access: "create"
38897
39262
  },
38898
39263
  "recordingExport.deleteExport": {
38899
- capName: "recordingExport",
39264
+ capName: "recording-export",
38900
39265
  capScope: "system",
38901
39266
  addonId: null,
38902
39267
  access: "delete"
38903
39268
  },
38904
39269
  "recordingExport.getDownloadUrl": {
38905
- capName: "recordingExport",
39270
+ capName: "recording-export",
38906
39271
  capScope: "system",
38907
39272
  addonId: null,
38908
39273
  access: "view"
38909
39274
  },
38910
39275
  "recordingExport.getExport": {
38911
- capName: "recordingExport",
39276
+ capName: "recording-export",
38912
39277
  capScope: "system",
38913
39278
  addonId: null,
38914
39279
  access: "view"
38915
39280
  },
38916
39281
  "recordingExport.listExports": {
38917
- capName: "recordingExport",
39282
+ capName: "recording-export",
39283
+ capScope: "system",
39284
+ addonId: null,
39285
+ access: "view"
39286
+ },
39287
+ "recordingExport.readExportBytes": {
39288
+ capName: "recording-export",
38918
39289
  capScope: "system",
38919
39290
  addonId: null,
38920
39291
  access: "view"
@@ -40267,6 +40638,12 @@ var CadenceSecField = external_exports.number().int().min(2).max(3600);
40267
40638
  var FramerateField = external_exports.number().int().min(1).max(60);
40268
40639
  var TargetsField = external_exports.array(NcRuleTargetSchema).min(1);
40269
40640
  var PriorityField = external_exports.number().int().min(1).max(5);
40641
+ var TIMELAPSE_DENSE_FLOOR_SEC = 0.1;
40642
+ var DenseCadenceSecField = external_exports.number().min(TIMELAPSE_DENSE_FLOOR_SEC).max(3600);
40643
+ var MinDwellSecField = external_exports.number().min(0).max(60);
40644
+ var PreviewTextField = external_exports.string().max(200);
40645
+ var PreviewModeField = external_exports.enum(["image", "gif"]);
40646
+ var ReportClassesField = external_exports.array(external_exports.string().min(1).max(40)).max(20);
40270
40647
  var TimelapseRuleInputSchema = external_exports.object({
40271
40648
  name: NameField,
40272
40649
  enabled: external_exports.boolean().default(true),
@@ -40282,9 +40659,30 @@ var TimelapseRuleInputSchema = external_exports.object({
40282
40659
  cadenceSec: CadenceSecField.default(15),
40283
40660
  /** Output frames per second of the assembled mp4 (predecessor parity). */
40284
40661
  framerate: FramerateField.default(10),
40662
+ /**
40663
+ * Explicit dense cadence — see {@link DenseCadenceSecField}. Absent ⇒ derived
40664
+ * as `max(1s, cadenceSec / 30)`, which is what every rule written before this
40665
+ * field gets.
40666
+ */
40667
+ denseCadenceSec: DenseCadenceSecField.optional(),
40668
+ /** Output-seconds guarantee per detection range — see {@link MinDwellSecField}. */
40669
+ minDwellSec: MinDwellSecField.optional(),
40285
40670
  /** `notification-output` targets the finished video/thumbnail is sent to. */
40286
40671
  targets: TargetsField,
40287
40672
  template: TimelapseTemplateSchema.optional(),
40673
+ /**
40674
+ * Caption on the notification's PREVIEW FRAME — see {@link PreviewTextField}
40675
+ * and {@link DEFAULT_TIMELAPSE_PREVIEW_TEXT}.
40676
+ *
40677
+ * Deliberately NOT part of {@link TimelapseTemplateSchema}: that object is
40678
+ * the notification's title/body, and clearing it (`template: null`) must not
40679
+ * silently clear the caption too.
40680
+ */
40681
+ previewText: PreviewTextField.optional(),
40682
+ /** Still or animation — see {@link PreviewModeField}. */
40683
+ previewMode: PreviewModeField.default("image"),
40684
+ /** Classes the notification counts — see {@link ReportClassesField}. */
40685
+ reportClasses: ReportClassesField.optional(),
40288
40686
  /** Canonical notification priority ordinal (1..5); per-target overridable. */
40289
40687
  priority: PriorityField.default(3)
40290
40688
  });
@@ -40295,8 +40693,13 @@ var TimelapseRulePatchSchema = external_exports.object({
40295
40693
  schedule: NcScheduleSchema.optional(),
40296
40694
  cadenceSec: CadenceSecField.optional(),
40297
40695
  framerate: FramerateField.optional(),
40696
+ denseCadenceSec: DenseCadenceSecField.optional(),
40697
+ minDwellSec: MinDwellSecField.optional(),
40298
40698
  targets: TargetsField.optional(),
40299
40699
  template: TimelapseTemplateSchema.nullable().optional(),
40700
+ previewText: PreviewTextField.optional(),
40701
+ previewMode: PreviewModeField.optional(),
40702
+ reportClasses: ReportClassesField.optional(),
40300
40703
  priority: PriorityField.optional()
40301
40704
  });
40302
40705
  var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
@@ -40308,10 +40711,28 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
40308
40711
  */
40309
40712
  ownerUserId: external_exports.string().optional(),
40310
40713
  /**
40311
- * Epoch-ms of the last successful generation the 1-hour re-generation
40312
- * guard's durable state (predecessor parity). Absent = never generated.
40714
+ * Epoch-ms of the NEWEST successful generation across every camera of this
40715
+ * rule. What a UI shows, and the compatibility floor for
40716
+ * {@link readTimelapseGeneratedAt}. Absent = never generated.
40313
40717
  */
40314
40718
  lastGeneratedAt: external_exports.number().optional(),
40719
+ /**
40720
+ * PER-CAMERA generation state, keyed by `String(deviceId)` — the
40721
+ * re-generation guard's real durable state.
40722
+ *
40723
+ * One rule covers several cameras and each renders its own video, so a rule
40724
+ * -wide stamp is wrong in the direction that DESTROYS work: camera A
40725
+ * succeeding at 06:05 tells camera B, whose render failed, that it is
40726
+ * already done — and B's night is gone for good, because the window will not
40727
+ * come back.
40728
+ *
40729
+ * ADDITIVE, so the migration is free: a row written before this field simply
40730
+ * has no map, and {@link readTimelapseGeneratedAt} falls back to
40731
+ * {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
40732
+ * "never generated" would re-render and re-notify every camera of every rule
40733
+ * once, on the deploy that shipped the map.
40734
+ */
40735
+ generatedByDevice: external_exports.record(external_exports.string(), external_exports.number()).optional(),
40315
40736
  /** userId of the caller who created the rule (server-stamped). */
40316
40737
  createdBy: external_exports.string(),
40317
40738
  createdAt: external_exports.number(),
@@ -40350,25 +40771,32 @@ var DETAIL_CROP_PADDING_FIELD = {
40350
40771
  var NativeLeaseAdmissionSchema = external_exports.enum(["all", "inferred"]);
40351
40772
  var NativeLeaseSettingsSchema = external_exports.object({
40352
40773
  /**
40353
- * How long a retained native frame is served before it counts as a miss.
40774
+ * How many delivered frames the worker HOLDS at once, waiting for each one's
40775
+ * detection result.
40776
+ *
40777
+ * This replaced a TTL on 2026-08-13, and the replacement is the whole point:
40778
+ * a time window was never related to the event the pixels were waiting for.
40779
+ * A held frame now lives from delivery until the runner has its `FrameResult`
40780
+ * — at which moment the runner cuts the subject tiles it actually wanted and
40781
+ * releases the frame. The bound exists only so a runner that stops answering
40782
+ * cannot pin RAM: above it the OLDEST held frame is dropped and counted.
40354
40783
  *
40355
- * Must cover the FULL late-crop horizon: detection inference + the
40356
- * cross-process inference-result hop to hub post-analysis + tracking + the
40357
- * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
40358
- * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
40359
- * RAM per busy camera grows linearly with no measured hit-rate gain.
40784
+ * Sizing: the steady state is `inferenceLatency × deliveredFps`, measured at
40785
+ * 40-160 ms × ≤25 fps = 1-4 frames. The default leaves headroom for a hiccup
40786
+ * without ever approaching the old resident set (43 frames × 24.9 MB at 4K).
40787
+ * Raising it does not buy hit rate it buys tolerance for a slow runner, and
40788
+ * `holdOverflow` on the metrics line is what says you need it.
40360
40789
  */
40361
- ttlMs: external_exports.number().int().min(250).max(1e4),
40790
+ holdFrames: external_exports.number().int().min(1).max(64),
40362
40791
  /**
40363
40792
  * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
40364
40793
  *
40365
- * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
40366
- * which one is actually binding before reasoning from that. At the shipped
40367
- * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
40368
- * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
40369
- * `leaseFrames` on the metrics line say which. When the ceiling binds, a
40370
- * change that admits fewer frames buys retention WINDOW at constant RAM
40371
- * rather than giving RAM back — lower this knob if RAM is what you wanted.
40794
+ * Since 2026-08-13 this is a SAFETY ceiling and nothing else: `holdFrames`
40795
+ * is what decides how much is held, and the ceiling is the number above which
40796
+ * something is wrong. Before that it was the effective cap at 1024 MB with
40797
+ * a 2 800 ms TTL a 4K camera sat pinned at `leaseMb:1020, leaseFrames:43`
40798
+ * with the TTL expiring nothing, which is exactly the confusion the hold
40799
+ * removes. `leaseMb` / `leaseFrames` still say what is resident.
40372
40800
  * `0` DISABLES the lease entirely and falls the worker back to the tiny
40373
40801
  * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
40374
40802
  * to replace).
@@ -40394,19 +40822,37 @@ var NativeLeaseSettingsSchema = external_exports.object({
40394
40822
  * there is the signal that some caller names frames outside the inference set
40395
40823
  * and that this must go back to `all`.
40396
40824
  */
40397
- admission: NativeLeaseAdmissionSchema
40825
+ admission: NativeLeaseAdmissionSchema,
40826
+ /**
40827
+ * RAM ceiling per decode worker, in MB, for the SUBJECT TILES — the
40828
+ * compressed native crops the worker cuts at the moment a frame's detection
40829
+ * result arrives, and keeps long after the frame itself is freed.
40830
+ *
40831
+ * This is the knob that replaced the old retention window, and it buys about
40832
+ * three orders of magnitude more of it: a tile is one subject at native
40833
+ * resolution, JPEG-encoded (~60-120 KB on a 4K person), against ~24.9 MB for
40834
+ * the frame it was cut from. A frame on which nothing was detected costs
40835
+ * nothing at all, which is the real change — the old lease paid per FRAME and
40836
+ * was interrogated per SUBJECT.
40837
+ *
40838
+ * `0` DISABLES tiles, leaving only the hold window and the ≤640 RAM
40839
+ * fallback — i.e. the pre-2026-08-13 miss profile. Set it there only to
40840
+ * reproduce that.
40841
+ */
40842
+ tileBudgetMb: external_exports.number().int().min(0).max(1024)
40398
40843
  });
40399
40844
  var DEFAULT_NATIVE_LEASE_SETTINGS = {
40400
- ttlMs: 1200,
40845
+ holdFrames: 8,
40401
40846
  budgetMb: 1024,
40402
40847
  activityMs: 15e3,
40848
+ tileBudgetMb: 64,
40403
40849
  admission: "inferred"
40404
40850
  };
40405
- var NATIVE_LEASE_TTL_FIELD = {
40406
- min: 250,
40407
- max: 1e4,
40408
- step: 50,
40409
- default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
40851
+ var NATIVE_LEASE_HOLD_FIELD = {
40852
+ min: 1,
40853
+ max: 64,
40854
+ step: 1,
40855
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.holdFrames
40410
40856
  };
40411
40857
  var NATIVE_LEASE_BUDGET_FIELD = {
40412
40858
  min: 0,
@@ -40420,6 +40866,12 @@ var NATIVE_LEASE_ACTIVITY_FIELD = {
40420
40866
  step: 1e3,
40421
40867
  default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
40422
40868
  };
40869
+ var NATIVE_LEASE_TILE_BUDGET_FIELD = {
40870
+ min: 0,
40871
+ max: 1024,
40872
+ step: 16,
40873
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb
40874
+ };
40423
40875
  var NATIVE_LEASE_ADMISSION_FIELD = {
40424
40876
  options: [{
40425
40877
  value: "all",