@camstack/addon-model-studio 1.1.31 → 1.1.33

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 (20) hide show
  1. package/dist/{MotionZonesSettings-BydNTXed.mjs → MotionZonesSettings-W4fQr3y3.mjs} +2 -2
  2. package/dist/{PrivacyMaskSettings-Dq03Q0ea.mjs → PrivacyMaskSettings-CtKzQjYD.mjs} +4 -4
  3. package/dist/{SceneMonitorEditor-8PcMSAlF.mjs → SceneMonitorEditor-CM3z_N4Q.mjs} +3 -3
  4. package/dist/_stub.js +4223 -3115
  5. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-BxmPMYYE.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-B0we2PiB.mjs} +4 -4
  6. package/dist/_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Boi8PUUw.mjs +26 -0
  7. package/dist/addon-model-studio.css +1 -1
  8. package/dist/{hostInit-CnNmgbGN.mjs → hostInit-B-Vb5-CN.mjs} +3 -3
  9. package/dist/model-studio.addon.js +1005 -74
  10. package/dist/model-studio.addon.mjs +1006 -75
  11. package/dist/{player-overlays-DkYP-ilS.mjs → player-overlays-CNkUzVZI.mjs} +1 -1
  12. package/dist/remoteEntry.js +1 -1
  13. package/dist/{responsive-BPPcVvwi.mjs → responsive-D-JIMJTX.mjs} +1 -1
  14. package/dist/{square-CpMmCZRC.mjs → square-CBwtzgNH.mjs} +1 -1
  15. package/dist/{trash-2-BY1LnuAV.mjs → trash-2-CiJaurwE.mjs} +1 -1
  16. package/dist/{use-device-snapshot-DTkJH7xh.mjs → use-device-snapshot-DpcJhtJX.mjs} +1 -1
  17. package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-B03fcjt1.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-vDbogekh.mjs} +1 -1
  18. package/package.json +1 -1
  19. package/python/convert_lib.py +28 -5
  20. package/dist/_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BMel40Gn.mjs +0 -26
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { spawn } from "node:child_process";
3
- import crypto$1 from "node:crypto";
3
+ import crypto$1, { createHash, randomUUID } from "node:crypto";
4
4
  import * as fs$1 from "node:fs";
5
5
  import fs from "node:fs";
6
6
  import os from "node:os";
@@ -7677,7 +7677,7 @@ var RecordingBandSchema = object({
7677
7677
  postBufferSec: number().min(0).optional()
7678
7678
  });
7679
7679
  ({
7680
- preBufferSec: 10,
7680
+ preBufferSec: 15,
7681
7681
  postBufferSec: 30
7682
7682
  }).postBufferSec * 1e3;
7683
7683
  /**
@@ -13657,128 +13657,103 @@ var COCO_TO_MACRO = {
13657
13657
  var AUDIO_MACRO_LABELS = [
13658
13658
  {
13659
13659
  id: "speech",
13660
- name: "Speech",
13661
- icon: "🗣️"
13660
+ name: "Speech"
13662
13661
  },
13663
13662
  {
13664
13663
  id: "scream",
13665
- name: "Scream / Shout",
13666
- icon: "😱"
13664
+ name: "Scream / Shout"
13667
13665
  },
13668
13666
  {
13669
13667
  id: "crying",
13670
- name: "Crying / Baby",
13671
- icon: "😢"
13668
+ name: "Crying / Baby"
13672
13669
  },
13673
13670
  {
13674
13671
  id: "laughter",
13675
- name: "Laughter",
13676
- icon: "😂"
13672
+ name: "Laughter"
13677
13673
  },
13678
13674
  {
13679
13675
  id: "music",
13680
- name: "Music",
13681
- icon: "🎵"
13676
+ name: "Music"
13682
13677
  },
13683
13678
  {
13684
13679
  id: "dog",
13685
- name: "Dog",
13686
- icon: "🐕"
13680
+ name: "Dog"
13687
13681
  },
13688
13682
  {
13689
13683
  id: "cat",
13690
- name: "Cat",
13691
- icon: "🐈"
13684
+ name: "Cat"
13692
13685
  },
13693
13686
  {
13694
13687
  id: "bird",
13695
- name: "Bird",
13696
- icon: "🐦"
13688
+ name: "Bird"
13697
13689
  },
13698
13690
  {
13699
13691
  id: "animal",
13700
- name: "Animal (other)",
13701
- icon: "🐾"
13692
+ name: "Animal (other)"
13702
13693
  },
13703
13694
  {
13704
13695
  id: "alarm",
13705
- name: "Alarm / Siren",
13706
- icon: "🚨"
13696
+ name: "Alarm / Siren"
13707
13697
  },
13708
13698
  {
13709
13699
  id: "doorbell",
13710
- name: "Doorbell / Knock",
13711
- icon: "🔔"
13700
+ name: "Doorbell / Knock"
13712
13701
  },
13713
13702
  {
13714
13703
  id: "glass_breaking",
13715
- name: "Glass Breaking",
13716
- icon: "💥"
13704
+ name: "Glass Breaking"
13717
13705
  },
13718
13706
  {
13719
13707
  id: "gunshot",
13720
- name: "Gunshot / Explosion",
13721
- icon: "💣"
13708
+ name: "Gunshot / Explosion"
13722
13709
  },
13723
13710
  {
13724
13711
  id: "vehicle",
13725
- name: "Vehicle",
13726
- icon: "🚗"
13712
+ name: "Vehicle"
13727
13713
  },
13728
13714
  {
13729
13715
  id: "siren",
13730
- name: "Emergency Siren",
13731
- icon: "🚑"
13716
+ name: "Emergency Siren"
13732
13717
  },
13733
13718
  {
13734
13719
  id: "fire",
13735
- name: "Fire / Smoke",
13736
- icon: "🔥"
13720
+ name: "Fire / Smoke"
13737
13721
  },
13738
13722
  {
13739
13723
  id: "water",
13740
- name: "Water",
13741
- icon: "💧"
13724
+ name: "Water"
13742
13725
  },
13743
13726
  {
13744
13727
  id: "wind",
13745
- name: "Wind / Weather",
13746
- icon: "🌬️"
13728
+ name: "Wind / Weather"
13747
13729
  },
13748
13730
  {
13749
13731
  id: "door",
13750
- name: "Door",
13751
- icon: "🚪"
13732
+ name: "Door"
13752
13733
  },
13753
13734
  {
13754
13735
  id: "footsteps",
13755
- name: "Footsteps",
13756
- icon: "👣"
13736
+ name: "Footsteps"
13757
13737
  },
13758
13738
  {
13759
13739
  id: "crowd",
13760
- name: "Crowd / Chatter",
13761
- icon: "👥"
13740
+ name: "Crowd / Chatter"
13762
13741
  },
13763
13742
  {
13764
13743
  id: "telephone",
13765
- name: "Telephone",
13766
- icon: "📞"
13744
+ name: "Telephone"
13767
13745
  },
13768
13746
  {
13769
13747
  id: "engine",
13770
- name: "Engine / Motor",
13771
- icon: "⚙️"
13748
+ name: "Engine / Motor"
13772
13749
  },
13773
13750
  {
13774
13751
  id: "tools",
13775
- name: "Tools / Construction",
13776
- icon: "🔨"
13752
+ name: "Tools / Construction"
13777
13753
  },
13778
13754
  {
13779
13755
  id: "silence",
13780
- name: "Silence",
13781
- icon: "🤫"
13756
+ name: "Silence"
13782
13757
  }
13783
13758
  ];
13784
13759
  var YAMNET_TO_MACRO = {
@@ -16242,7 +16217,7 @@ var TrackEnvelopeSchema = object({
16242
16217
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16243
16218
  * keeps every scalar the list surfaces actually render (ids, class(es),
16244
16219
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16245
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
16220
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
16246
16221
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16247
16222
  * `getTrack`. Mirrors the event-store `projection` convention
16248
16223
  * (`getObjectEvents` et al.).
@@ -16537,6 +16512,35 @@ var TrackSchema = object({
16537
16512
  */
