@camstack/addon-model-studio 1.1.20 → 1.1.22

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.
@@ -13142,7 +13142,23 @@ var NotificationActionSchema = object({
13142
13142
  * else — see `notification-center/action-token.ts` for what that does and
13143
13143
  * does not buy.
13144
13144
  */
13145
- destructive: boolean().optional()
13145
+ destructive: boolean().optional(),
13146
+ /**
13147
+ * How the tap should REACH the url.
13148
+ *
13149
+ * `navigate` (absent, and every button authored before this field) opens it:
13150
+ * the phone leaves the notification and shows whatever the callback returns.
13151
+ * That is right for a button whose answer the operator wants to read.
13152
+ *
13153
+ * `background` fires it as a POST and stays put. It exists for the buttons
13154
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
13155
+ * an answer to the notification, and being thrown into a browser tab to
13156
+ * confirm it costs more attention than the notification did. A backend that
13157
+ * cannot do a background call renders it as an ordinary link (the adapters
13158
+ * fall back rather than dropping the button), so this is a preference, never
13159
+ * a requirement.
13160
+ */
13161
+ mode: _enum(["navigate", "background"]).optional()
13146
13162
  });
13147
13163
  /**
13148
13164
  * The canonical notification. `body` is the only hard field (Apprise model).
@@ -14143,6 +14159,9 @@ var NcSystemEventConditionSchema = object({
14143
14159
  nodeIds: array(string().min(1)).min(1).optional(),
14144
14160
  packageNames: array(string().min(1)).min(1).optional()
14145
14161
  });
14162
+ /** Hard ceiling on a window (24h). A snooze that could not expire would be an
14163
+ * outage the operator asked for once and forgot. */
14164
+ var NC_SNOOZE_MAX_MINUTES = 1440;
14146
14165
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14147
14166
  var NcScheduleSchema = object({
14148
14167
  windows: array(object({
@@ -14519,15 +14538,15 @@ var NcConditionsSchema = object({
14519
14538
  * (an `immediate` rule naming an `audio-*` class, one notification per
14520
14539
  * classified sample) stays exactly as it was for rules that already use it.
14521
14540
  *
14522
- * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
14523
- * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
14524
- * (`camstack/src/data/notification-center.ts`, guarded by
14525
- * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
14541
+ * In {@link NC_CONDITION_CATALOG} since P2, and the ORDER it got there is the
14542
+ * rule rather than an accident: the viewer mirrors the descriptor enums BY
14543
+ * HAND (`camstack/src/data/notification-center.ts`, guarded by
14544
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor strips the
14526
14545
  * condition fields it does not know when a rule is saved from the phone.
14527
14546
  * Publishing an editor for a condition the app cannot round-trip is how an
14528
- * operator loses a rule's conditions by opening it — so the descriptor, the
14529
- * admin widget and the viewer mirror land together (P2 + P3), and only then
14530
- * does an audio rule become authorable.
14547
+ * operator loses a rule's conditions by opening it — so the viewer mirror
14548
+ * (P3, shipped) went FIRST, and the descriptor an editor renders from
14549
+ * follows here.
14531
14550
  */
14532
14551
  audio: NcAudioConditionSchema.optional()
14533
14552
  });
@@ -14763,6 +14782,30 @@ var NcRuleInputSchema = object({
14763
14782
  */
14764
14783
  snoozeAllowGlobal: boolean().optional(),
14765
14784
  /**
14785
+ * The snooze durations THIS rule's notification offers as buttons, in
14786
+ * minutes.
14787
+ *
14788
+ * Three states, and all three are distinct — which is exactly why this is
14789
+ * `.optional()` and never `.default()`. A Zod default does not run on the
14790
+ * addon cap path (three production failures in one day), so a schema default
14791
+ * would collapse the first two:
14792
+ *
14793
+ * | value | meaning |
14794
+ * | --- | --- |
14795
+ * | absent | the operator never said ⇒ {@link NC_DEFAULT_SNOOZE_MINUTES} |
14796
+ * | `[]` | **no snooze buttons on this rule** — the explicit override |
14797
+ * | a list | these choices, de-duplicated and sorted, at most four |
14798
+ *
14799
+ * `.max(4)` because the notifier's own action budget is small (ntfy allows
14800
+ * three buttons in total) and a rule that spent it all on snooze choices
14801
+ * would push its own tap-through actions off the notification.
14802
+ *
14803
+ * An empty list is NOT an alarm exemption: a rule the alarm is about, or
14804
+ * that arms the panel, is exempt automatically and cannot be silenced by a
14805
+ * window from anywhere (D133).
14806
+ */
14807
+ snoozeOptions: array(number().int().min(1).max(NC_SNOOZE_MAX_MINUTES)).max(4).optional(),
14808
+ /**
14766
14809
  * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
14767
14810
  *
14768
14811
  * This is what makes the rule set the alarm's trigger set without the alarm
@@ -14862,6 +14905,7 @@ var NcConditionDescriptorSchema = object({
14862
14905
  "device",
14863
14906
  "package",
14864
14907
  "occupancy",
14908
+ "audio",
14865
14909
  "system"
14866
14910
  ]),
14867
14911
  label: string(),
@@ -14880,6 +14924,7 @@ var NcConditionDescriptorSchema = object({
14880
14924
  "crossingSelect",
14881
14925
  "polygonDraw",
14882
14926
  "occupancy",
14927
+ "audio",
14883
14928
  "deviceState",
14884
14929
  "systemEvent"
14885
14930
  ]),
@@ -15031,7 +15076,20 @@ var NcSnoozeInputSchema = object({
15031
15076
  ruleId: string().optional(),
15032
15077
  /** Required when `scope: 'device'`. */
15033
15078
  deviceId: number().int().optional(),
15034
- durationMinutes: number().int().min(1).max(1440),
15079
+ /**
15080
+ * Narrow the window to these subject classes — "the cat, not the person".
15081
+ *
15082
+ * ORTHOGONAL to `scope`, deliberately, and absent means EVERY class: that is
15083
+ * what every window authored before this field meant, so no persisted row
15084
+ * changes meaning and no client has to learn anything to keep working.
15085
+ *
15086
+ * It is what makes the window's real key `(deviceId, classes[])` and lets it
15087
+ * cross rules (D133): the operator points at a camera and a kind of thing,
15088
+ * not at whichever of their four rules happened to produce the notification
15089
+ * they are dismissing.
15090
+ */
15091
+ classes: array(string().min(1)).min(1).optional(),
15092
+ durationMinutes: number().int().min(1).max(NC_SNOOZE_MAX_MINUTES),
15035
15093
  /**
15036
15094
  * Silence this for EVERY recipient, not just the caller. Permission is
15037
15095
  * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
@@ -15056,6 +15114,10 @@ var NcSnoozeSchema = object({
15056
15114
  scope: NcSnoozeScopeSchema,
15057
15115
  ruleId: string().optional(),
15058
15116
  deviceId: number().int().optional(),
15117
+ /** Subject classes this window covers. ABSENT = every class — see
15118
+ * {@link NcSnoozeInputSchema.shape.classes}. Lives in the JSON blob and has
15119
+ * no SQLite column: nothing queries a window by class. */
15120
+ classes: array(string().min(1)).min(1).optional(),
15059
15121
  startedAt: number(),
15060
15122
  /** Exclusive: at exactly this instant the snooze is over. Expiry is a
15061
15123
  * COMPARISON, not a job — no sweeper can leave the operator silenced. */
@@ -23758,13 +23820,24 @@ method(object({
23758
23820
  /** Playback-speed multiplier for the render (1 = realtime). */
23759
23821
  var ExportSpeedSchema = number().min(.25).max(32);
23760
23822
  /**
23761
- * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
23823
+ * One dense interval, in WALL-CLOCK SECONDS FROM THE EXPORT'S OWN `fromMs`.
23824
+ *
23825
+ * **Wall clock, not ffmpeg's `t`** — and the recorder translates. A caller
23826
+ * derives these bounds from things that happened at a TIME (a track's
23827
+ * `firstSeen`), while `t` runs over the source playlist: the concatenation of
23828
+ * every segment present for the range, with each recording GAP removed. The
23829
+ * two agree only on a window that recorded without one interruption, and only
23830
+ * the render side knows the segments, so the translation lives there
23831
+ * (`export-dense-map.ts`, addon-pipeline).
23832
+ *
23833
+ * It was not always so. These seconds were fed to `between(t,…)` verbatim, and
23834
+ * on a 10 h window holding 29,393 s of footage every range landed late by the
23835
+ * gap accumulated before it — up to 6,607 s, well past EOF. Nothing matched,
23836
+ * the video was a uniform timelapse, and the log line reported the five ranges
23837
+ * that had been ASKED for (2026-08-13, export `57d14363`, camera 615).
23762
23838
  *
23763
- * Relative and not absolute epoch on purpose: the renderer's frame-select
23764
- * expression sees ffmpeg's `t`, which starts at 0 for the export's source
23765
- * playlist. Handing it absolute epochs would make every call site responsible
23766
- * for the same subtraction, and the one that forgot would emit a filter that
23767
- * selects nothing — silently, as a uniform timelapse.
23839
+ * Relative and not absolute epoch, because an absolute epoch would make every
23840
+ * call site responsible for the same subtraction.
23768
23841
  */
23769
23842
  var ExportDenseRangeSchema = object({
23770
23843
  fromSec: number().nonnegative(),
@@ -30617,6 +30690,7 @@ Object.freeze({
30617
30690
  "network-access": "ingress",
30618
30691
  "smtp-provider": "email"
30619
30692
  });
30693
+ new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
30620
30694
  new Set(["devices", "classes"]);
30621
30695
  /**
30622
30696
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
@@ -30894,25 +30968,32 @@ object({
30894
30968
  var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
30895
30969
  object({
30896
30970
  /**
30897
- * How long a retained native frame is served before it counts as a miss.
30971
+ * How many delivered frames the worker HOLDS at once, waiting for each one's
30972
+ * detection result.
30898
30973
  *
30899
- * Must cover the FULL late-crop horizon: detection inference + the
30900
- * cross-process inference-result hop to hub post-analysis + tracking + the
30901
- * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
30902
- * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
30903
- * RAM per busy camera grows linearly with no measured hit-rate gain.
30974
+ * This replaced a TTL on 2026-08-13, and the replacement is the whole point:
30975
+ * a time window was never related to the event the pixels were waiting for.
30976
+ * A held frame now lives from delivery until the runner has its `FrameResult`
30977
+ * at which moment the runner cuts the subject tiles it actually wanted and
30978
+ * releases the frame. The bound exists only so a runner that stops answering
30979
+ * cannot pin RAM: above it the OLDEST held frame is dropped and counted.
30980
+ *
30981
+ * Sizing: the steady state is `inferenceLatency × deliveredFps`, measured at
30982
+ * 40-160 ms × ≤25 fps = 1-4 frames. The default leaves headroom for a hiccup
30983
+ * without ever approaching the old resident set (43 frames × 24.9 MB at 4K).
30984
+ * Raising it does not buy hit rate — it buys tolerance for a slow runner, and
30985
+ * `holdOverflow` on the metrics line is what says you need it.
30904
30986
  */
30905
- ttlMs: number().int().min(250).max(1e4),
30987
+ holdFrames: number().int().min(1).max(64),
30906
30988
  /**
30907
30989
  * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
30908
30990
  *
30909
- * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
30910
- * which one is actually binding before reasoning from that. At the shipped
30911
- * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
30912
- * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
30913
- * `leaseFrames` on the metrics line say which. When the ceiling binds, a
30914
- * change that admits fewer frames buys retention WINDOW at constant RAM
30915
- * rather than giving RAM back — lower this knob if RAM is what you wanted.
30991
+ * Since 2026-08-13 this is a SAFETY ceiling and nothing else: `holdFrames`
30992
+ * is what decides how much is held, and the ceiling is the number above which
30993
+ * something is wrong. Before that it was the effective cap at 1024 MB with
30994
+ * a 2 800 ms TTL a 4K camera sat pinned at `leaseMb:1020, leaseFrames:43`
30995
+ * with the TTL expiring nothing, which is exactly the confusion the hold
30996
+ * removes. `leaseMb` / `leaseFrames` still say what is resident.
30916
30997
  * `0` DISABLES the lease entirely and falls the worker back to the tiny
30917
30998
  * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
30918
30999
  * to replace).
@@ -30938,22 +31019,45 @@ object({
30938
31019
  * there is the signal that some caller names frames outside the inference set
30939
31020
  * and that this must go back to `all`.
30940
31021
  */
30941
- admission: NativeLeaseAdmissionSchema
31022
+ admission: NativeLeaseAdmissionSchema,
31023
+ /**
31024
+ * RAM ceiling per decode worker, in MB, for the SUBJECT TILES — the
31025
+ * compressed native crops the worker cuts at the moment a frame's detection
31026
+ * result arrives, and keeps long after the frame itself is freed.
31027
+ *
31028
+ * This is the knob that replaced the old retention window, and it buys about
31029
+ * three orders of magnitude more of it: a tile is one subject at native
31030
+ * resolution, JPEG-encoded (~60-120 KB on a 4K person), against ~24.9 MB for
31031
+ * the frame it was cut from. A frame on which nothing was detected costs
31032
+ * nothing at all, which is the real change — the old lease paid per FRAME and
31033
+ * was interrogated per SUBJECT.
31034
+ *
31035
+ * `0` DISABLES tiles, leaving only the hold window and the ≤640 RAM
31036
+ * fallback — i.e. the pre-2026-08-13 miss profile. Set it there only to
31037
+ * reproduce that.
31038
+ */
31039
+ tileBudgetMb: number().int().min(0).max(1024)
30942
31040
  });
30943
31041
  /**
30944
- * The values in force when the operator has set nothing — byte-for-byte the
30945
- * constants the decode worker shipped with as env-var defaults, so making these
30946
- * settings changed no behaviour on the day it landed.
31042
+ * The values in force when the operator has set nothing.
31043
+ *
31044
+ * `budgetMb` stays at 1024 on the day the hold landed, deliberately: it stopped
31045
+ * being the retention window and became the OOM ceiling, and lowering a ceiling
31046
+ * in the same change that redefines it would make a regression and a retune
31047
+ * indistinguishable. Cut it once `tileHits` / `holdOverflow` have been read on
31048
+ * live traffic.
30947
31049
  */
30948
31050
  var DEFAULT_NATIVE_LEASE_SETTINGS = {
30949
- ttlMs: 1200,
31051
+ holdFrames: 8,
30950
31052
  budgetMb: 1024,
30951
31053
  activityMs: 15e3,
31054
+ tileBudgetMb: 64,
30952
31055
  admission: "inferred"
30953
31056
  };
30954
- DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
31057
+ DEFAULT_NATIVE_LEASE_SETTINGS.holdFrames;
30955
31058
  DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
30956
31059
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
31060
+ DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
30957
31061
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
30958
31062
  //#endregion
30959
31063
  //#region src/convert-orchestrator.ts
@@ -13119,7 +13119,23 @@ var NotificationActionSchema = object({
13119
13119
  * else — see `notification-center/action-token.ts` for what that does and
13120
13120
  * does not buy.
13121
13121
  */
13122
- destructive: boolean().optional()
13122
+ destructive: boolean().optional(),
13123
+ /**
13124
+ * How the tap should REACH the url.
13125
+ *
13126
+ * `navigate` (absent, and every button authored before this field) opens it:
13127
+ * the phone leaves the notification and shows whatever the callback returns.
13128
+ * That is right for a button whose answer the operator wants to read.
13129
+ *
13130
+ * `background` fires it as a POST and stays put. It exists for the buttons
13131
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
13132
+ * an answer to the notification, and being thrown into a browser tab to
13133
+ * confirm it costs more attention than the notification did. A backend that
13134
+ * cannot do a background call renders it as an ordinary link (the adapters
13135
+ * fall back rather than dropping the button), so this is a preference, never
13136
+ * a requirement.
13137
+ */
13138
+ mode: _enum(["navigate", "background"]).optional()
13123
13139
  });
13124
13140
  /**
13125
13141
  * The canonical notification. `body` is the only hard field (Apprise model).
@@ -14120,6 +14136,9 @@ var NcSystemEventConditionSchema = object({
14120
14136
  nodeIds: array(string().min(1)).min(1).optional(),
14121
14137
  packageNames: array(string().min(1)).min(1).optional()
14122
14138
  });
14139
+ /** Hard ceiling on a window (24h). A snooze that could not expire would be an
14140
+ * outage the operator asked for once and forgot. */
14141
+ var NC_SNOOZE_MAX_MINUTES = 1440;
14123
14142
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14124
14143
  var NcScheduleSchema = object({
14125
14144
  windows: array(object({
@@ -14496,15 +14515,15 @@ var NcConditionsSchema = object({
14496
14515
  * (an `immediate` rule naming an `audio-*` class, one notification per
14497
14516
  * classified sample) stays exactly as it was for rules that already use it.
14498
14517
  *
14499
- * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
14500
- * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
14501
- * (`camstack/src/data/notification-center.ts`, guarded by
14502
- * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
14518
+ * In {@link NC_CONDITION_CATALOG} since P2, and the ORDER it got there is the
14519
+ * rule rather than an accident: the viewer mirrors the descriptor enums BY
14520
+ * HAND (`camstack/src/data/notification-center.ts`, guarded by
14521
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor strips the
14503
14522
  * condition fields it does not know when a rule is saved from the phone.
14504
14523
  * Publishing an editor for a condition the app cannot round-trip is how an
14505
- * operator loses a rule's conditions by opening it — so the descriptor, the
14506
- * admin widget and the viewer mirror land together (P2 + P3), and only then
14507
- * does an audio rule become authorable.
14524
+ * operator loses a rule's conditions by opening it — so the viewer mirror
14525
+ * (P3, shipped) went FIRST, and the descriptor an editor renders from
14526
+ * follows here.
14508
14527
  */
14509
14528
  audio: NcAudioConditionSchema.optional()
14510
14529
  });
@@ -14740,6 +14759,30 @@ var NcRuleInputSchema = object({
14740
14759
  */
14741
14760
  snoozeAllowGlobal: boolean().optional(),
14742
14761
  /**
14762
+ * The snooze durations THIS rule's notification offers as buttons, in
14763
+ * minutes.
14764
+ *
14765
+ * Three states, and all three are distinct — which is exactly why this is
14766
+ * `.optional()` and never `.default()`. A Zod default does not run on the
14767
+ * addon cap path (three production failures in one day), so a schema default
14768
+ * would collapse the first two:
14769
+ *
14770
+ * | value | meaning |
14771
+ * | --- | --- |
14772
+ * | absent | the operator never said ⇒ {@link NC_DEFAULT_SNOOZE_MINUTES} |
14773
+ * | `[]` | **no snooze buttons on this rule** — the explicit override |
14774
+ * | a list | these choices, de-duplicated and sorted, at most four |
14775
+ *
14776
+ * `.max(4)` because the notifier's own action budget is small (ntfy allows
14777
+ * three buttons in total) and a rule that spent it all on snooze choices
14778
+ * would push its own tap-through actions off the notification.
14779
+ *
14780
+ * An empty list is NOT an alarm exemption: a rule the alarm is about, or
14781
+ * that arms the panel, is exempt automatically and cannot be silenced by a
14782
+ * window from anywhere (D133).
14783
+ */
14784
+ snoozeOptions: array(number().int().min(1).max(NC_SNOOZE_MAX_MINUTES)).max(4).optional(),
14785
+ /**
14743
14786
  * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
14744
14787
  *
14745
14788
  * This is what makes the rule set the alarm's trigger set without the alarm
@@ -14839,6 +14882,7 @@ var NcConditionDescriptorSchema = object({
14839
14882
  "device",
14840
14883
  "package",
14841
14884
  "occupancy",
14885
+ "audio",
14842
14886
  "system"
14843
14887
  ]),
14844
14888
  label: string(),
@@ -14857,6 +14901,7 @@ var NcConditionDescriptorSchema = object({
14857
14901
  "crossingSelect",
14858
14902
  "polygonDraw",
14859
14903
  "occupancy",
14904
+ "audio",
14860
14905
  "deviceState",
14861
14906
  "systemEvent"
14862
14907
  ]),
@@ -15008,7 +15053,20 @@ var NcSnoozeInputSchema = object({
15008
15053
  ruleId: string().optional(),
15009
15054
  /** Required when `scope: 'device'`. */
15010
15055
  deviceId: number().int().optional(),
15011
- durationMinutes: number().int().min(1).max(1440),
15056
+ /**
15057
+ * Narrow the window to these subject classes — "the cat, not the person".
15058
+ *
15059
+ * ORTHOGONAL to `scope`, deliberately, and absent means EVERY class: that is
15060
+ * what every window authored before this field meant, so no persisted row
15061
+ * changes meaning and no client has to learn anything to keep working.
15062
+ *
15063
+ * It is what makes the window's real key `(deviceId, classes[])` and lets it
15064
+ * cross rules (D133): the operator points at a camera and a kind of thing,
15065
+ * not at whichever of their four rules happened to produce the notification
15066
+ * they are dismissing.
15067
+ */
15068
+ classes: array(string().min(1)).min(1).optional(),
15069
+ durationMinutes: number().int().min(1).max(NC_SNOOZE_MAX_MINUTES),
15012
15070
  /**
15013
15071
  * Silence this for EVERY recipient, not just the caller. Permission is
15014
15072
  * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
@@ -15033,6 +15091,10 @@ var NcSnoozeSchema = object({
15033
15091
  scope: NcSnoozeScopeSchema,
15034
15092
  ruleId: string().optional(),
15035
15093
  deviceId: number().int().optional(),
15094
+ /** Subject classes this window covers. ABSENT = every class — see
15095
+ * {@link NcSnoozeInputSchema.shape.classes}. Lives in the JSON blob and has
15096
+ * no SQLite column: nothing queries a window by class. */
15097
+ classes: array(string().min(1)).min(1).optional(),
15036
15098
  startedAt: number(),
15037
15099
  /** Exclusive: at exactly this instant the snooze is over. Expiry is a
15038
15100
  * COMPARISON, not a job — no sweeper can leave the operator silenced. */
@@ -23735,13 +23797,24 @@ method(object({
23735
23797
  /** Playback-speed multiplier for the render (1 = realtime). */
23736
23798
  var ExportSpeedSchema = number().min(.25).max(32);
23737
23799
  /**
23738
- * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
23800
+ * One dense interval, in WALL-CLOCK SECONDS FROM THE EXPORT'S OWN `fromMs`.
23801
+ *
23802
+ * **Wall clock, not ffmpeg's `t`** — and the recorder translates. A caller
23803
+ * derives these bounds from things that happened at a TIME (a track's
23804
+ * `firstSeen`), while `t` runs over the source playlist: the concatenation of
23805
+ * every segment present for the range, with each recording GAP removed. The
23806
+ * two agree only on a window that recorded without one interruption, and only
23807
+ * the render side knows the segments, so the translation lives there
23808
+ * (`export-dense-map.ts`, addon-pipeline).
23809
+ *
23810
+ * It was not always so. These seconds were fed to `between(t,…)` verbatim, and
23811
+ * on a 10 h window holding 29,393 s of footage every range landed late by the
23812
+ * gap accumulated before it — up to 6,607 s, well past EOF. Nothing matched,
23813
+ * the video was a uniform timelapse, and the log line reported the five ranges
23814
+ * that had been ASKED for (2026-08-13, export `57d14363`, camera 615).
23739
23815
  *
23740
- * Relative and not absolute epoch on purpose: the renderer's frame-select
23741
- * expression sees ffmpeg's `t`, which starts at 0 for the export's source
23742
- * playlist. Handing it absolute epochs would make every call site responsible
23743
- * for the same subtraction, and the one that forgot would emit a filter that
23744
- * selects nothing — silently, as a uniform timelapse.
23816
+ * Relative and not absolute epoch, because an absolute epoch would make every
23817
+ * call site responsible for the same subtraction.
23745
23818
  */
23746
23819
  var ExportDenseRangeSchema = object({
23747
23820
  fromSec: number().nonnegative(),
@@ -30594,6 +30667,7 @@ Object.freeze({
30594
30667
  "network-access": "ingress",
30595
30668
  "smtp-provider": "email"
30596
30669
  });
30670
+ new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
30597
30671
  new Set(["devices", "classes"]);
30598
30672
  /**
30599
30673
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
@@ -30871,25 +30945,32 @@ object({
30871
30945
  var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
30872
30946
  object({
30873
30947
  /**
30874
- * How long a retained native frame is served before it counts as a miss.
30948
+ * How many delivered frames the worker HOLDS at once, waiting for each one's
30949
+ * detection result.
30875
30950
  *
30876
- * Must cover the FULL late-crop horizon: detection inference + the
30877
- * cross-process inference-result hop to hub post-analysis + tracking + the
30878
- * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
30879
- * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
30880
- * RAM per busy camera grows linearly with no measured hit-rate gain.
30951
+ * This replaced a TTL on 2026-08-13, and the replacement is the whole point:
30952
+ * a time window was never related to the event the pixels were waiting for.
30953
+ * A held frame now lives from delivery until the runner has its `FrameResult`
30954
+ * at which moment the runner cuts the subject tiles it actually wanted and
30955
+ * releases the frame. The bound exists only so a runner that stops answering
30956
+ * cannot pin RAM: above it the OLDEST held frame is dropped and counted.
30957
+ *
30958
+ * Sizing: the steady state is `inferenceLatency × deliveredFps`, measured at
30959
+ * 40-160 ms × ≤25 fps = 1-4 frames. The default leaves headroom for a hiccup
30960
+ * without ever approaching the old resident set (43 frames × 24.9 MB at 4K).
30961
+ * Raising it does not buy hit rate — it buys tolerance for a slow runner, and
30962
+ * `holdOverflow` on the metrics line is what says you need it.
30881
30963
  */
30882
- ttlMs: number().int().min(250).max(1e4),
30964
+ holdFrames: number().int().min(1).max(64),
30883
30965
  /**
30884
30966
  * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
30885
30967
  *
30886
- * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
30887
- * which one is actually binding before reasoning from that. At the shipped
30888
- * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
30889
- * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
30890
- * `leaseFrames` on the metrics line say which. When the ceiling binds, a
30891
- * change that admits fewer frames buys retention WINDOW at constant RAM
30892
- * rather than giving RAM back — lower this knob if RAM is what you wanted.
30968
+ * Since 2026-08-13 this is a SAFETY ceiling and nothing else: `holdFrames`
30969
+ * is what decides how much is held, and the ceiling is the number above which
30970
+ * something is wrong. Before that it was the effective cap at 1024 MB with
30971
+ * a 2 800 ms TTL a 4K camera sat pinned at `leaseMb:1020, leaseFrames:43`
30972
+ * with the TTL expiring nothing, which is exactly the confusion the hold
30973
+ * removes. `leaseMb` / `leaseFrames` still say what is resident.
30893
30974
  * `0` DISABLES the lease entirely and falls the worker back to the tiny
30894
30975
  * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
30895
30976
  * to replace).
@@ -30915,22 +30996,45 @@ object({
30915
30996
  * there is the signal that some caller names frames outside the inference set
30916
30997
  * and that this must go back to `all`.
30917
30998
  */
30918
- admission: NativeLeaseAdmissionSchema
30999
+ admission: NativeLeaseAdmissionSchema,
31000
+ /**
31001
+ * RAM ceiling per decode worker, in MB, for the SUBJECT TILES — the
31002
+ * compressed native crops the worker cuts at the moment a frame's detection
31003
+ * result arrives, and keeps long after the frame itself is freed.
31004
+ *
31005
+ * This is the knob that replaced the old retention window, and it buys about
31006
+ * three orders of magnitude more of it: a tile is one subject at native
31007
+ * resolution, JPEG-encoded (~60-120 KB on a 4K person), against ~24.9 MB for
31008
+ * the frame it was cut from. A frame on which nothing was detected costs
31009
+ * nothing at all, which is the real change — the old lease paid per FRAME and
31010
+ * was interrogated per SUBJECT.
31011
+ *
31012
+ * `0` DISABLES tiles, leaving only the hold window and the ≤640 RAM
31013
+ * fallback — i.e. the pre-2026-08-13 miss profile. Set it there only to
31014
+ * reproduce that.
31015
+ */
31016
+ tileBudgetMb: number().int().min(0).max(1024)
30919
31017
  });
30920
31018
  /**
30921
- * The values in force when the operator has set nothing — byte-for-byte the
30922
- * constants the decode worker shipped with as env-var defaults, so making these
30923
- * settings changed no behaviour on the day it landed.
31019
+ * The values in force when the operator has set nothing.
31020
+ *
31021
+ * `budgetMb` stays at 1024 on the day the hold landed, deliberately: it stopped
31022
+ * being the retention window and became the OOM ceiling, and lowering a ceiling
31023
+ * in the same change that redefines it would make a regression and a retune
31024
+ * indistinguishable. Cut it once `tileHits` / `holdOverflow` have been read on
31025
+ * live traffic.
30924
31026
  */
30925
31027
  var DEFAULT_NATIVE_LEASE_SETTINGS = {
30926
- ttlMs: 1200,
31028
+ holdFrames: 8,
30927
31029
  budgetMb: 1024,
30928
31030
  activityMs: 15e3,
31031
+ tileBudgetMb: 64,
30929
31032
  admission: "inferred"
30930
31033
  };
30931
- DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
31034
+ DEFAULT_NATIVE_LEASE_SETTINGS.holdFrames;
30932
31035
  DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
30933
31036
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
31037
+ DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
30934
31038
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
30935
31039
  //#endregion
30936
31040
  //#region src/convert-orchestrator.ts
@@ -1,6 +1,6 @@
1
1
  import { c as e, g as t, h as n, l as r, n as i, p as a, r as o, t as s, u as c, y as l } from "./_virtual_mf___mfe_internal__addon_model_studio_page__loadShare__react__loadShare__.js-DJDHChgO.mjs";
2
2
  import { n as u, r as d, t as f } from "./_virtual_mf___mfe_internal__addon_model_studio_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-ds_Ehzaa.mjs";
3
- import { m as p } from "./_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DpvRwqsx.mjs";
3
+ import { m as p } from "./_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CiO5eZYn.mjs";
4
4
  //#region ../ui-library/src/lib/cap-error.ts
5
5
  function m(e) {
6
6
  if (typeof e != "object" || !e) return null;
@@ -1,2 +1,2 @@
1
- import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-CNbwY0Zt.mjs";
1
+ import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-C0lWeSYH.mjs";
2
2
  export { t as get, e as init };
@@ -1,4 +1,4 @@
1
- import { s as e } from "./player-overlays-9g9Wl5lu.mjs";
1
+ import { s as e } from "./player-overlays-Djb228qI.mjs";
2
2
  var t = e("eye-off", [
3
3
  ["path", {
4
4
  d: "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",
@@ -16,14 +16,31 @@ var t = e("eye-off", [
16
16
  d: "m2 2 20 20",
17
17
  key: "1ooewy"
18
18
  }]
19
- ]), n = e("square", [["rect", {
19
+ ]), n = e("mic", [
20
+ ["path", {
21
+ d: "M12 19v3",
22
+ key: "npa21l"
23
+ }],
24
+ ["path", {
25
+ d: "M19 10v2a7 7 0 0 1-14 0v-2",
26
+ key: "1vc78b"
27
+ }],
28
+ ["rect", {
29
+ x: "9",
30
+ y: "2",
31
+ width: "6",
32
+ height: "13",
33
+ rx: "3",
34
+ key: "s6n7sd"
35
+ }]
36
+ ]), r = e("square", [["rect", {
20
37
  width: "18",
21
38
  height: "18",
22
39
  x: "3",
23
40
  y: "3",
24
41
  rx: "2",
25
42
  key: "afitv7"
26
- }]]), r = e("trash-2", [
43
+ }]]), i = e("trash-2", [
27
44
  ["path", {
28
45
  d: "M10 11v6",
29
46
  key: "nco0om"
@@ -46,4 +63,4 @@ var t = e("eye-off", [
46
63
  }]
47
64
  ]);
48
65
  //#endregion
49
- export { n, t as r, r as t };
66
+ export { t as i, r as n, n as r, i as t };
@@ -2753,7 +2753,7 @@ async function rr(e) {
2753
2753
  }
2754
2754
  }
2755
2755
  async function ir() {
2756
- return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-ChXR-SrT.mjs")).catch((e) => {
2756
+ return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-DLrVxdSd.mjs")).catch((e) => {
2757
2757
  throw tr = void 0, e;
2758
2758
  }), tr;
2759
2759
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-model-studio",
3
- "version": "1.1.20",
3
+ "version": "1.1.22",
4
4
  "description": "Custom detection model registry, conversion & distribution for CamStack",
5
5
  "keywords": [
6
6
  "camstack",