@camstack/addon-terminal 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +228 -36
  2. package/dist/addon.mjs +228 -36
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -7047,6 +7047,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7047
7047
  action: string().min(1),
7048
7048
  input: unknown()
7049
7049
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7050
+ //#endregion
7051
+ //#region ../types/dist/err-msg-IQTHeDzc.mjs
7052
+ /**
7053
+ import { errMsg } from '@camstack/types'
7054
+ * Extract a human-readable message from an unknown error value.
7055
+ * Replaces the ubiquitous `errMsg(err)` pattern.
7056
+ */
7057
+ function errMsg(err) {
7058
+ if (err instanceof Error) return err.message;
7059
+ if (typeof err === "string") return err;
7060
+ return String(err);
7061
+ }
7050
7062
  var EncodeProfileSchema = object({
7051
7063
  video: object({
7052
7064
  codec: _enum([
@@ -7284,8 +7296,31 @@ var AdoptionJobSchema = object({
7284
7296
  error: string().nullable()
7285
7297
  });
7286
7298
  /**
7287
- * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7288
- * pipeline functions an operator thinks in terms of.
7299
+ * Per-camera FUNCTION SWITCHES.
7300
+ *
7301
+ * ## The aggregate group is being withdrawn — the BADGE is not (D113)
7302
+ *
7303
+ * This file shipped as "the one coherent on/off surface over the pipeline
7304
+ * functions an operator thinks in terms of". The operator's verdict on
7305
+ * 2026-08-12 was that the coherent surface bought complexity and no clarity:
7306
+ * every function already had a settings page of its own, and a second place to
7307
+ * turn it off is a second place to look. Each switch is going back to its own
7308
+ * component's original options — detection to the detection-pipeline wrapper
7309
+ * binding, audio analysis to its own, recording to `RecordingConfig.enabled`
7310
+ * (which was always first-class; the switch was a veneer over
7311
+ * `recording.setDeviceConfig`), notifications to a notification-center
7312
+ * per-device setting, the two camera planes to their own components.
7313
+ *
7314
+ * What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
7315
+ * thing that lets a status surface say DISABLED instead of BROKEN, recomposed
7316
+ * straight from the authorities with no group in the middle. That rule was
7317
+ * never about a control panel.
7318
+ *
7319
+ * Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
7320
+ * {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
7321
+ * `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
7322
+ * viewers (v1.0.305) and the admin UI still call it. It is deleted when they
7323
+ * stop; nothing new may be built on it.
7289
7324
  *
7290
7325
  * ## This file adds no state
7291
7326
  *
@@ -7447,6 +7482,14 @@ var CameraSwitchGroupSchema = object({
7447
7482
  fetchedAt: number()
7448
7483
  });
7449
7484
  /**
7485
+ * The wrapper capability each wrapper-backed switch controls. Named constants
7486
+ * because the same strings appear in `legacy-migrations.ts`, in
7487
+ * `isCapActiveForDevice` call sites and in the fake harness — a typo in any of
7488
+ * them is a switch that silently writes a binding nobody reads.
7489
+ */
7490
+ var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
7491
+ var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
7492
+ /**
7450
7493
  * Ops-log — the durable, append-only operations audit shared by the
7451
7494
  * recordings and events management surfaces.
7452
7495
  *
@@ -17822,9 +17865,16 @@ var CameraStatusSchema = object({
17822
17865
  audio: CameraAudioStatusSchema.nullable(),
17823
17866
  recording: CameraRecordingStatusSchema.nullable(),
17824
17867
  /**
17825
- * Per-camera function switches an OPERATOR has turned off
17868
+ * Per-camera functions an OPERATOR has turned off
17826
17869
  * ([D61](../../../../docs/decisions/adr-0067.md)).
17827
17870
  *
17871
+ * Composed from the AUTHORITIES themselves — the wrapper bindings,
17872
+ * `RecordingConfig.enabled`, the notification mute, the broker's audio
17873
+ * policy, the camera's own microphone — via `composeSwitchedOff`, not from
17874
+ * the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
17875
+ * The badge outlives the control panel: the panel was a convenience, this is
17876
+ * the difference between a camera being off and a camera being dead.
17877
+ *
17828
17878
  * This is the difference between DISABLED and BROKEN. A camera whose
17829
17879
  * `detection` block reports zero fps and whose `switchedOff` contains
17830
17880
  * `'object-detection'` was switched off by a person; the same camera with an
@@ -24911,10 +24961,42 @@ method(object({
24911
24961
  */
24912
24962
  /** Playback-speed multiplier for the render (1 = realtime). */
24913
24963
  var ExportSpeedSchema = number().min(.25).max(32);
24964
+ /**
24965
+ * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
24966
+ *
24967
+ * Relative and not absolute epoch on purpose: the renderer's frame-select
24968
+ * expression sees ffmpeg's `t`, which starts at 0 for the export's source
24969
+ * playlist. Handing it absolute epochs would make every call site responsible
24970
+ * for the same subtraction, and the one that forgot would emit a filter that
24971
+ * selects nothing — silently, as a uniform timelapse.
24972
+ */
24973
+ var ExportDenseRangeSchema = object({
24974
+ fromSec: number().nonnegative(),
24975
+ toSec: number().nonnegative()
24976
+ }).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
24977
+ /**
24978
+ * Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
24979
+ * listed ranges and at the base `everyMs` everywhere else.
24980
+ *
24981
+ * `everyMs` must be strictly smaller than the base cadence — a dense rate that
24982
+ * is not denser renders a uniform timelapse the operator believes is two-rate.
24983
+ */
24984
+ var ExportDenseSchema = object({
24985
+ everyMs: number().int().positive(),
24986
+ ranges: array(ExportDenseRangeSchema).min(1).max(200)
24987
+ });
24914
24988
  /** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
24915
24989
  var ExportTimelapseSchema = object({
24916
24990
  everyMs: number().int().positive(),
24917
- outputFps: number().int().min(1).max(60).optional()
24991
+ outputFps: number().int().min(1).max(60).optional(),
24992
+ /** Optional second, FASTER rate over the intervals that matter. */
24993
+ dense: ExportDenseSchema.optional()
24994
+ }).superRefine((v, ctx) => {
24995
+ if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
24996
+ code: ZodIssueCode.custom,
24997
+ message: "dense.everyMs must be strictly smaller than the base everyMs",
24998
+ path: ["dense", "everyMs"]
24999
+ });
24918
25000
  });
24919
25001
  /**
24920
25002
  * Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
@@ -24972,6 +25054,19 @@ var ExportDownloadSchema = object({
24972
25054
  url: string(),
24973
25055
  endpoints: array(string())
24974
25056
  });
25057
+ /**
25058
+ * A finished export's bytes, inline.
25059
+ *
25060
+ * `bytes` is the DECODED length — the number the caller bounds and logs
25061
+ * against, so nobody has to infer it from the base64 length.
25062
+ */
25063
+ var ExportBytesSchema = object({
25064
+ base64: string(),
25065
+ contentType: string(),
25066
+ /** Suggested filename, extension included. */
25067
+ name: string(),
25068
+ bytes: number().int().nonnegative()
25069
+ });
24975
25070
  method(object({
24976
25071
  deviceId: number(),
24977
25072
  profile: string(),
@@ -24996,6 +25091,9 @@ method(object({
24996
25091
  }), method(object({ exportId: string() }), ExportDownloadSchema, {
24997
25092
  kind: "query",
24998
25093
  auth: "protected"
25094
+ }), method(object({ exportId: string() }), ExportBytesSchema, {
25095
+ kind: "query",
25096
+ auth: "protected"
24999
25097
  });
25000
25098
  /**
25001
25099
  * scene-monitor — device-scoped reference-region state cap. An operator marks
@@ -31847,6 +31945,12 @@ Object.freeze({
31847
31945
  addonId: null,
31848
31946
  access: "view"
31849
31947
  },
31948
+ "recordingExport.readExportBytes": {
31949
+ capName: "recordingExport",
31950
+ capScope: "system",
31951
+ addonId: null,
31952
+ access: "view"
31953
+ },
31850
31954
  "sceneMonitor.captureReference": {
31851
31955
  capName: "scene-monitor",
31852
31956
  capScope: "device",
@@ -33264,10 +33368,28 @@ TimelapseRuleInputSchema.extend({
33264
33368
  */
33265
33369
  ownerUserId: string().optional(),
33266
33370
  /**
33267
- * Epoch-ms of the last successful generation the 1-hour re-generation
33268
- * guard's durable state (predecessor parity). Absent = never generated.
33371
+ * Epoch-ms of the NEWEST successful generation across every camera of this
33372
+ * rule. What a UI shows, and the compatibility floor for
33373
+ * {@link readTimelapseGeneratedAt}. Absent = never generated.
33269
33374
  */
33270
33375
  lastGeneratedAt: number().optional(),
33376
+ /**
33377
+ * PER-CAMERA generation state, keyed by `String(deviceId)` — the
33378
+ * re-generation guard's real durable state.
33379
+ *
33380
+ * One rule covers several cameras and each renders its own video, so a rule
33381
+ * -wide stamp is wrong in the direction that DESTROYS work: camera A
33382
+ * succeeding at 06:05 tells camera B, whose render failed, that it is
33383
+ * already done — and B's night is gone for good, because the window will not
33384
+ * come back.
33385
+ *
33386
+ * ADDITIVE, so the migration is free: a row written before this field simply
33387
+ * has no map, and {@link readTimelapseGeneratedAt} falls back to
33388
+ * {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
33389
+ * "never generated" would re-render and re-notify every camera of every rule
33390
+ * once, on the deploy that shipped the map.
33391
+ */
33392
+ generatedByDevice: record(string(), number()).optional(),
33271
33393
  /** userId of the caller who created the rule (server-stamped). */
33272
33394
  createdBy: string(),
33273
33395
  createdAt: number(),
@@ -33516,6 +33638,91 @@ async function warmNodePty() {
33516
33638
  await loadNodePty();
33517
33639
  }
33518
33640
  //#endregion
33641
+ //#region src/silence-analysis.ts
33642
+ /**
33643
+ * Every analyzer a Terminal camera is created with switched OFF.
33644
+ *
33645
+ * `motion-detection` sits first deliberately: it is the one that holds the
33646
+ * decode session open, so it is the one whose absence is the difference between
33647
+ * a terminal costing a screen scrape and costing a decode pipeline.
33648
+ */
33649
+ var TERMINAL_SILENCED_CAP_NAMES = [
33650
+ "motion-detection",
33651
+ DETECTION_PIPELINE_CAP_NAME,
33652
+ AUDIO_ANALYSIS_CAP_NAME
33653
+ ];
33654
+ /**
33655
+ * Resolve the addon currently providing `capName` for this device.
33656
+ *
33657
+ * Inlined rather than reached for through a helper because there is exactly one
33658
+ * honest source: the device's own bindings. `listBindableCapsForDeviceType`
33659
+ * answers for the device TYPE and would happily name a wrapper that is not the
33660
+ * one bound here.
33661
+ *
33662
+ * Returns `null` when the cap is not bound at all — which is not an error: a
33663
+ * deployment with no audio analyzer has nothing to switch off, and demanding one
33664
+ * would make every terminal creation fail on a perfectly valid hub.
33665
+ */
33666
+ async function resolveBoundWrapper(api, deviceId, capName) {
33667
+ const bindings = await api.deviceManager.getBindings.query({ deviceId });
33668
+ for (const entry of bindings.entries) {
33669
+ if (entry.capName !== capName) continue;
33670
+ if (entry.kind !== "wrapped") continue;
33671
+ if (entry.providerAddonId === "") return null;
33672
+ return entry.providerAddonId;
33673
+ }
33674
+ return null;
33675
+ }
33676
+ /**
33677
+ * Switch every analyzer in {@link TERMINAL_SILENCED_CAP_NAMES} off for one
33678
+ * Terminal camera.
33679
+ *
33680
+ * Called ONLY on creation. An operator who deliberately turns detection back on
33681
+ * for a terminal must win, and a reconcile that re-asserted every pass would
33682
+ * silently overrule them once a minute.
33683
+ *
33684
+ * @throws if a cap IS bound and the write to its authority failed. The camera
33685
+ * would then be running a full analyzer for a screen recording, which is the
33686
+ * exact cost this exists to remove — and a silent version of that failure is
33687
+ * unfindable.
33688
+ */
33689
+ async function silenceAnalysisFor(deps, deviceId) {
33690
+ const failures = [];
33691
+ for (const capName of TERMINAL_SILENCED_CAP_NAMES) try {
33692
+ const wrapperAddonId = await resolveBoundWrapper(deps.api, deviceId, capName);
33693
+ if (wrapperAddonId === null) {
33694
+ deps.logger.debug("terminal camera: no analyzer bound for this capability", {
33695
+ tags: { deviceId },
33696
+ meta: { capName }
33697
+ });
33698
+ continue;
33699
+ }
33700
+ await deps.api.deviceManager.setWrapperActive.mutate({
33701
+ deviceId,
33702
+ capName,
33703
+ wrapperAddonId,
33704
+ active: false
33705
+ });
33706
+ deps.logger.info("terminal camera: analyzer switched off at its authority", {
33707
+ tags: { deviceId },
33708
+ meta: {
33709
+ capName,
33710
+ wrapperAddonId
33711
+ }
33712
+ });
33713
+ } catch (err) {
33714
+ failures.push(`${capName}: ${errMsg(err)}`);
33715
+ deps.logger.error("terminal camera: could NOT switch an analyzer off", {
33716
+ tags: { deviceId },
33717
+ meta: {
33718
+ capName,
33719
+ error: errMsg(err)
33720
+ }
33721
+ });
33722
+ }
33723
+ if (failures.length > 0) throw new Error(`terminal camera ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
33724
+ }
33725
+ //#endregion
33519
33726
  //#region src/terminal-camera-declarations.ts
33520
33727
  /**
33521
33728
  * Feed DeclaredDevices every live declaration plus one deterministic orphan
@@ -40049,37 +40256,22 @@ var TerminalAddon = class extends BaseAddon {
40049
40256
  }
40050
40257
  }
40051
40258
  /**
40052
- * A Terminal camera is a rendered screen. Object detection on it finds
40053
- * nothing, forever, at full cost.
40054
- *
40055
- * Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
40056
- * through the detection pipeline at ~61 ms of inference each, plus 115
40057
- * capture-scheduler requests a minute — against `detections=0`. Multiply by
40058
- * one terminal per node and it is a standing tax on a hub that was already
40059
- * shedding 86 % of its capture queue.
40259
+ * A Terminal camera is a rendered screen. Every analyzer on it finds nothing,
40260
+ * forever, at full cost — see `silence-analysis.ts` for the measurements, the
40261
+ * reason `motion-detection` is in the list, and why a failure THROWS instead
40262
+ * of warning.
40060
40263
  *
40061
- * Written through `setCameraSwitch`, which is the authority that already owns
40062
- * this function [D62] forbids a second store that disagrees with it. And
40063
- * written ONLY on creation: an operator who deliberately turns detection back
40064
- * on for a terminal must win, and a reconcile that re-asserted every pass
40065
- * would silently overrule them once a minute.
40066
- */
40067
- async silenceAnalysisFor(deviceId) {
40068
- for (const switchId of ["object-detection", "audio-analysis"]) try {
40069
- await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
40070
- deviceId,
40071
- switchId,
40072
- enabled: false
40073
- });
40074
- } catch (err) {
40075
- this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
40076
- tags: { deviceId },
40077
- meta: {
40078
- switchId,
40079
- error: err instanceof Error ? err.message : String(err)
40080
- }
40081
- });
40082
- }
40264
+ * Written to the AUTHORITIES (`deviceManager.setWrapperActive`), not through
40265
+ * the deprecated switch cap the previous version used (D113). Written ONLY on
40266
+ * creation: an operator who deliberately turns detection back on for a
40267
+ * terminal must win, and a reconcile that re-asserted every pass would
40268
+ * silently overrule them once a minute.
40269
+ */
40270
+ silenceAnalysisFor(deviceId) {
40271
+ return silenceAnalysisFor({
40272
+ api: this.ctx.api,
40273
+ logger: this.ctx.logger
40274
+ }, deviceId);
40083
40275
  }
40084
40276
  terminalInstanceControl() {
40085
40277
  return {
package/dist/addon.mjs CHANGED
@@ -7024,6 +7024,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7024
7024
  action: string().min(1),
7025
7025
  input: unknown()
7026
7026
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7027
+ //#endregion
7028
+ //#region ../types/dist/err-msg-IQTHeDzc.mjs
7029
+ /**
7030
+ import { errMsg } from '@camstack/types'
7031
+ * Extract a human-readable message from an unknown error value.
7032
+ * Replaces the ubiquitous `errMsg(err)` pattern.
7033
+ */
7034
+ function errMsg(err) {
7035
+ if (err instanceof Error) return err.message;
7036
+ if (typeof err === "string") return err;
7037
+ return String(err);
7038
+ }
7027
7039
  var EncodeProfileSchema = object({
7028
7040
  video: object({
7029
7041
  codec: _enum([
@@ -7261,8 +7273,31 @@ var AdoptionJobSchema = object({
7261
7273
  error: string().nullable()
7262
7274
  });
7263
7275
  /**
7264
- * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7265
- * pipeline functions an operator thinks in terms of.
7276
+ * Per-camera FUNCTION SWITCHES.
7277
+ *
7278
+ * ## The aggregate group is being withdrawn — the BADGE is not (D113)
7279
+ *
7280
+ * This file shipped as "the one coherent on/off surface over the pipeline
7281
+ * functions an operator thinks in terms of". The operator's verdict on
7282
+ * 2026-08-12 was that the coherent surface bought complexity and no clarity:
7283
+ * every function already had a settings page of its own, and a second place to
7284
+ * turn it off is a second place to look. Each switch is going back to its own
7285
+ * component's original options — detection to the detection-pipeline wrapper
7286
+ * binding, audio analysis to its own, recording to `RecordingConfig.enabled`
7287
+ * (which was always first-class; the switch was a veneer over
7288
+ * `recording.setDeviceConfig`), notifications to a notification-center
7289
+ * per-device setting, the two camera planes to their own components.
7290
+ *
7291
+ * What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
7292
+ * thing that lets a status surface say DISABLED instead of BROKEN, recomposed
7293
+ * straight from the authorities with no group in the middle. That rule was
7294
+ * never about a control panel.
7295
+ *
7296
+ * Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
7297
+ * {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
7298
+ * `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
7299
+ * viewers (v1.0.305) and the admin UI still call it. It is deleted when they
7300
+ * stop; nothing new may be built on it.
7266
7301
  *
7267
7302
  * ## This file adds no state
7268
7303
  *
@@ -7424,6 +7459,14 @@ var CameraSwitchGroupSchema = object({
7424
7459
  fetchedAt: number()
7425
7460
  });
7426
7461
  /**
7462
+ * The wrapper capability each wrapper-backed switch controls. Named constants
7463
+ * because the same strings appear in `legacy-migrations.ts`, in
7464
+ * `isCapActiveForDevice` call sites and in the fake harness — a typo in any of
7465
+ * them is a switch that silently writes a binding nobody reads.
7466
+ */
7467
+ var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
7468
+ var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
7469
+ /**
7427
7470
  * Ops-log — the durable, append-only operations audit shared by the
7428
7471
  * recordings and events management surfaces.
7429
7472
  *
@@ -17799,9 +17842,16 @@ var CameraStatusSchema = object({
17799
17842
  audio: CameraAudioStatusSchema.nullable(),
17800
17843
  recording: CameraRecordingStatusSchema.nullable(),
17801
17844
  /**
17802
- * Per-camera function switches an OPERATOR has turned off
17845
+ * Per-camera functions an OPERATOR has turned off
17803
17846
  * ([D61](../../../../docs/decisions/adr-0067.md)).
17804
17847
  *
17848
+ * Composed from the AUTHORITIES themselves — the wrapper bindings,
17849
+ * `RecordingConfig.enabled`, the notification mute, the broker's audio
17850
+ * policy, the camera's own microphone — via `composeSwitchedOff`, not from
17851
+ * the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
17852
+ * The badge outlives the control panel: the panel was a convenience, this is
17853
+ * the difference between a camera being off and a camera being dead.
17854
+ *
17805
17855
  * This is the difference between DISABLED and BROKEN. A camera whose
17806
17856
  * `detection` block reports zero fps and whose `switchedOff` contains
17807
17857
  * `'object-detection'` was switched off by a person; the same camera with an
@@ -24888,10 +24938,42 @@ method(object({
24888
24938
  */
24889
24939
  /** Playback-speed multiplier for the render (1 = realtime). */
24890
24940
  var ExportSpeedSchema = number().min(.25).max(32);
24941
+ /**
24942
+ * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
24943
+ *
24944
+ * Relative and not absolute epoch on purpose: the renderer's frame-select
24945
+ * expression sees ffmpeg's `t`, which starts at 0 for the export's source
24946
+ * playlist. Handing it absolute epochs would make every call site responsible
24947
+ * for the same subtraction, and the one that forgot would emit a filter that
24948
+ * selects nothing — silently, as a uniform timelapse.
24949
+ */
24950
+ var ExportDenseRangeSchema = object({
24951
+ fromSec: number().nonnegative(),
24952
+ toSec: number().nonnegative()
24953
+ }).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
24954
+ /**
24955
+ * Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
24956
+ * listed ranges and at the base `everyMs` everywhere else.
24957
+ *
24958
+ * `everyMs` must be strictly smaller than the base cadence — a dense rate that
24959
+ * is not denser renders a uniform timelapse the operator believes is two-rate.
24960
+ */
24961
+ var ExportDenseSchema = object({
24962
+ everyMs: number().int().positive(),
24963
+ ranges: array(ExportDenseRangeSchema).min(1).max(200)
24964
+ });
24891
24965
  /** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
24892
24966
  var ExportTimelapseSchema = object({
24893
24967
  everyMs: number().int().positive(),
24894
- outputFps: number().int().min(1).max(60).optional()
24968
+ outputFps: number().int().min(1).max(60).optional(),
24969
+ /** Optional second, FASTER rate over the intervals that matter. */
24970
+ dense: ExportDenseSchema.optional()
24971
+ }).superRefine((v, ctx) => {
24972
+ if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
24973
+ code: ZodIssueCode.custom,
24974
+ message: "dense.everyMs must be strictly smaller than the base everyMs",
24975
+ path: ["dense", "everyMs"]
24976
+ });
24895
24977
  });
24896
24978
  /**
24897
24979
  * Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
@@ -24949,6 +25031,19 @@ var ExportDownloadSchema = object({
24949
25031
  url: string(),
24950
25032
  endpoints: array(string())
24951
25033
  });
25034
+ /**
25035
+ * A finished export's bytes, inline.
25036
+ *
25037
+ * `bytes` is the DECODED length — the number the caller bounds and logs
25038
+ * against, so nobody has to infer it from the base64 length.
25039
+ */
25040
+ var ExportBytesSchema = object({
25041
+ base64: string(),
25042
+ contentType: string(),
25043
+ /** Suggested filename, extension included. */
25044
+ name: string(),
25045
+ bytes: number().int().nonnegative()
25046
+ });
24952
25047
  method(object({
24953
25048
  deviceId: number(),
24954
25049
  profile: string(),
@@ -24973,6 +25068,9 @@ method(object({
24973
25068
  }), method(object({ exportId: string() }), ExportDownloadSchema, {
24974
25069
  kind: "query",
24975
25070
  auth: "protected"
25071
+ }), method(object({ exportId: string() }), ExportBytesSchema, {
25072
+ kind: "query",
25073
+ auth: "protected"
24976
25074
  });
24977
25075
  /**
24978
25076
  * scene-monitor — device-scoped reference-region state cap. An operator marks
@@ -31824,6 +31922,12 @@ Object.freeze({
31824
31922
  addonId: null,
31825
31923
  access: "view"
31826
31924
  },
31925
+ "recordingExport.readExportBytes": {
31926
+ capName: "recordingExport",
31927
+ capScope: "system",
31928
+ addonId: null,
31929
+ access: "view"
31930
+ },
31827
31931
  "sceneMonitor.captureReference": {
31828
31932
  capName: "scene-monitor",
31829
31933
  capScope: "device",
@@ -33241,10 +33345,28 @@ TimelapseRuleInputSchema.extend({
33241
33345
  */
33242
33346
  ownerUserId: string().optional(),
33243
33347
  /**
33244
- * Epoch-ms of the last successful generation the 1-hour re-generation
33245
- * guard's durable state (predecessor parity). Absent = never generated.
33348
+ * Epoch-ms of the NEWEST successful generation across every camera of this
33349
+ * rule. What a UI shows, and the compatibility floor for
33350
+ * {@link readTimelapseGeneratedAt}. Absent = never generated.
33246
33351
  */
33247
33352
  lastGeneratedAt: number().optional(),
33353
+ /**
33354
+ * PER-CAMERA generation state, keyed by `String(deviceId)` — the
33355
+ * re-generation guard's real durable state.
33356
+ *
33357
+ * One rule covers several cameras and each renders its own video, so a rule
33358
+ * -wide stamp is wrong in the direction that DESTROYS work: camera A
33359
+ * succeeding at 06:05 tells camera B, whose render failed, that it is
33360
+ * already done — and B's night is gone for good, because the window will not
33361
+ * come back.
33362
+ *
33363
+ * ADDITIVE, so the migration is free: a row written before this field simply
33364
+ * has no map, and {@link readTimelapseGeneratedAt} falls back to
33365
+ * {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
33366
+ * "never generated" would re-render and re-notify every camera of every rule
33367
+ * once, on the deploy that shipped the map.
33368
+ */
33369
+ generatedByDevice: record(string(), number()).optional(),
33248
33370
  /** userId of the caller who created the rule (server-stamped). */
33249
33371
  createdBy: string(),
33250
33372
  createdAt: number(),
@@ -33493,6 +33615,91 @@ async function warmNodePty() {
33493
33615
  await loadNodePty();
33494
33616
  }
33495
33617
  //#endregion
33618
+ //#region src/silence-analysis.ts
33619
+ /**
33620
+ * Every analyzer a Terminal camera is created with switched OFF.
33621
+ *
33622
+ * `motion-detection` sits first deliberately: it is the one that holds the
33623
+ * decode session open, so it is the one whose absence is the difference between
33624
+ * a terminal costing a screen scrape and costing a decode pipeline.
33625
+ */
33626
+ var TERMINAL_SILENCED_CAP_NAMES = [
33627
+ "motion-detection",
33628
+ DETECTION_PIPELINE_CAP_NAME,
33629
+ AUDIO_ANALYSIS_CAP_NAME
33630
+ ];
33631
+ /**
33632
+ * Resolve the addon currently providing `capName` for this device.
33633
+ *
33634
+ * Inlined rather than reached for through a helper because there is exactly one
33635
+ * honest source: the device's own bindings. `listBindableCapsForDeviceType`
33636
+ * answers for the device TYPE and would happily name a wrapper that is not the
33637
+ * one bound here.
33638
+ *
33639
+ * Returns `null` when the cap is not bound at all — which is not an error: a
33640
+ * deployment with no audio analyzer has nothing to switch off, and demanding one
33641
+ * would make every terminal creation fail on a perfectly valid hub.
33642
+ */
33643
+ async function resolveBoundWrapper(api, deviceId, capName) {
33644
+ const bindings = await api.deviceManager.getBindings.query({ deviceId });
33645
+ for (const entry of bindings.entries) {
33646
+ if (entry.capName !== capName) continue;
33647
+ if (entry.kind !== "wrapped") continue;
33648
+ if (entry.providerAddonId === "") return null;
33649
+ return entry.providerAddonId;
33650
+ }
33651
+ return null;
33652
+ }
33653
+ /**
33654
+ * Switch every analyzer in {@link TERMINAL_SILENCED_CAP_NAMES} off for one
33655
+ * Terminal camera.
33656
+ *
33657
+ * Called ONLY on creation. An operator who deliberately turns detection back on
33658
+ * for a terminal must win, and a reconcile that re-asserted every pass would
33659
+ * silently overrule them once a minute.
33660
+ *
33661
+ * @throws if a cap IS bound and the write to its authority failed. The camera
33662
+ * would then be running a full analyzer for a screen recording, which is the
33663
+ * exact cost this exists to remove — and a silent version of that failure is
33664
+ * unfindable.
33665
+ */
33666
+ async function silenceAnalysisFor(deps, deviceId) {
33667
+ const failures = [];
33668
+ for (const capName of TERMINAL_SILENCED_CAP_NAMES) try {
33669
+ const wrapperAddonId = await resolveBoundWrapper(deps.api, deviceId, capName);
33670
+ if (wrapperAddonId === null) {
33671
+ deps.logger.debug("terminal camera: no analyzer bound for this capability", {
33672
+ tags: { deviceId },
33673
+ meta: { capName }
33674
+ });
33675
+ continue;
33676
+ }
33677
+ await deps.api.deviceManager.setWrapperActive.mutate({
33678
+ deviceId,
33679
+ capName,
33680
+ wrapperAddonId,
33681
+ active: false
33682
+ });
33683
+ deps.logger.info("terminal camera: analyzer switched off at its authority", {
33684
+ tags: { deviceId },
33685
+ meta: {
33686
+ capName,
33687
+ wrapperAddonId
33688
+ }
33689
+ });
33690
+ } catch (err) {
33691
+ failures.push(`${capName}: ${errMsg(err)}`);
33692
+ deps.logger.error("terminal camera: could NOT switch an analyzer off", {
33693
+ tags: { deviceId },
33694
+ meta: {
33695
+ capName,
33696
+ error: errMsg(err)
33697
+ }
33698
+ });
33699
+ }
33700
+ if (failures.length > 0) throw new Error(`terminal camera ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
33701
+ }
33702
+ //#endregion
33496
33703
  //#region src/terminal-camera-declarations.ts
33497
33704
  /**
33498
33705
  * Feed DeclaredDevices every live declaration plus one deterministic orphan
@@ -40026,37 +40233,22 @@ var TerminalAddon = class extends BaseAddon {
40026
40233
  }
40027
40234
  }
40028
40235
  /**
40029
- * A Terminal camera is a rendered screen. Object detection on it finds
40030
- * nothing, forever, at full cost.
40031
- *
40032
- * Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
40033
- * through the detection pipeline at ~61 ms of inference each, plus 115
40034
- * capture-scheduler requests a minute — against `detections=0`. Multiply by
40035
- * one terminal per node and it is a standing tax on a hub that was already
40036
- * shedding 86 % of its capture queue.
40236
+ * A Terminal camera is a rendered screen. Every analyzer on it finds nothing,
40237
+ * forever, at full cost — see `silence-analysis.ts` for the measurements, the
40238
+ * reason `motion-detection` is in the list, and why a failure THROWS instead
40239
+ * of warning.
40037
40240
  *
40038
- * Written through `setCameraSwitch`, which is the authority that already owns
40039
- * this function [D62] forbids a second store that disagrees with it. And
40040
- * written ONLY on creation: an operator who deliberately turns detection back
40041
- * on for a terminal must win, and a reconcile that re-asserted every pass
40042
- * would silently overrule them once a minute.
40043
- */
40044
- async silenceAnalysisFor(deviceId) {
40045
- for (const switchId of ["object-detection", "audio-analysis"]) try {
40046
- await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
40047
- deviceId,
40048
- switchId,
40049
- enabled: false
40050
- });
40051
- } catch (err) {
40052
- this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
40053
- tags: { deviceId },
40054
- meta: {
40055
- switchId,
40056
- error: err instanceof Error ? err.message : String(err)
40057
- }
40058
- });
40059
- }
40241
+ * Written to the AUTHORITIES (`deviceManager.setWrapperActive`), not through
40242
+ * the deprecated switch cap the previous version used (D113). Written ONLY on
40243
+ * creation: an operator who deliberately turns detection back on for a
40244
+ * terminal must win, and a reconcile that re-asserted every pass would
40245
+ * silently overrule them once a minute.
40246
+ */
40247
+ silenceAnalysisFor(deviceId) {
40248
+ return silenceAnalysisFor({
40249
+ api: this.ctx.api,
40250
+ logger: this.ctx.logger
40251
+ }, deviceId);
40060
40252
  }
40061
40253
  terminalInstanceControl() {
40062
40254
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-terminal",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Interactive terminal sessions (pty + xterm) as a CamStack addon",
5
5
  "keywords": [
6
6
  "camstack",