16538
16513
  hasFace: boolean().optional(),
16539
16514
  /**
16515
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
16516
+ * face an operator could ASSIGN to an identity.
16517
+ *
16518
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
16519
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
16520
+ * branch, before every gate, and means no more than "a face detector produced
16521
+ * a face detail". This one is stamped at the single moment the gallery row
16522
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
16523
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
16524
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
16525
+ * the crop-store drop. Everything between the detector and that insert can
16526
+ * legitimately refuse the face, so a flag written any earlier promises the
16527
+ * operator something to assign and delivers nothing.
16528
+ *
16529
+ * **Independent of recognition.** A face collected but never auto-matched is
16530
+ * still assignable — it is in fact the face an operator most wants to reach —
16531
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
16532
+ * `subLabel`; this says only that the raw material exists.
16533
+ *
16534
+ * **Set once, never cleared.** A track that produced a gallery row produced
16535
+ * one; deleting the row later is the gallery's business, not this flag's.
16536
+ *
16537
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
16538
+ * before the column omits it, and so does every server that predates the
16539
+ * field. A consumer must test `=== true` and render nothing otherwise —
16540
+ * never infer "no assignable face".
16541
+ */
16542
+ hasEmbeddedFace: boolean().optional(),
16543
+ /**
16540
16544
  * This subject CONTAINS a folded rider — a person the rider-pairing step
16541
16545
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
16542
16546
  * so the passage is tracked once and as a VEHICLE.
@@ -17886,6 +17890,32 @@ var DetailResultSchema = object({
17886
17890
  /** FRAME-space bbox (already mapped back from crop space). */
17887
17891
  bbox: NativeCropBboxSchema.optional(),
17888
17892
  embedding: string().optional(),
17893
+ /**
17894
+ * L2 magnitude of the RAW pre-normalization embedding. Additive and optional
17895
+ * so an older runner that does not send it degrades to "unmeasurable", which
17896
+ * the consuming gate treats as accept — never as reject.
17897
+ */
17898
+ embeddingMagnitude: number().optional(),
17899
+ /**
17900
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
17901
+ * `label`, and for the same reason: the producing node is the only side that
17902
+ * knows which model actually ran, and re-deriving it downstream is exactly
17903
+ * the inference this model exists to forbid.
17904
+ *
17905
+ * Additive and optional, so a runner that predates it degrades to
17906
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
17907
+ * its previous assumption instead of refusing.
17908
+ *
17909
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
17910
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
17911
+ * with nothing on the wire it took the id from the GALLERY, so the gate
17912
+ * compared the gallery against itself and could never fire. That turned the
17913
+ * one guard protecting the face feature space into a tautology, and it is
17914
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
17915
+ * gallery as the catalog comment predicts — it would have scored the new
17916
+ * model's vectors against the old model's samples in one cosine space.
17917
+ */
17918
+ embeddingModelId: string().optional(),
17889
17919
  label: string().optional(),
17890
17920
  /**
17891
17921
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -18761,6 +18791,12 @@ var CameraStatusSchema = object({
18761
18791
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
18762
18792
  fetchedAt: number()
18763
18793
  });
18794
+ var InferenceDeviceExclusionReasonSchema = _enum([
18795
+ "disabled",
18796
+ "unavailable",
18797
+ "cannot-host-camera-root",
18798
+ "accelerator-preferred"
18799
+ ]);
18764
18800
  var NodeInferenceDeviceSchema = object({
18765
18801
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
18766
18802
  key: string(),
@@ -18791,7 +18827,17 @@ var NodeInferenceDeviceSchema = object({
18791
18827
  * available per format; this is the stored selection that becomes the
18792
18828
  * default for EVERY camera landing on this accelerator.
18793
18829
  */
18794
- steps: record(string(), DeviceStepConfigSchema).optional()
18830
+ steps: record(string(), DeviceStepConfigSchema).optional(),
18831
+ /**
18832
+ * `null` when the device IS a camera-root candidate on this node; otherwise
18833
+ * the reason the dispatcher drops it. Computed by the SAME
18834
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
18835
+ * never disagree with the election — deriving it in the UI from
18836
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
18837
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
18838
+ * "an accelerator is serving" predicate).
18839
+ */
18840
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
18795
18841
  });
18796
18842
  var NodeInferenceDevicesSchema = object({
18797
18843
  nodeId: string(),
@@ -19296,6 +19342,7 @@ DeviceType.Camera, method(object({
19296
19342
  isBattery: boolean(),
19297
19343
  reason: _enum([
19298
19344
  "disabled",
19345
+ "offline",
19299
19346
  "sleeping",
19300
19347
  "unreachable",
19301
19348
  "waking"
@@ -19341,9 +19388,17 @@ targets: array(object({
19341
19388
  sleeping: boolean(),
19342
19389
  /** Current device state rendered over the cached frame. State images
19343
19390
  * remain authoritative even when their photographic background is
19344
- * old; null means the link must carry a current camera frame. */
19391
+ * old; null means the link must carry a current camera frame.
19392
+ *
19393
+ * **This is the ONLY source a surface may derive an inactive tile
19394
+ * from.** A client that reads a device's `online`/`disabled` flags and
19395
+ * draws its own verdict will disagree with the pixels it is painting
19396
+ * — the frame comes from here, and so must the sentence over it
19397
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
19398
+ * state. */
19345
19399
  stateReason: _enum([
19346
19400
  "disabled",
19401
+ "offline",
19347
19402
  "sleeping",
19348
19403
  "unreachable",
19349
19404
  "waking"
@@ -22540,7 +22595,12 @@ var ListResultSchema = object({
22540
22595
  probedAt: number()
22541
22596
  });
22542
22597
  var PreferredSchema = LocalInterfaceSchema.nullable();
22543
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
22598
+ /**
22599
+ * Candidate base URL for the SDK to race on connect. Order matters —
22600
+ * the SDK should attempt these top-to-bottom with a short per-candidate
22601
+ * timeout (e.g. 1500ms) and cache the winner for the session.
22602
+ */
22603
+ var ConnectionEndpointSchema = object({
22544
22604
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
22545
22605
  label: string(),
22546
22606
  /** Fully-formed base URL with scheme + host + port. */
@@ -22583,7 +22643,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
22583
22643
  * ordering between polls.
22584
22644
  */
22585
22645
  priority: number()
22586
- })).readonly() });
22646
+ });
22647
+ /**
22648
+ * Where the advertised local port came from. Ordered most → least
22649
+ * authoritative, and the whole point of returning it: a client must be able to
22650
+ * tell a FACT about the hub's socket from an echo of its own guess.
22651
+ */
22652
+ var LocalPortSourceEnum = _enum([
22653
+ "server-config",
22654
+ "server-env",
22655
+ "caller-hint",
22656
+ "default"
22657
+ ]);
22658
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
22659
+ var AdvertisedLocalPortSchema = object({
22660
+ port: number().int().min(1).max(65535),
22661
+ source: LocalPortSourceEnum
22662
+ });
22663
+ var GetConnectionEndpointsResultSchema = object({
22664
+ endpoints: array(ConnectionEndpointSchema).readonly(),
22665
+ /**
22666
+ * The port the hub built the LAN/loopback URLs with, and where that number
22667
+ * came from.
22668
+ *
22669
+ * Returned rather than merely applied, because "the URL is right" and "the
22670
+ * client can KNOW the URL is right" are different properties. A client that
22671
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
22672
+ * a hub that echoed the port the client sent, so it cannot decide whether to
22673
+ * race the candidate or discard it. With `source` it can: anything but
22674
+ * `caller-hint` is the hub's own socket.
22675
+ *
22676
+ * Absent on hubs predating this field — a client that finds it missing is
22677
+ * talking to an echoing hub and must degrade exactly as it does for
22678
+ * `caller-hint`.
22679
+ */
22680
+ localPort: AdvertisedLocalPortSchema
22681
+ });
22587
22682
  /**
22588
22683
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
22589
22684
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -22604,8 +22699,13 @@ var AllowedAddressesSchema = object({
22604
22699
  */
22605
22700
  addresses: array(string()).readonly() });
22606
22701
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
22607
- /** Local hub HTTP port to use in base URLs. */
22608
- port: number().int().min(1).max(65535),
22702
+ /**
22703
+ * LEGACY HINT — do not send from new code. Kept optional so clients
22704
+ * written against the echoing contract keep working; the hub uses it
22705
+ * only when it cannot read its own port, and says so via
22706
+ * `localPort.source === 'caller-hint'`.
22707
+ */
22708
+ port: number().int().min(1).max(65535).optional(),
22609
22709
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
22610
22710
  * candidate. Default `true`. */
22611
22711
  includeLoopback: boolean().optional(),
@@ -33529,7 +33629,6 @@ var NC_AUDIO_DEFAULTS = {
33529
33629
  hitPercent: 60,
33530
33630
  samplingSeconds: 10
33531
33631
  };
33532
- new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
33533
33632
  new Set(["devices", "classes"]);
33534
33633
  NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
33535
33634
  /**
@@ -33763,6 +33862,39 @@ TimelapseRuleInputSchema.extend({
33763
33862
  createdAt: number(),
33764
33863
  updatedAt: number()
33765
33864
  });
33865
+ Object.freeze(Object.fromEntries([{
33866
+ stepId: "face-embedding",
33867
+ label: "Face recognition model",
33868
+ defaultModelId: "arcface-r100",
33869
+ indexName: "the enrolled face gallery",
33870
+ options: [
33871
+ {
33872
+ id: "arcface-r100",
33873
+ label: "ArcFace (ResNet34)"
33874
+ },
33875
+ {
33876
+ id: "auraface-r100",
33877
+ label: "AuraFace R100"
33878
+ },
33879
+ {
33880
+ id: "inception-resnet-v1",
33881
+ label: "Inception ResNet V1"
33882
+ }
33883
+ ]
33884
+ }, {
33885
+ stepId: "clip-embedding",
33886
+ label: "Semantic search model",
33887
+ defaultModelId: "mobileclip-s1",
33888
+ indexName: "the object semantic-search index",
33889
+ options: [{
33890
+ id: "mobileclip-s1",
33891
+ label: "MobileCLIP S1"
33892
+ }, {
33893
+ id: "mobileclip-s2",
33894
+ label: "MobileCLIP S2"
33895
+ }]
33896
+ }].map((s) => [s.stepId, s.defaultModelId])));
33897
+ string().min(1);
33766
33898
  object({
33767
33899
  /**
33768
33900
  * Fraction of the box's own size added on EACH side before cutting.
@@ -33783,10 +33915,21 @@ object({
33783
33915
  */
33784
33916
  square: boolean()
33785
33917
  });
33786
- ({
33918
+ /**
33919
+ * The convention in force when nobody has configured one — byte-for-byte the
33920
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
33921
+ */
33922
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
33787
33923
  paddingRatio: .15,
33788
33924
  square: false
33789
- }).paddingRatio;
33925
+ };
33926
+ Object.freeze({
33927
+ x: 0,
33928
+ y: 0,
33929
+ w: 1,
33930
+ h: 1
33931
+ });
33932
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
33790
33933
  /**
33791
33934
  * WHICH delivered frames the decode worker retains a native copy of.
33792
33935
  *
@@ -34062,6 +34205,462 @@ function removeCustomModel(list, modelId) {
34062
34205
  removed: next.length !== list.length
34063
34206
  };
34064
34207
  }
34208
+ var FRIGATE_PUBLIC_CATALOG = [{
34209
+ id: "frigate-mobiledet-edgetpu",
34210
+ label: "Frigate default — SSDLite MobileDet (EdgeTPU)",
34211
+ format: "tflite",
34212
+ url: "https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess_edgetpu.tflite",
34213
+ sizeMB: 5.4,
34214
+ inputSize: 320,
34215
+ postprocessor: "ssd",
34216
+ labels: [
34217
+ "person",
34218
+ "bicycle",
34219
+ "car",
34220
+ "motorcycle",
34221
+ "airplane",
34222
+ "bus",
34223
+ "train",
34224
+ "car",
34225
+ "boat",
34226
+ "traffic light",
34227
+ "fire hydrant",
34228
+ "street sign",
34229
+ "stop sign",
34230
+ "parking meter",
34231
+ "bench",
34232
+ "bird",
34233
+ "cat",
34234
+ "dog",
34235
+ "horse",
34236
+ "sheep",
34237
+ "cow",
34238
+ "elephant",
34239
+ "bear",
34240
+ "zebra",
34241
+ "giraffe",
34242
+ "hat",
34243
+ "backpack",
34244
+ "umbrella",
34245
+ "shoe",
34246
+ "eye glasses",
34247
+ "handbag",
34248
+ "tie",
34249
+ "suitcase",
34250
+ "frisbee",
34251
+ "skis",
34252
+ "snowboard",
34253
+ "sports ball",
34254
+ "kite",
34255
+ "baseball bat",
34256
+ "baseball glove",
34257
+ "skateboard",
34258
+ "surfboard",
34259
+ "tennis racket",
34260
+ "bottle",
34261
+ "plate",
34262
+ "wine glass",
34263
+ "cup",
34264
+ "fork",
34265
+ "knife",
34266
+ "spoon",
34267
+ "bowl",
34268
+ "banana",
34269
+ "apple",
34270
+ "sandwich",
34271
+ "orange",
34272
+ "broccoli",
34273
+ "carrot",
34274
+ "hot dog",
34275
+ "pizza",
34276
+ "donut",
34277
+ "cake",
34278
+ "chair",
34279
+ "couch",
34280
+ "potted plant",
34281
+ "bed",
34282
+ "mirror",
34283
+ "dining table",
34284
+ "window",
34285
+ "desk",
34286
+ "toilet",
34287
+ "door",
34288
+ "tv",
34289
+ "laptop",
34290
+ "mouse",
34291
+ "remote",
34292
+ "keyboard",
34293
+ "cell phone",
34294
+ "microwave",
34295
+ "oven",
34296
+ "toaster",
34297
+ "sink",
34298
+ "refrigerator",
34299
+ "blender",
34300
+ "book",
34301
+ "clock",
34302
+ "vase",
34303
+ "scissors",
34304
+ "teddy bear",
34305
+ "hair drier",
34306
+ "toothbrush",
34307
+ "hair brush"
34308
+ ],
34309
+ notes: "Frigate's default detection model (the one a stock Frigate + Coral install runs). EdgeTPU-compiled full-integer TFLite, 320×320, TFLite_Detection_PostProcess output → ssd decode. Requires a Coral (tflite format) node."
34310
+ }];
34311
+ /**
34312
+ * Build the registrable descriptor for a curated catalog entry. The URL is
34313
+ * the PUBLIC one — any node downloads it on demand; no Distribute needed.
34314
+ */
34315
+ function buildFrigateCatalogDescriptor(catalogId, overrides) {
34316
+ const cat = FRIGATE_PUBLIC_CATALOG.find((e) => e.id === catalogId);
34317
+ if (!cat) throw new Error(`Unknown Frigate catalog id "${catalogId}" — available: ${FRIGATE_PUBLIC_CATALOG.map((e) => e.id).join(", ")}`);
34318
+ return {
34319
+ stepId: "object-detection",
34320
+ entry: {
34321
+ id: overrides?.modelId?.trim() || cat.id,
34322
+ name: overrides?.name?.trim() || cat.label,
34323
+ description: `${cat.notes} Imported from the Frigate public catalog via Model Studio.`,
34324
+ formats: { [cat.format]: {
34325
+ url: cat.url,
34326
+ sizeMB: cat.sizeMB,
34327
+ runtimes: ["python"]
34328
+ } },
34329
+ inputSize: {
34330
+ width: cat.inputSize,
34331
+ height: cat.inputSize
34332
+ },
34333
+ labels: cat.labels.map((name) => ({
34334
+ id: name,
34335
+ name
34336
+ })),
34337
+ preprocessMode: "letterbox",
34338
+ ...cat.postprocessor !== void 0 ? { postprocessor: cat.postprocessor } : {}
34339
+ }
34340
+ };
34341
+ }
34342
+ //#endregion
34343
+ //#region src/frigate/plus-conversion.ts
34344
+ /** Converted formats ADD to the imported entry; identity fields (decode,
34345
+ * labels, description, preprocess) stay the import's. */
34346
+ function mergeConvertedFormats(base, converted) {
34347
+ return {
34348
+ ...base,
34349
+ formats: {
34350
+ ...base.formats,
34351
+ ...converted.formats
34352
+ }
34353
+ };
34354
+ }
34355
+ function targetOf(format) {
34356
+ return format === "openvino" ? {
34357
+ format,
34358
+ precisions: ["fp16"]
34359
+ } : { format };
34360
+ }
34361
+ /**
34362
+ * Convert the imported ONNX for each requested engine, one target at a time,
34363
+ * merging every success into the registered entry. Returns one outcome per
34364
+ * requested format — the caller surfaces them verbatim.
34365
+ */
34366
+ async function runPlusConversions(deps, args) {
34367
+ const outcomes = [];
34368
+ let current = args.entry;
34369
+ for (const format of args.targetFormats) {
34370
+ const targets = [targetOf(format)];
34371
+ try {
34372
+ const ranked = await deps.recommend({ targets });
34373
+ const best = ranked.find((r) => r.supported);
34374
+ if (!best) {
34375
+ const reason = ranked[0]?.reason ?? "no node can satisfy this target";
34376
+ outcomes.push({
34377
+ format,
34378
+ ok: false,
34379
+ error: reason
34380
+ });
34381
+ continue;
34382
+ }
34383
+ const sourceUrl = await deps.freshSourceUrl();
34384
+ const result = await deps.convert({
34385
+ sourceUrl,
34386
+ metadata: {
34387
+ id: current.id,
34388
+ name: current.name,
34389
+ labels: current.labels,
34390
+ inputSize: current.inputSize,
34391
+ preprocessMode: current.preprocessMode ?? "letterbox",
34392
+ outputFormat: "yolo"
34393
+ },
34394
+ targets
34395
+ }, best.nodeId);
34396
+ if (result.artifacts.length === 0) {
34397
+ outcomes.push({
34398
+ format,
34399
+ ok: false,
34400
+ nodeId: best.nodeId,
34401
+ error: "no validated artifacts"
34402
+ });
34403
+ continue;
34404
+ }
34405
+ current = mergeConvertedFormats(current, result.entry);
34406
+ await deps.upsertEntry(current);
34407
+ await deps.markAvailable(best.nodeId, current.id, result.artifacts);
34408
+ outcomes.push({
34409
+ format,
34410
+ ok: true,
34411
+ nodeId: best.nodeId
34412
+ });
34413
+ } catch (err) {
34414
+ outcomes.push({
34415
+ format,
34416
+ ok: false,
34417
+ error: err instanceof Error ? err.message : "conversion failed"
34418
+ });
34419
+ }
34420
+ }
34421
+ return outcomes;
34422
+ }
34423
+ //#endregion
34424
+ //#region src/frigate/frigate-plus.ts
34425
+ var PLUS_API_HOST = "https://api.frigate.video";
34426
+ var PLUS_KEY_RE = /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}:[a-z0-9]{40}$/;
34427
+ function isValidPlusApiKey(key) {
34428
+ return PLUS_KEY_RE.test(key);
34429
+ }
34430
+ function isRecord(value) {
34431
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34432
+ }
34433
+ /** Exchange the API key for an access token. The key never appears in errors. */
34434
+ async function fetchPlusAccessToken(fetchFn, apiKey) {
34435
+ if (!isValidPlusApiKey(apiKey)) throw new Error("Frigate+ API key is malformed — expected the \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:40-hex\" shape shown in your Frigate+ account settings");
34436
+ const [user, pass] = apiKey.split(":");
34437
+ const basic = Buffer.from(`${user}:${pass}`).toString("base64");
34438
+ const res = await fetchFn(`${PLUS_API_HOST}/v1/auth/token`, { headers: { authorization: `Basic ${basic}` } });
34439
+ if (!res.ok) throw new Error(`Frigate+ authentication failed (HTTP ${res.status}) — check the API key in the Model Studio addon settings`);
34440
+ const body = await res.json();
34441
+ const token = isRecord(body) && typeof body.accessToken === "string" ? body.accessToken : null;
34442
+ if (token === null) throw new Error("Frigate+ auth response carried no accessToken");
34443
+ return token;
34444
+ }
34445
+ async function plusGet(fetchFn, token, path) {
34446
+ const res = await fetchFn(`${PLUS_API_HOST}/v1/${path}`, { headers: { authorization: `Bearer ${token}` } });
34447
+ if (!res.ok) throw new Error(`Frigate+ request "${path}" failed (HTTP ${res.status}): ${await res.text()}`);
34448
+ return res.json();
34449
+ }
34450
+ /**
34451
+ * `verifiedCounts` has THREE real shapes and none may break the parse:
34452
+ * -1 → base model ("not applicable") ⇒ 0
34453
+ * { "11": 199 } → legacy custom (flat label→count)
34454
+ * { camera: { label: n, … }, … } → recent custom (per camera)
34455
+ */
34456
+ function verifiedImageCount(raw) {
34457
+ if (typeof raw === "number") return raw > 0 ? raw : 0;
34458
+ if (!isRecord(raw)) return 0;
34459
+ let total = 0;
34460
+ for (const value of Object.values(raw)) if (typeof value === "number") total += value > 0 ? value : 0;
34461
+ else if (isRecord(value)) {
34462
+ for (const inner of Object.values(value)) if (typeof inner === "number" && inner > 0) total += inner;
34463
+ }
34464
+ return total;
34465
+ }
34466
+ function toModelSummary(m) {
34467
+ if (typeof m.id !== "string") return null;
34468
+ return {
34469
+ id: m.id,
34470
+ name: typeof m.name === "string" ? m.name : m.id,
34471
+ type: typeof m.type === "string" ? m.type : "unknown",
34472
+ isBaseModel: m.isBaseModel === true,
34473
+ baseModel: typeof m.baseModel === "string" ? m.baseModel : "",
34474
+ supportedDetectors: Array.isArray(m.supportedDetectors) ? m.supportedDetectors.filter((d) => typeof d === "string") : [],
34475
+ trainDate: typeof m.trainDate === "string" ? m.trainDate : "",
34476
+ ...typeof m.hailoDevice === "string" ? { hailoDevice: m.hailoDevice } : {},
34477
+ verifiedImages: verifiedImageCount(m.verifiedCounts),
34478
+ ...typeof m.width === "number" ? { width: m.width } : {},
34479
+ ...typeof m.height === "number" ? { height: m.height } : {}
34480
+ };
34481
+ }
34482
+ /** Parse a raw `/v1/model/list` body (`{list:[…]}`, or a bare array). */
34483
+ function toPlusModelSummaries(raw) {
34484
+ return (Array.isArray(raw) ? raw : isRecord(raw) && Array.isArray(raw.list) ? raw.list : []).filter(isRecord).map(toModelSummary).filter((m) => m !== null);
34485
+ }
34486
+ /**
34487
+ * EVERYTHING the account has access to — base AND custom models. The real
34488
+ * response wraps the array: `{ "list": [...] }` (a bare array is tolerated
34489
+ * for forward compatibility).
34490
+ */
34491
+ async function listPlusModels(fetchFn, apiKey) {
34492
+ return toPlusModelSummaries(await plusGet(fetchFn, await fetchPlusAccessToken(fetchFn, apiKey), "model/list"));
34493
+ }
34494
+ function toModelInfo(raw, modelId) {
34495
+ if (!isRecord(raw)) throw new Error("Frigate+ model info: unexpected response shape");
34496
+ const labelMap = {};
34497
+ if (isRecord(raw.labelMap)) {
34498
+ for (const [k, v] of Object.entries(raw.labelMap)) if (typeof v === "string") labelMap[k] = v;
34499
+ }
34500
+ return {
34501
+ id: typeof raw.id === "string" ? raw.id : modelId,
34502
+ type: typeof raw.type === "string" ? raw.type : "unknown",
34503
+ width: typeof raw.width === "number" ? raw.width : 320,
34504
+ height: typeof raw.height === "number" ? raw.height : 320,
34505
+ labelMap
34506
+ };
34507
+ }
34508
+ /** Resolve one model's metadata + presigned artifact URL. */
34509
+ async function requestPlusModelDownload(fetchFn, apiKey, plusModelId) {
34510
+ const token = await fetchPlusAccessToken(fetchFn, apiKey);
34511
+ const info = toModelInfo(await plusGet(fetchFn, token, `model/${plusModelId}`), plusModelId);
34512
+ const signed = await plusGet(fetchFn, token, `model/${plusModelId}/signed_url`);
34513
+ const url = isRecord(signed) && typeof signed.url === "string" ? signed.url : null;
34514
+ if (url === null) throw new Error("Frigate+ signed_url response carried no url");
34515
+ return {
34516
+ info,
34517
+ downloadUrl: url
34518
+ };
34519
+ }
34520
+ /** Frigate+ model type → our format + decode. Mirrors the Frigate presets. */
34521
+ var PLUS_TYPE_MAP = {
34522
+ mobiledet: {
34523
+ format: "tflite",
34524
+ postprocessor: "ssd",
34525
+ extension: ".tflite"
34526
+ },
34527
+ yolonas: {
34528
+ format: "onnx",
34529
+ postprocessor: "yolonas",
34530
+ extension: ".onnx"
34531
+ },
34532
+ yolov9: {
34533
+ format: "onnx",
34534
+ extension: ".onnx"
34535
+ }
34536
+ };
34537
+ function plusTypeTraits(type) {
34538
+ return PLUS_TYPE_MAP[type] ?? {
34539
+ format: "onnx",
34540
+ extension: ".onnx"
34541
+ };
34542
+ }
34543
+ /** Ordered labels from the API's `index → label` map; holes keep alignment. */
34544
+ function orderedLabels(labelMap) {
34545
+ const indices = Object.keys(labelMap).map((k) => Number.parseInt(k, 10)).filter((n) => Number.isInteger(n) && n >= 0);
34546
+ if (indices.length === 0) return [];
34547
+ const max = Math.max(...indices);
34548
+ const out = [];
34549
+ for (let i = 0; i <= max; i++) out.push(labelMap[String(i)] ?? `unknown-${i}`);
34550
+ return out;
34551
+ }
34552
+ /**
34553
+ * Build the registrable descriptor for a downloaded Frigate+ model. The
34554
+ * artifact is hub-local (landed by the server-side download), so the URL uses
34555
+ * the `camstack-local://` convention (D208) — other nodes get it via
34556
+ * Distribute.
34557
+ */
34558
+ function plusInfoToDescriptor(info, opts) {
34559
+ const traits = plusTypeTraits(info.type);
34560
+ const labels = orderedLabels(info.labelMap);
34561
+ return {
34562
+ stepId: "object-detection",
34563
+ entry: {
34564
+ id: opts.modelId,
34565
+ name: opts.name,
34566
+ description: `Frigate+ personal model (${info.type}) — imported via Model Studio`,
34567
+ formats: { [traits.format]: {
34568
+ url: `camstack-local://${opts.modelId}/${opts.filename}`,
34569
+ sizeMB: opts.sizeMB,
34570
+ runtimes: ["python"]
34571
+ } },
34572
+ inputSize: {
34573
+ width: info.width,
34574
+ height: info.height
34575
+ },
34576
+ labels: labels.map((name) => ({
34577
+ id: name,
34578
+ name
34579
+ })),
34580
+ preprocessMode: "letterbox",
34581
+ ...traits.postprocessor !== void 0 ? { postprocessor: traits.postprocessor } : {}
34582
+ }
34583
+ };
34584
+ }
34585
+ var MODEL_UPLOAD_ALLOWED_EXTENSIONS = [".onnx", ".tflite"];
34586
+ var FILENAME_RE = /^[a-zA-Z0-9._-]+$/;
34587
+ /**
34588
+ * Validate an upload filename: plain basename (no separators, no traversal),
34589
+ * conservative charset, allowlisted model extension. Returns it unchanged so
34590
+ * call sites read as an assertion. Throws with an actionable message.
34591
+ */
34592
+ function sanitizeModelFilename(filename) {
34593
+ if (filename.includes("/") || filename.includes("\\") || filename.includes("..") || !FILENAME_RE.test(filename)) throw new Error(`Invalid model filename "${filename}" — plain basename with letters, digits, dot, underscore, hyphen only`);
34594
+ if (!MODEL_UPLOAD_ALLOWED_EXTENSIONS.some((ext) => filename.endsWith(ext))) throw new Error(`Unsupported model file extension on "${filename}" — expected one of ${MODEL_UPLOAD_ALLOWED_EXTENSIONS.join(", ")}`);
34595
+ return filename;
34596
+ }
34597
+ var ModelUploadStore = class {
34598
+ uploads = /* @__PURE__ */ new Map();
34599
+ opts;
34600
+ maxBytes;
34601
+ constructor(opts) {
34602
+ this.opts = opts;
34603
+ this.maxBytes = opts.maxBytes ?? 536870912;
34604
+ }
34605
+ begin(input) {
34606
+ const filename = sanitizeModelFilename(input.filename);
34607
+ if (!Number.isFinite(input.totalBytes) || input.totalBytes <= 0) throw new Error("totalBytes must be a positive number");
34608
+ if (input.totalBytes > this.maxBytes) throw new Error(`Upload too large: ${input.totalBytes} bytes exceeds the ${this.maxBytes}-byte cap`);
34609
+ fs$1.mkdirSync(this.opts.stagingDir, { recursive: true });
34610
+ const uploadId = randomUUID();
34611
+ const stagingPath = path$1.join(this.opts.stagingDir, `${uploadId}-${filename}`);
34612
+ fs$1.writeFileSync(stagingPath, Buffer.alloc(0));
34613
+ this.uploads.set(uploadId, {
34614
+ filename,
34615
+ totalBytes: input.totalBytes,
34616
+ stagingPath,
34617
+ hash: createHash("sha256"),
34618
+ received: 0
34619
+ });
34620
+ return { uploadId };
34621
+ }
34622
+ append(input) {
34623
+ const upload = this.mustGet(input.uploadId);
34624
+ const chunk = Buffer.from(input.chunkBase64, "base64");
34625
+ if (chunk.length === 0) throw new Error("Empty chunk");
34626
+ if (upload.received + chunk.length > upload.totalBytes) {
34627
+ this.abort({ uploadId: input.uploadId });
34628
+ throw new Error(`Chunk exceeds the declared totalBytes (${upload.totalBytes}) — upload aborted`);
34629
+ }
34630
+ fs$1.appendFileSync(upload.stagingPath, chunk);
34631
+ upload.hash.update(chunk);
34632
+ upload.received += chunk.length;
34633
+ return { received: upload.received };
34634
+ }
34635
+ commit(input) {
34636
+ const upload = this.mustGet(input.uploadId);
34637
+ if (upload.received !== upload.totalBytes) throw new Error(`Upload incomplete: received ${upload.received} of ${upload.totalBytes} bytes`);
34638
+ fs$1.mkdirSync(this.opts.modelsDir, { recursive: true });
34639
+ const destPath = path$1.join(this.opts.modelsDir, upload.filename);
34640
+ fs$1.renameSync(upload.stagingPath, destPath);
34641
+ this.uploads.delete(input.uploadId);
34642
+ return {
34643
+ filename: upload.filename,
34644
+ bytes: upload.received,
34645
+ sha256: upload.hash.digest("hex")
34646
+ };
34647
+ }
34648
+ abort(input) {
34649
+ const upload = this.uploads.get(input.uploadId);
34650
+ if (upload) {
34651
+ try {
34652
+ fs$1.rmSync(upload.stagingPath, { force: true });
34653
+ } catch {}
34654
+ this.uploads.delete(input.uploadId);
34655
+ }
34656
+ return { ok: true };
34657
+ }
34658
+ mustGet(uploadId) {
34659
+ const upload = this.uploads.get(uploadId);
34660
+ if (!upload) throw new Error(`Unknown uploadId "${uploadId}" — begin a new upload`);
34661
+ return upload;
34662
+ }
34663
+ };
34065
34664
  //#endregion
34066
34665
  //#region src/model-studio-actions.ts
34067
34666
  /**
@@ -34102,6 +34701,100 @@ var modelStudioActions = defineCustomActions({
34102
34701
  }), { kind: "mutation" }),
34103
34702
  listAvailability: customAction(_void(), ModelAvailabilityMapSchema),
34104
34703
  /**
34704
+ * Chunked model-file upload (Frigate import). One tRPC mutation cannot
34705
+ * carry a model file (hub HTTP body limit ~1 MB), so the page streams
34706
+ * base64 chunks: begin → append× → commit. Commit lands the artifact in the
34707
+ * hub's shared modelsDir, where the `camstack-local://` catalog-URL
34708
+ * convention resolves it by basename and `model-distributor` can ship it to
34709
+ * agents. Abort (or an oversize chunk) discards the staging file.
34710
+ */
34711
+ beginModelUpload: customAction(object({
34712
+ filename: string(),
34713
+ totalBytes: number().int().positive()
34714
+ }), object({ uploadId: string() }), { kind: "mutation" }),
34715
+ appendModelUpload: customAction(object({
34716
+ uploadId: string(),
34717
+ chunkBase64: string()
34718
+ }), object({ received: number() }), { kind: "mutation" }),
34719
+ commitModelUpload: customAction(object({ uploadId: string() }), object({
34720
+ filename: string(),
34721
+ bytes: number(),
34722
+ sha256: string()
34723
+ }), { kind: "mutation" }),
34724
+ abortModelUpload: customAction(object({ uploadId: string() }), object({ ok: literal(true) }), { kind: "mutation" }),
34725
+ /**
34726
+ * Curated PUBLIC Frigate model catalog (D214 addendum) — models Frigate
34727
+ * uses/references that are downloadable without credentials, with verified
34728
+ * metadata so the operator types nothing. Import downloads SERVER-SIDE into
34729
+ * the hub's modelsDir and registers the descriptor with the public URL
34730
+ * (other nodes download on demand — no Distribute needed).
34731
+ */
34732
+ listFrigateCatalog: customAction(_void(), array(object({
34733
+ id: string(),
34734
+ label: string(),
34735
+ format: _enum(MODEL_FORMATS),
34736
+ sizeMB: number(),
34737
+ inputSize: number(),
34738
+ notes: string()
34739
+ })).readonly()),
34740
+ importFrigateCatalogModel: customAction(object({
34741
+ catalogId: string(),
34742
+ modelId: string().optional(),
34743
+ name: string().optional()
34744
+ }), object({
34745
+ ok: literal(true),
34746
+ modelId: string(),
34747
+ bytes: number()
34748
+ }), { kind: "mutation" }),
34749
+ /**
34750
+ * Frigate+ personal models. The API key lives in the addon's SETTINGS
34751
+ * (password field) and never leaves the hub: list / test / import all run
34752
+ * server-side against api.frigate.video. Status deliberately reveals only
34753
+ * booleans — never the key.
34754
+ */
34755
+ /**
34756
+ * Nodes whose engine can actually LOAD a tflite artifact (the pool's only
34757
+ * tflite runtime binds the Coral EdgeTPU delegate — no Coral, no tflite).
34758
+ * The UI gates tflite imports on this instead of letting them fail at load.
34759
+ */
34760
+ tfliteSupport: customAction(_void(), object({ nodes: array(string()).readonly() })),
34761
+ frigatePlusStatus: customAction(_void(), object({
34762
+ configured: boolean(),
34763
+ keyFormatValid: boolean()
34764
+ })),
34765
+ frigatePlusTest: customAction(_void(), object({
34766
+ ok: boolean(),
34767
+ error: string().optional()
34768
+ }), { kind: "mutation" }),
34769
+ frigatePlusListModels: customAction(_void(), array(object({
34770
+ id: string(),
34771
+ name: string(),
34772
+ type: string(),
34773
+ isBaseModel: boolean(),
34774
+ baseModel: string(),
34775
+ supportedDetectors: array(string()).readonly(),
34776
+ trainDate: string(),
34777
+ hailoDevice: string().optional(),
34778
+ verifiedImages: number(),
34779
+ width: number().optional(),
34780
+ height: number().optional()
34781
+ })).readonly()),
34782
+ importFrigatePlusModel: customAction(object({
34783
+ plusModelId: string(),
34784
+ modelId: string(),
34785
+ name: string().optional()
34786
+ }), object({
34787
+ ok: literal(true),
34788
+ modelId: string(),
34789
+ bytes: number(),
34790
+ conversions: array(object({
34791
+ format: _enum(["openvino", "coreml"]),
34792
+ ok: boolean(),
34793
+ nodeId: string().optional(),
34794
+ error: string().optional()
34795
+ })).readonly()
34796
+ }), { kind: "mutation" }),
34797
+ /**
34105
34798
  * Rank nodes for model conversion based on hardware capabilities.
34106
34799
  * CoreML targets require darwin; OpenVINO targets require an OpenVINO-capable node.
34107
34800
  */
@@ -34184,21 +34877,14 @@ function spawnRunConvert(jobPath, pythonPath, onProgress, _sessionId) {
34184
34877
  });
34185
34878
  });
34186
34879
  }
34187
- /**
34188
- * Model Studio provides the `custom-model-registry` collection cap (hub only)
34189
- * and the `model-convert` singleton cap (any node).
34190
- *
34191
- * P1 maintained the durable list of custom detection models and exposed them
34192
- * so the detection pipeline can offer them in its model picker.
34193
- *
34194
- * P3 adds the `model-convert` provider on EVERY node so agents can run
34195
- * local Python conversion without routing through the hub.
34196
- * The `custom-model-registry` cap and all customActions are hub-only.
34197
- */
34198
- var ModelStudioAddon = class extends BaseAddon {
34880
+ /** Global-fetch adapter for the injected Frigate+ client seam. */
34881
+ var plusFetch = async (url, init) => fetch(url, init);
34882
+ var ModelStudioAddon = class ModelStudioAddon extends BaseAddon {
34199
34883
  id = "model-studio";
34200
34884
  modelsState = null;
34201
34885
  availabilityState = null;
34886
+ /** Lazily-built chunked-upload store (hub only) — see `upload/model-upload-store.ts`. */
34887
+ uploadStore = null;
34202
34888
  /**
34203
34889
  * Module-Federation page declaration surfaced via the `addon-pages-source`
34204
34890
  * collection cap. Mirrors the manifest `pages[]` entry — admin-ui's
@@ -34215,7 +34901,21 @@ var ModelStudioAddon = class extends BaseAddon {
34215
34901
  section: "detection"
34216
34902
  }];
34217
34903
  constructor() {
34218
- super({});
34904
+ super({ frigatePlusApiKey: "" });
34905
+ }
34906
+ globalSettingsSchema() {
34907
+ return this.schema({ sections: [{
34908
+ id: "frigate-plus",
34909
+ title: "Frigate+",
34910
+ description: "Credentials for importing your personal Frigate+ models. Create an API key in your Frigate+ account (https://plus.frigate.video → Settings → API keys; see docs.frigate.video/integrations/plus). The key is used only server-side against api.frigate.video and is never sent to the browser.",
34911
+ fields: [{
34912
+ type: "password",
34913
+ key: "frigatePlusApiKey",
34914
+ label: "Frigate+ API key",
34915
+ description: "Shape: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:40-hex-chars",
34916
+ showToggle: true
34917
+ }]
34918
+ }] });
34219
34919
  }
34220
34920
  makeConvertDeps() {
34221
34921
  return {
@@ -34285,11 +34985,62 @@ var ModelStudioAddon = class extends BaseAddon {
34285
34985
  providers,
34286
34986
  customActions: isHub ? modelStudioActions : void 0,
34287
34987
  actionHandlers: isHub ? {
34988
+ beginModelUpload: async (input) => (await this.getUploadStore()).begin(input),
34989
+ appendModelUpload: async (input) => (await this.getUploadStore()).append(input),
34990
+ commitModelUpload: async (input) => {
34991
+ const result = (await this.getUploadStore()).commit(input);
34992
+ this.ctx.logger.info("Model upload landed in modelsDir", { meta: {
34993
+ filename: result.filename,
34994
+ bytes: result.bytes,
34995
+ sha256: result.sha256
34996
+ } });
34997
+ return result;
34998
+ },
34999
+ abortModelUpload: async (input) => (await this.getUploadStore()).abort(input),
34288
35000
  registerCustomModel: async (input) => this.registerCustomModel(input),
34289
35001
  removeCustomModel: async (input) => this.removeCustomModel(input),
34290
35002
  listCustomModels: async () => this.listCustomModels(),
34291
35003
  distributeModel: async (input) => this.distributeModel(input),
34292
35004
  listAvailability: async () => this.availability_.get(),
35005
+ listFrigateCatalog: async () => FRIGATE_PUBLIC_CATALOG.map((c) => ({
35006
+ id: c.id,
35007
+ label: c.label,
35008
+ format: c.format,
35009
+ sizeMB: c.sizeMB,
35010
+ inputSize: c.inputSize,
35011
+ notes: c.notes
35012
+ })),
35013
+ importFrigateCatalogModel: async (input) => this.importFrigateCatalogModel(input),
35014
+ tfliteSupport: async () => {
35015
+ const api = this.ctx.api;
35016
+ if (!api) return { nodes: [] };
35017
+ const nodes = await this.recommendDeps().listNodes();
35018
+ const supported = [];
35019
+ for (const nId of nodes) try {
35020
+ if ((await api.platformProbe.getCapabilities.query({ nodeId: nId })).scores.some((sc) => sc.format === "tflite" && sc.available)) supported.push(nId);
35021
+ } catch {}
35022
+ return { nodes: supported };
35023
+ },
35024
+ frigatePlusStatus: async () => {
35025
+ const key = this.config.frigatePlusApiKey ?? "";
35026
+ return {
35027
+ configured: key.length > 0,
35028
+ keyFormatValid: isValidPlusApiKey(key)
35029
+ };
35030
+ },
35031
+ frigatePlusTest: async () => {
35032
+ try {
35033
+ await fetchPlusAccessToken(plusFetch, this.config.frigatePlusApiKey ?? "");
35034
+ return { ok: true };
35035
+ } catch (err) {
35036
+ return {
35037
+ ok: false,
35038
+ error: err instanceof Error ? err.message : "auth failed"
35039
+ };
35040
+ }
35041
+ },
35042
+ frigatePlusListModels: async () => listPlusModels(plusFetch, this.config.frigatePlusApiKey ?? ""),
35043
+ importFrigatePlusModel: async (input) => this.importFrigatePlusModel(input),
34293
35044
  recommendConvertNode: async (input) => {
34294
35045
  const api = this.ctx.api;
34295
35046
  if (!api) throw new Error("recommendConvertNode: ctx.api unavailable");
@@ -34366,6 +35117,22 @@ var ModelStudioAddon = class extends BaseAddon {
34366
35117
  } : void 0
34367
35118
  };
34368
35119
  }
35120
+ /**
35121
+ * Build (once) the upload store, anchored at this node's writable data root:
35122
+ * staging under `<dataDir>/model-uploads`, commits into the SHARED
35123
+ * `<nodeDataDir>/models` — the same directory every inference addon loads
35124
+ * from, which is what makes the `camstack-local://` URL convention resolve.
35125
+ */
35126
+ async getUploadStore() {
35127
+ if (this.uploadStore) return this.uploadStore;
35128
+ const modelsDir = await this.resolveModelsDir();
35129
+ const stagingDir = path.join(this.ctx.dataDir ?? os.tmpdir(), "model-uploads");
35130
+ this.uploadStore = new ModelUploadStore({
35131
+ stagingDir,
35132
+ modelsDir
35133
+ });
35134
+ return this.uploadStore;
35135
+ }
34369
35136
  get state_() {
34370
35137
  if (!this.modelsState) throw new Error("ModelStudioAddon: durable state not initialized");
34371
35138
  return this.modelsState;
@@ -34403,6 +35170,170 @@ var ModelStudioAddon = class extends BaseAddon {
34403
35170
  bytes: res.bytes
34404
35171
  };
34405
35172
  }
35173
+ /** listNodes/hardwareOf deps for `recommendConvertNode` — shared by the
35174
+ * convert actions and the Frigate+ auto-conversion. */
35175
+ recommendDeps() {
35176
+ const api = this.ctx.api;
35177
+ if (!api) throw new Error("model-studio: ctx.api unavailable");
35178
+ return {
35179
+ listNodes: async () => {
35180
+ return (await api.addons.listCapabilityProviders.query({ capName: "model-convert" })).map((p) => {
35181
+ const atIdx = p.addonId.indexOf("@");
35182
+ return atIdx !== -1 ? p.addonId.slice(atIdx + 1) : "hub";
35183
+ });
35184
+ },
35185
+ hardwareOf: async (nId) => {
35186
+ const hw = await api.platformProbe.getHardware.query({ nodeId: nId });
35187
+ const caps = await api.platformProbe.getCapabilities.query({ nodeId: nId });
35188
+ return {
35189
+ platform: hw.platform,
35190
+ gpu: hw.gpu ?? void 0,
35191
+ npu: hw.npu ?? void 0,
35192
+ openvino: caps.scores.some((s) => s.format === "openvino" && s.available)
35193
+ };
35194
+ }
35195
+ };
35196
+ }
35197
+ /** sha256 of a file on disk (streamed). */
35198
+ static async fileSha256(filePath) {
35199
+ const hash = crypto$1.createHash("sha256");
35200
+ await new Promise((resolve, reject) => {
35201
+ fs.createReadStream(filePath).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve()).on("error", reject);
35202
+ });
35203
+ return hash.digest("hex");
35204
+ }
35205
+ /**
35206
+ * Import a curated PUBLIC Frigate catalog model: download SERVER-SIDE into
35207
+ * the hub's modelsDir (size-verified against the curated declaration),
35208
+ * register the descriptor with the PUBLIC url (nodes download on demand),
35209
+ * and record hub availability.
35210
+ */
35211
+ async importFrigateCatalogModel(input) {
35212
+ const cat = FRIGATE_PUBLIC_CATALOG.find((c) => c.id === input.catalogId);
35213
+ if (!cat) throw new Error(`importFrigateCatalogModel: unknown catalog id "${input.catalogId}"`);
35214
+ const descriptor = buildFrigateCatalogDescriptor(input.catalogId, {
35215
+ ...input.modelId !== void 0 ? { modelId: input.modelId } : {},
35216
+ ...input.name !== void 0 ? { name: input.name } : {}
35217
+ });
35218
+ const modelsDir = await this.resolveModelsDir();
35219
+ const filename = cat.url.split("/").at(-1) ?? `${descriptor.entry.id}.${cat.format}`;
35220
+ const destPath = path.join(modelsDir, filename);
35221
+ await downloadFile(cat.url, destPath);
35222
+ const bytes = (await fs.promises.stat(destPath)).size;
35223
+ const declaredBytes = cat.sizeMB * 1e6;
35224
+ if (bytes < declaredBytes * .5 || bytes > declaredBytes * 2) {
35225
+ await fs.promises.rm(destPath, { force: true });
35226
+ throw new Error(`importFrigateCatalogModel: downloaded ${bytes} bytes but the catalog declares ~${Math.round(declaredBytes)} — refusing to register a suspicious artifact`);
35227
+ }
35228
+ const sha256 = await ModelStudioAddon.fileSha256(destPath);
35229
+ await this.registerCustomModel(descriptor);
35230
+ await this.availability_.update((prev) => upsertAvailability(prev, descriptor.entry.id, "hub", {
35231
+ format: cat.format,
35232
+ sha256,
35233
+ bytes,
35234
+ at: Date.now()
35235
+ }));
35236
+ this.ctx.logger.info("Imported Frigate catalog model", { meta: {
35237
+ catalogId: input.catalogId,
35238
+ modelId: descriptor.entry.id,
35239
+ bytes,
35240
+ sha256
35241
+ } });
35242
+ return {
35243
+ ok: true,
35244
+ modelId: descriptor.entry.id,
35245
+ bytes
35246
+ };
35247
+ }
35248
+ /**
35249
+ * Import one of the operator's personal Frigate+ models: resolve metadata +
35250
+ * presigned URL with the SECRET key (server-side only), download into the
35251
+ * hub's modelsDir, and register with the model-type-derived characteristics
35252
+ * (format, decode, input size, labelmap — nothing hand-typed). The artifact
35253
+ * is hub-local: other nodes get it via Models → Distribute.
35254
+ */
35255
+ async importFrigatePlusModel(input) {
35256
+ if (!/^[a-zA-Z0-9._-]+$/.test(input.modelId)) throw new Error(`importFrigatePlusModel: model id "${input.modelId}" is invalid`);
35257
+ const key = this.config.frigatePlusApiKey ?? "";
35258
+ const { info, downloadUrl } = await requestPlusModelDownload(plusFetch, key, input.plusModelId);
35259
+ const traits = plusTypeTraits(info.type);
35260
+ const filename = `${input.modelId}${traits.extension}`;
35261
+ const modelsDir = await this.resolveModelsDir();
35262
+ const destPath = path.join(modelsDir, filename);
35263
+ await downloadFile(downloadUrl, destPath);
35264
+ const bytes = (await fs.promises.stat(destPath)).size;
35265
+ if (bytes < 1e4) {
35266
+ await fs.promises.rm(destPath, { force: true });
35267
+ throw new Error(`importFrigatePlusModel: downloaded only ${bytes} bytes — the signed URL likely expired or the response was an error page`);
35268
+ }
35269
+ const sha256 = await ModelStudioAddon.fileSha256(destPath);
35270
+ const descriptor = plusInfoToDescriptor(info, {
35271
+ modelId: input.modelId,
35272
+ name: input.name?.trim() || `Frigate+ ${info.type} (${input.plusModelId.slice(0, 8)})`,
35273
+ filename,
35274
+ sizeMB: Number((bytes / 1e6).toFixed(1))
35275
+ });
35276
+ await this.registerCustomModel(descriptor);
35277
+ await this.availability_.update((prev) => upsertAvailability(prev, input.modelId, "hub", {
35278
+ format: traits.format,
35279
+ sha256,
35280
+ bytes,
35281
+ at: Date.now()
35282
+ }));
35283
+ this.ctx.logger.info("Imported Frigate+ model", { meta: {
35284
+ plusModelId: input.plusModelId,
35285
+ modelId: input.modelId,
35286
+ type: info.type,
35287
+ bytes
35288
+ } });
35289
+ let conversions = [];
35290
+ if (traits.format === "onnx") {
35291
+ const api = this.ctx.api;
35292
+ if (api) {
35293
+ conversions = await runPlusConversions({
35294
+ recommend: (i) => recommendConvertNode(i, this.recommendDeps()),
35295
+ convert: (convertInput, nId) => api.modelConvert.convert.mutate(convertInput, nodePin(nId)),
35296
+ freshSourceUrl: async () => (await requestPlusModelDownload(plusFetch, key, input.plusModelId)).downloadUrl,
35297
+ upsertEntry: async (entry) => {
35298
+ await this.state_.update((prev) => upsertCustomModel(prev, {
35299
+ stepId: descriptor.stepId,
35300
+ entry
35301
+ }));
35302
+ },
35303
+ markAvailable: async (nId, mId, artifacts) => {
35304
+ await this.availability_.update((prev) => {
35305
+ const seen = /* @__PURE__ */ new Set();
35306
+ let next = prev;
35307
+ for (const artifact of artifacts) {
35308
+ if (seen.has(artifact.format)) continue;
35309
+ seen.add(artifact.format);
35310
+ next = upsertAvailability(next, mId, nId, {
35311
+ format: artifact.format,
35312
+ sha256: "",
35313
+ bytes: Math.round(artifact.sizeMB * 1e6),
35314
+ at: Date.now()
35315
+ });
35316
+ }
35317
+ return next;
35318
+ });
35319
+ }
35320
+ }, {
35321
+ entry: descriptor.entry,
35322
+ targetFormats: ["openvino", "coreml"]
35323
+ });
35324
+ this.ctx.logger.info("Frigate+ import — engine conversions", { meta: {
35325
+ modelId: input.modelId,
35326
+ outcomes: conversions.map((o) => ({ ...o }))
35327
+ } });
35328
+ }
35329
+ }
35330
+ return {
35331
+ ok: true,
35332
+ modelId: input.modelId,
35333
+ bytes,
35334
+ conversions
35335
+ };
35336
+ }
34406
35337
  async registerCustomModel(input) {
34407
35338
  if (!input.stepId.trim()) throw new Error("registerCustomModel: stepId is required");
34408
35339
  if (!input.entry.id.trim()) throw new Error("registerCustomModel: entry.id is required");