@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
@@ -7700,7 +7700,7 @@ var RecordingBandSchema = object({
7700
7700
  postBufferSec: number().min(0).optional()
7701
7701
  });
7702
7702
  ({
7703
- preBufferSec: 10,
7703
+ preBufferSec: 15,
7704
7704
  postBufferSec: 30
7705
7705
  }).postBufferSec * 1e3;
7706
7706
  /**
@@ -13680,128 +13680,103 @@ var COCO_TO_MACRO = {
13680
13680
  var AUDIO_MACRO_LABELS = [
13681
13681
  {
13682
13682
  id: "speech",
13683
- name: "Speech",
13684
- icon: "🗣️"
13683
+ name: "Speech"
13685
13684
  },
13686
13685
  {
13687
13686
  id: "scream",
13688
- name: "Scream / Shout",
13689
- icon: "😱"
13687
+ name: "Scream / Shout"
13690
13688
  },
13691
13689
  {
13692
13690
  id: "crying",
13693
- name: "Crying / Baby",
13694
- icon: "😢"
13691
+ name: "Crying / Baby"
13695
13692
  },
13696
13693
  {
13697
13694
  id: "laughter",
13698
- name: "Laughter",
13699
- icon: "😂"
13695
+ name: "Laughter"
13700
13696
  },
13701
13697
  {
13702
13698
  id: "music",
13703
- name: "Music",
13704
- icon: "🎵"
13699
+ name: "Music"
13705
13700
  },
13706
13701
  {
13707
13702
  id: "dog",
13708
- name: "Dog",
13709
- icon: "🐕"
13703
+ name: "Dog"
13710
13704
  },
13711
13705
  {
13712
13706
  id: "cat",
13713
- name: "Cat",
13714
- icon: "🐈"
13707
+ name: "Cat"
13715
13708
  },
13716
13709
  {
13717
13710
  id: "bird",
13718
- name: "Bird",
13719
- icon: "🐦"
13711
+ name: "Bird"
13720
13712
  },
13721
13713
  {
13722
13714
  id: "animal",
13723
- name: "Animal (other)",
13724
- icon: "🐾"
13715
+ name: "Animal (other)"
13725
13716
  },
13726
13717
  {
13727
13718
  id: "alarm",
13728
- name: "Alarm / Siren",
13729
- icon: "🚨"
13719
+ name: "Alarm / Siren"
13730
13720
  },
13731
13721
  {
13732
13722
  id: "doorbell",
13733
- name: "Doorbell / Knock",
13734
- icon: "🔔"
13723
+ name: "Doorbell / Knock"
13735
13724
  },
13736
13725
  {
13737
13726
  id: "glass_breaking",
13738
- name: "Glass Breaking",
13739
- icon: "💥"
13727
+ name: "Glass Breaking"
13740
13728
  },
13741
13729
  {
13742
13730
  id: "gunshot",
13743
- name: "Gunshot / Explosion",
13744
- icon: "💣"
13731
+ name: "Gunshot / Explosion"
13745
13732
  },
13746
13733
  {
13747
13734
  id: "vehicle",
13748
- name: "Vehicle",
13749
- icon: "🚗"
13735
+ name: "Vehicle"
13750
13736
  },
13751
13737
  {
13752
13738
  id: "siren",
13753
- name: "Emergency Siren",
13754
- icon: "🚑"
13739
+ name: "Emergency Siren"
13755
13740
  },
13756
13741
  {
13757
13742
  id: "fire",
13758
- name: "Fire / Smoke",
13759
- icon: "🔥"
13743
+ name: "Fire / Smoke"
13760
13744
  },
13761
13745
  {
13762
13746
  id: "water",
13763
- name: "Water",
13764
- icon: "💧"
13747
+ name: "Water"
13765
13748
  },
13766
13749
  {
13767
13750
  id: "wind",
13768
- name: "Wind / Weather",
13769
- icon: "🌬️"
13751
+ name: "Wind / Weather"
13770
13752
  },
13771
13753
  {
13772
13754
  id: "door",
13773
- name: "Door",
13774
- icon: "🚪"
13755
+ name: "Door"
13775
13756
  },
13776
13757
  {
13777
13758
  id: "footsteps",
13778
- name: "Footsteps",
13779
- icon: "👣"
13759
+ name: "Footsteps"
13780
13760
  },
13781
13761
  {
13782
13762
  id: "crowd",
13783
- name: "Crowd / Chatter",
13784
- icon: "👥"
13763
+ name: "Crowd / Chatter"
13785
13764
  },
13786
13765
  {
13787
13766
  id: "telephone",
13788
- name: "Telephone",
13789
- icon: "📞"
13767
+ name: "Telephone"
13790
13768
  },
13791
13769
  {
13792
13770
  id: "engine",
13793
- name: "Engine / Motor",
13794
- icon: "⚙️"
13771
+ name: "Engine / Motor"
13795
13772
  },
13796
13773
  {
13797
13774
  id: "tools",
13798
- name: "Tools / Construction",
13799
- icon: "🔨"
13775
+ name: "Tools / Construction"
13800
13776
  },
13801
13777
  {
13802
13778
  id: "silence",
13803
- name: "Silence",
13804
- icon: "🤫"
13779
+ name: "Silence"
13805
13780
  }
13806
13781
  ];
13807
13782
  var YAMNET_TO_MACRO = {
@@ -16265,7 +16240,7 @@ var TrackEnvelopeSchema = object({
16265
16240
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16266
16241
  * keeps every scalar the list surfaces actually render (ids, class(es),
16267
16242
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16268
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
16243
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
16269
16244
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16270
16245
  * `getTrack`. Mirrors the event-store `projection` convention
16271
16246
  * (`getObjectEvents` et al.).
@@ -16560,6 +16535,35 @@ var TrackSchema = object({
16560
16535
  */
16561
16536
  hasFace: boolean().optional(),
16562
16537
  /**
16538
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
16539
+ * face an operator could ASSIGN to an identity.
16540
+ *
16541
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
16542
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
16543
+ * branch, before every gate, and means no more than "a face detector produced
16544
+ * a face detail". This one is stamped at the single moment the gallery row
16545
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
16546
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
16547
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
16548
+ * the crop-store drop. Everything between the detector and that insert can
16549
+ * legitimately refuse the face, so a flag written any earlier promises the
16550
+ * operator something to assign and delivers nothing.
16551
+ *
16552
+ * **Independent of recognition.** A face collected but never auto-matched is
16553
+ * still assignable — it is in fact the face an operator most wants to reach —
16554
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
16555
+ * `subLabel`; this says only that the raw material exists.
16556
+ *
16557
+ * **Set once, never cleared.** A track that produced a gallery row produced
16558
+ * one; deleting the row later is the gallery's business, not this flag's.
16559
+ *
16560
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
16561
+ * before the column omits it, and so does every server that predates the
16562
+ * field. A consumer must test `=== true` and render nothing otherwise —
16563
+ * never infer "no assignable face".
16564
+ */
16565
+ hasEmbeddedFace: boolean().optional(),
16566
+ /**
16563
16567
  * This subject CONTAINS a folded rider — a person the rider-pairing step
16564
16568
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
16565
16569
  * so the passage is tracked once and as a VEHICLE.
@@ -17909,6 +17913,32 @@ var DetailResultSchema = object({
17909
17913
  /** FRAME-space bbox (already mapped back from crop space). */
17910
17914
  bbox: NativeCropBboxSchema.optional(),
17911
17915
  embedding: string().optional(),
17916
+ /**
17917
+ * L2 magnitude of the RAW pre-normalization embedding. Additive and optional
17918
+ * so an older runner that does not send it degrades to "unmeasurable", which
17919
+ * the consuming gate treats as accept — never as reject.
17920
+ */
17921
+ embeddingMagnitude: number().optional(),
17922
+ /**
17923
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
17924
+ * `label`, and for the same reason: the producing node is the only side that
17925
+ * knows which model actually ran, and re-deriving it downstream is exactly
17926
+ * the inference this model exists to forbid.
17927
+ *
17928
+ * Additive and optional, so a runner that predates it degrades to
17929
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
17930
+ * its previous assumption instead of refusing.
17931
+ *
17932
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
17933
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
17934
+ * with nothing on the wire it took the id from the GALLERY, so the gate
17935
+ * compared the gallery against itself and could never fire. That turned the
17936
+ * one guard protecting the face feature space into a tautology, and it is
17937
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
17938
+ * gallery as the catalog comment predicts — it would have scored the new
17939
+ * model's vectors against the old model's samples in one cosine space.
17940
+ */
17941
+ embeddingModelId: string().optional(),
17912
17942
  label: string().optional(),
17913
17943
  /**
17914
17944
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -18784,6 +18814,12 @@ var CameraStatusSchema = object({
18784
18814
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
18785
18815
  fetchedAt: number()
18786
18816
  });
18817
+ var InferenceDeviceExclusionReasonSchema = _enum([
18818
+ "disabled",
18819
+ "unavailable",
18820
+ "cannot-host-camera-root",
18821
+ "accelerator-preferred"
18822
+ ]);
18787
18823
  var NodeInferenceDeviceSchema = object({
18788
18824
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
18789
18825
  key: string(),
@@ -18814,7 +18850,17 @@ var NodeInferenceDeviceSchema = object({
18814
18850
  * available per format; this is the stored selection that becomes the
18815
18851
  * default for EVERY camera landing on this accelerator.
18816
18852
  */
18817
- steps: record(string(), DeviceStepConfigSchema).optional()
18853
+ steps: record(string(), DeviceStepConfigSchema).optional(),
18854
+ /**
18855
+ * `null` when the device IS a camera-root candidate on this node; otherwise
18856
+ * the reason the dispatcher drops it. Computed by the SAME
18857
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
18858
+ * never disagree with the election — deriving it in the UI from
18859
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
18860
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
18861
+ * "an accelerator is serving" predicate).
18862
+ */
18863
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
18818
18864
  });
18819
18865
  var NodeInferenceDevicesSchema = object({
18820
18866
  nodeId: string(),
@@ -19319,6 +19365,7 @@ DeviceType.Camera, method(object({
19319
19365
  isBattery: boolean(),
19320
19366
  reason: _enum([
19321
19367
  "disabled",
19368
+ "offline",
19322
19369
  "sleeping",
19323
19370
  "unreachable",
19324
19371
  "waking"
@@ -19364,9 +19411,17 @@ targets: array(object({
19364
19411
  sleeping: boolean(),
19365
19412
  /** Current device state rendered over the cached frame. State images
19366
19413
  * remain authoritative even when their photographic background is
19367
- * old; null means the link must carry a current camera frame. */
19414
+ * old; null means the link must carry a current camera frame.
19415
+ *
19416
+ * **This is the ONLY source a surface may derive an inactive tile
19417
+ * from.** A client that reads a device's `online`/`disabled` flags and
19418
+ * draws its own verdict will disagree with the pixels it is painting
19419
+ * — the frame comes from here, and so must the sentence over it
19420
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
19421
+ * state. */
19368
19422
  stateReason: _enum([
19369
19423
  "disabled",
19424
+ "offline",
19370
19425
  "sleeping",
19371
19426
  "unreachable",
19372
19427
  "waking"
@@ -22563,7 +22618,12 @@ var ListResultSchema = object({
22563
22618
  probedAt: number()
22564
22619
  });
22565
22620
  var PreferredSchema = LocalInterfaceSchema.nullable();
22566
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
22621
+ /**
22622
+ * Candidate base URL for the SDK to race on connect. Order matters —
22623
+ * the SDK should attempt these top-to-bottom with a short per-candidate
22624
+ * timeout (e.g. 1500ms) and cache the winner for the session.
22625
+ */
22626
+ var ConnectionEndpointSchema = object({
22567
22627
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
22568
22628
  label: string(),
22569
22629
  /** Fully-formed base URL with scheme + host + port. */
@@ -22606,7 +22666,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
22606
22666
  * ordering between polls.
22607
22667
  */
22608
22668
  priority: number()
22609
- })).readonly() });
22669
+ });
22670
+ /**
22671
+ * Where the advertised local port came from. Ordered most → least
22672
+ * authoritative, and the whole point of returning it: a client must be able to
22673
+ * tell a FACT about the hub's socket from an echo of its own guess.
22674
+ */
22675
+ var LocalPortSourceEnum = _enum([
22676
+ "server-config",
22677
+ "server-env",
22678
+ "caller-hint",
22679
+ "default"
22680
+ ]);
22681
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
22682
+ var AdvertisedLocalPortSchema = object({
22683
+ port: number().int().min(1).max(65535),
22684
+ source: LocalPortSourceEnum
22685
+ });
22686
+ var GetConnectionEndpointsResultSchema = object({
22687
+ endpoints: array(ConnectionEndpointSchema).readonly(),
22688
+ /**
22689
+ * The port the hub built the LAN/loopback URLs with, and where that number
22690
+ * came from.
22691
+ *
22692
+ * Returned rather than merely applied, because "the URL is right" and "the
22693
+ * client can KNOW the URL is right" are different properties. A client that
22694
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
22695
+ * a hub that echoed the port the client sent, so it cannot decide whether to
22696
+ * race the candidate or discard it. With `source` it can: anything but
22697
+ * `caller-hint` is the hub's own socket.
22698
+ *
22699
+ * Absent on hubs predating this field — a client that finds it missing is
22700
+ * talking to an echoing hub and must degrade exactly as it does for
22701
+ * `caller-hint`.
22702
+ */
22703
+ localPort: AdvertisedLocalPortSchema
22704
+ });
22610
22705
  /**
22611
22706
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
22612
22707
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -22627,8 +22722,13 @@ var AllowedAddressesSchema = object({
22627
22722
  */
22628
22723
  addresses: array(string()).readonly() });
22629
22724
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
22630
- /** Local hub HTTP port to use in base URLs. */
22631
- port: number().int().min(1).max(65535),
22725
+ /**
22726
+ * LEGACY HINT — do not send from new code. Kept optional so clients
22727
+ * written against the echoing contract keep working; the hub uses it
22728
+ * only when it cannot read its own port, and says so via
22729
+ * `localPort.source === 'caller-hint'`.
22730
+ */
22731
+ port: number().int().min(1).max(65535).optional(),
22632
22732
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
22633
22733
  * candidate. Default `true`. */
22634
22734
  includeLoopback: boolean().optional(),
@@ -33552,7 +33652,6 @@ var NC_AUDIO_DEFAULTS = {
33552
33652
  hitPercent: 60,
33553
33653
  samplingSeconds: 10
33554
33654
  };
33555
- new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
33556
33655
  new Set(["devices", "classes"]);
33557
33656
  NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
33558
33657
  /**
@@ -33786,6 +33885,39 @@ TimelapseRuleInputSchema.extend({
33786
33885
  createdAt: number(),
33787
33886
  updatedAt: number()
33788
33887
  });
33888
+ Object.freeze(Object.fromEntries([{
33889
+ stepId: "face-embedding",
33890
+ label: "Face recognition model",
33891
+ defaultModelId: "arcface-r100",
33892
+ indexName: "the enrolled face gallery",
33893
+ options: [
33894
+ {
33895
+ id: "arcface-r100",
33896
+ label: "ArcFace (ResNet34)"
33897
+ },
33898
+ {
33899
+ id: "auraface-r100",
33900
+ label: "AuraFace R100"
33901
+ },
33902
+ {
33903
+ id: "inception-resnet-v1",
33904
+ label: "Inception ResNet V1"
33905
+ }
33906
+ ]
33907
+ }, {
33908
+ stepId: "clip-embedding",
33909
+ label: "Semantic search model",
33910
+ defaultModelId: "mobileclip-s1",
33911
+ indexName: "the object semantic-search index",
33912
+ options: [{
33913
+ id: "mobileclip-s1",
33914
+ label: "MobileCLIP S1"
33915
+ }, {
33916
+ id: "mobileclip-s2",
33917
+ label: "MobileCLIP S2"
33918
+ }]
33919
+ }].map((s) => [s.stepId, s.defaultModelId])));
33920
+ string().min(1);
33789
33921
  object({
33790
33922
  /**
33791
33923
  * Fraction of the box's own size added on EACH side before cutting.
@@ -33806,10 +33938,21 @@ object({
33806
33938
  */
33807
33939
  square: boolean()
33808
33940
  });
33809
- ({
33941
+ /**
33942
+ * The convention in force when nobody has configured one — byte-for-byte the
33943
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
33944
+ */
33945
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
33810
33946
  paddingRatio: .15,
33811
33947
  square: false
33812
- }).paddingRatio;
33948
+ };
33949
+ Object.freeze({
33950
+ x: 0,
33951
+ y: 0,
33952
+ w: 1,
33953
+ h: 1
33954
+ });
33955
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
33813
33956
  /**
33814
33957
  * WHICH delivered frames the decode worker retains a native copy of.
33815
33958
  *
@@ -34085,6 +34228,462 @@ function removeCustomModel(list, modelId) {
34085
34228
  removed: next.length !== list.length
34086
34229
  };
34087
34230
  }
34231
+ var FRIGATE_PUBLIC_CATALOG = [{
34232
+ id: "frigate-mobiledet-edgetpu",
34233
+ label: "Frigate default — SSDLite MobileDet (EdgeTPU)",
34234
+ format: "tflite",
34235
+ url: "https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess_edgetpu.tflite",
34236
+ sizeMB: 5.4,
34237
+ inputSize: 320,
34238
+ postprocessor: "ssd",
34239
+ labels: [
34240
+ "person",
34241
+ "bicycle",
34242
+ "car",
34243
+ "motorcycle",
34244
+ "airplane",
34245
+ "bus",
34246
+ "train",
34247
+ "car",
34248
+ "boat",
34249
+ "traffic light",
34250
+ "fire hydrant",
34251
+ "street sign",
34252
+ "stop sign",
34253
+ "parking meter",
34254
+ "bench",
34255
+ "bird",
34256
+ "cat",
34257
+ "dog",
34258
+ "horse",
34259
+ "sheep",
34260
+ "cow",
34261
+ "elephant",
34262
+ "bear",
34263
+ "zebra",
34264
+ "giraffe",
34265
+ "hat",
34266
+ "backpack",
34267
+ "umbrella",
34268
+ "shoe",
34269
+ "eye glasses",
34270
+ "handbag",
34271
+ "tie",
34272
+ "suitcase",
34273
+ "frisbee",
34274
+ "skis",
34275
+ "snowboard",
34276
+ "sports ball",
34277
+ "kite",
34278
+ "baseball bat",
34279
+ "baseball glove",
34280
+ "skateboard",
34281
+ "surfboard",
34282
+ "tennis racket",
34283
+ "bottle",
34284
+ "plate",
34285
+ "wine glass",
34286
+ "cup",
34287
+ "fork",
34288
+ "knife",
34289
+ "spoon",
34290
+ "bowl",
34291
+ "banana",
34292
+ "apple",
34293
+ "sandwich",
34294
+ "orange",
34295
+ "broccoli",
34296
+ "carrot",
34297
+ "hot dog",
34298
+ "pizza",
34299
+ "donut",
34300
+ "cake",
34301
+ "chair",
34302
+ "couch",
34303
+ "potted plant",
34304
+ "bed",
34305
+ "mirror",
34306
+ "dining table",
34307
+ "window",
34308
+ "desk",
34309
+ "toilet",
34310
+ "door",
34311
+ "tv",
34312
+ "laptop",
34313
+ "mouse",
34314
+ "remote",
34315
+ "keyboard",
34316
+ "cell phone",
34317
+ "microwave",
34318
+ "oven",
34319
+ "toaster",
34320
+ "sink",
34321
+ "refrigerator",
34322
+ "blender",
34323
+ "book",
34324
+ "clock",
34325
+ "vase",
34326
+ "scissors",
34327
+ "teddy bear",
34328
+ "hair drier",
34329
+ "toothbrush",
34330
+ "hair brush"
34331
+ ],
34332
+ 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."
34333
+ }];
34334
+ /**
34335
+ * Build the registrable descriptor for a curated catalog entry. The URL is
34336
+ * the PUBLIC one — any node downloads it on demand; no Distribute needed.
34337
+ */
34338
+ function buildFrigateCatalogDescriptor(catalogId, overrides) {
34339
+ const cat = FRIGATE_PUBLIC_CATALOG.find((e) => e.id === catalogId);
34340
+ if (!cat) throw new Error(`Unknown Frigate catalog id "${catalogId}" — available: ${FRIGATE_PUBLIC_CATALOG.map((e) => e.id).join(", ")}`);
34341
+ return {
34342
+ stepId: "object-detection",
34343
+ entry: {
34344
+ id: overrides?.modelId?.trim() || cat.id,
34345
+ name: overrides?.name?.trim() || cat.label,
34346
+ description: `${cat.notes} Imported from the Frigate public catalog via Model Studio.`,
34347
+ formats: { [cat.format]: {
34348
+ url: cat.url,
34349
+ sizeMB: cat.sizeMB,
34350
+ runtimes: ["python"]
34351
+ } },
34352
+ inputSize: {
34353
+ width: cat.inputSize,
34354
+ height: cat.inputSize
34355
+ },
34356
+ labels: cat.labels.map((name) => ({
34357
+ id: name,
34358
+ name
34359
+ })),
34360
+ preprocessMode: "letterbox",
34361
+ ...cat.postprocessor !== void 0 ? { postprocessor: cat.postprocessor } : {}
34362
+ }
34363
+ };
34364
+ }
34365
+ //#endregion
34366
+ //#region src/frigate/plus-conversion.ts
34367
+ /** Converted formats ADD to the imported entry; identity fields (decode,
34368
+ * labels, description, preprocess) stay the import's. */
34369
+ function mergeConvertedFormats(base, converted) {
34370
+ return {
34371
+ ...base,
34372
+ formats: {
34373
+ ...base.formats,
34374
+ ...converted.formats
34375
+ }
34376
+ };
34377
+ }
34378
+ function targetOf(format) {
34379
+ return format === "openvino" ? {
34380
+ format,
34381
+ precisions: ["fp16"]
34382
+ } : { format };
34383
+ }
34384
+ /**
34385
+ * Convert the imported ONNX for each requested engine, one target at a time,
34386
+ * merging every success into the registered entry. Returns one outcome per
34387
+ * requested format — the caller surfaces them verbatim.
34388
+ */
34389
+ async function runPlusConversions(deps, args) {
34390
+ const outcomes = [];
34391
+ let current = args.entry;
34392
+ for (const format of args.targetFormats) {
34393
+ const targets = [targetOf(format)];
34394
+ try {
34395
+ const ranked = await deps.recommend({ targets });
34396
+ const best = ranked.find((r) => r.supported);
34397
+ if (!best) {
34398
+ const reason = ranked[0]?.reason ?? "no node can satisfy this target";
34399
+ outcomes.push({
34400
+ format,
34401
+ ok: false,
34402
+ error: reason
34403
+ });
34404
+ continue;
34405
+ }
34406
+ const sourceUrl = await deps.freshSourceUrl();
34407
+ const result = await deps.convert({
34408
+ sourceUrl,
34409
+ metadata: {
34410
+ id: current.id,
34411
+ name: current.name,
34412
+ labels: current.labels,
34413
+ inputSize: current.inputSize,
34414
+ preprocessMode: current.preprocessMode ?? "letterbox",
34415
+ outputFormat: "yolo"
34416
+ },
34417
+ targets
34418
+ }, best.nodeId);
34419
+ if (result.artifacts.length === 0) {
34420
+ outcomes.push({
34421
+ format,
34422
+ ok: false,
34423
+ nodeId: best.nodeId,
34424
+ error: "no validated artifacts"
34425
+ });
34426
+ continue;
34427
+ }
34428
+ current = mergeConvertedFormats(current, result.entry);
34429
+ await deps.upsertEntry(current);
34430
+ await deps.markAvailable(best.nodeId, current.id, result.artifacts);
34431
+ outcomes.push({
34432
+ format,
34433
+ ok: true,
34434
+ nodeId: best.nodeId
34435
+ });
34436
+ } catch (err) {
34437
+ outcomes.push({
34438
+ format,
34439
+ ok: false,
34440
+ error: err instanceof Error ? err.message : "conversion failed"
34441
+ });
34442
+ }
34443
+ }
34444
+ return outcomes;
34445
+ }
34446
+ //#endregion
34447
+ //#region src/frigate/frigate-plus.ts
34448
+ var PLUS_API_HOST = "https://api.frigate.video";
34449
+ 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}$/;
34450
+ function isValidPlusApiKey(key) {
34451
+ return PLUS_KEY_RE.test(key);
34452
+ }
34453
+ function isRecord(value) {
34454
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34455
+ }
34456
+ /** Exchange the API key for an access token. The key never appears in errors. */
34457
+ async function fetchPlusAccessToken(fetchFn, apiKey) {
34458
+ 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");
34459
+ const [user, pass] = apiKey.split(":");
34460
+ const basic = Buffer.from(`${user}:${pass}`).toString("base64");
34461
+ const res = await fetchFn(`${PLUS_API_HOST}/v1/auth/token`, { headers: { authorization: `Basic ${basic}` } });
34462
+ if (!res.ok) throw new Error(`Frigate+ authentication failed (HTTP ${res.status}) — check the API key in the Model Studio addon settings`);
34463
+ const body = await res.json();
34464
+ const token = isRecord(body) && typeof body.accessToken === "string" ? body.accessToken : null;
34465
+ if (token === null) throw new Error("Frigate+ auth response carried no accessToken");
34466
+ return token;
34467
+ }
34468
+ async function plusGet(fetchFn, token, path) {
34469
+ const res = await fetchFn(`${PLUS_API_HOST}/v1/${path}`, { headers: { authorization: `Bearer ${token}` } });
34470
+ if (!res.ok) throw new Error(`Frigate+ request "${path}" failed (HTTP ${res.status}): ${await res.text()}`);
34471
+ return res.json();
34472
+ }
34473
+ /**
34474
+ * `verifiedCounts` has THREE real shapes and none may break the parse:
34475
+ * -1 → base model ("not applicable") ⇒ 0
34476
+ * { "11": 199 } → legacy custom (flat label→count)
34477
+ * { camera: { label: n, … }, … } → recent custom (per camera)
34478
+ */
34479
+ function verifiedImageCount(raw) {
34480
+ if (typeof raw === "number") return raw > 0 ? raw : 0;
34481
+ if (!isRecord(raw)) return 0;
34482
+ let total = 0;
34483
+ for (const value of Object.values(raw)) if (typeof value === "number") total += value > 0 ? value : 0;
34484
+ else if (isRecord(value)) {
34485
+ for (const inner of Object.values(value)) if (typeof inner === "number" && inner > 0) total += inner;
34486
+ }
34487
+ return total;
34488
+ }
34489
+ function toModelSummary(m) {
34490
+ if (typeof m.id !== "string") return null;
34491
+ return {
34492
+ id: m.id,
34493
+ name: typeof m.name === "string" ? m.name : m.id,
34494
+ type: typeof m.type === "string" ? m.type : "unknown",
34495
+ isBaseModel: m.isBaseModel === true,
34496
+ baseModel: typeof m.baseModel === "string" ? m.baseModel : "",
34497
+ supportedDetectors: Array.isArray(m.supportedDetectors) ? m.supportedDetectors.filter((d) => typeof d === "string") : [],
34498
+ trainDate: typeof m.trainDate === "string" ? m.trainDate : "",
34499
+ ...typeof m.hailoDevice === "string" ? { hailoDevice: m.hailoDevice } : {},
34500
+ verifiedImages: verifiedImageCount(m.verifiedCounts),
34501
+ ...typeof m.width === "number" ? { width: m.width } : {},
34502
+ ...typeof m.height === "number" ? { height: m.height } : {}
34503
+ };
34504
+ }
34505
+ /** Parse a raw `/v1/model/list` body (`{list:[…]}`, or a bare array). */
34506
+ function toPlusModelSummaries(raw) {
34507
+ return (Array.isArray(raw) ? raw : isRecord(raw) && Array.isArray(raw.list) ? raw.list : []).filter(isRecord).map(toModelSummary).filter((m) => m !== null);
34508
+ }
34509
+ /**
34510
+ * EVERYTHING the account has access to — base AND custom models. The real
34511
+ * response wraps the array: `{ "list": [...] }` (a bare array is tolerated
34512
+ * for forward compatibility).
34513
+ */
34514
+ async function listPlusModels(fetchFn, apiKey) {
34515
+ return toPlusModelSummaries(await plusGet(fetchFn, await fetchPlusAccessToken(fetchFn, apiKey), "model/list"));
34516
+ }
34517
+ function toModelInfo(raw, modelId) {
34518
+ if (!isRecord(raw)) throw new Error("Frigate+ model info: unexpected response shape");
34519
+ const labelMap = {};
34520
+ if (isRecord(raw.labelMap)) {
34521
+ for (const [k, v] of Object.entries(raw.labelMap)) if (typeof v === "string") labelMap[k] = v;
34522
+ }
34523
+ return {
34524
+ id: typeof raw.id === "string" ? raw.id : modelId,
34525
+ type: typeof raw.type === "string" ? raw.type : "unknown",
34526
+ width: typeof raw.width === "number" ? raw.width : 320,
34527
+ height: typeof raw.height === "number" ? raw.height : 320,
34528
+ labelMap
34529
+ };
34530
+ }
34531
+ /** Resolve one model's metadata + presigned artifact URL. */
34532
+ async function requestPlusModelDownload(fetchFn, apiKey, plusModelId) {
34533
+ const token = await fetchPlusAccessToken(fetchFn, apiKey);
34534
+ const info = toModelInfo(await plusGet(fetchFn, token, `model/${plusModelId}`), plusModelId);
34535
+ const signed = await plusGet(fetchFn, token, `model/${plusModelId}/signed_url`);
34536
+ const url = isRecord(signed) && typeof signed.url === "string" ? signed.url : null;
34537
+ if (url === null) throw new Error("Frigate+ signed_url response carried no url");
34538
+ return {
34539
+ info,
34540
+ downloadUrl: url
34541
+ };
34542
+ }
34543
+ /** Frigate+ model type → our format + decode. Mirrors the Frigate presets. */
34544
+ var PLUS_TYPE_MAP = {
34545
+ mobiledet: {
34546
+ format: "tflite",
34547
+ postprocessor: "ssd",
34548
+ extension: ".tflite"
34549
+ },
34550
+ yolonas: {
34551
+ format: "onnx",
34552
+ postprocessor: "yolonas",
34553
+ extension: ".onnx"
34554
+ },
34555
+ yolov9: {
34556
+ format: "onnx",
34557
+ extension: ".onnx"
34558
+ }
34559
+ };
34560
+ function plusTypeTraits(type) {
34561
+ return PLUS_TYPE_MAP[type] ?? {
34562
+ format: "onnx",
34563
+ extension: ".onnx"
34564
+ };
34565
+ }
34566
+ /** Ordered labels from the API's `index → label` map; holes keep alignment. */
34567
+ function orderedLabels(labelMap) {
34568
+ const indices = Object.keys(labelMap).map((k) => Number.parseInt(k, 10)).filter((n) => Number.isInteger(n) && n >= 0);
34569
+ if (indices.length === 0) return [];
34570
+ const max = Math.max(...indices);
34571
+ const out = [];
34572
+ for (let i = 0; i <= max; i++) out.push(labelMap[String(i)] ?? `unknown-${i}`);
34573
+ return out;
34574
+ }
34575
+ /**
34576
+ * Build the registrable descriptor for a downloaded Frigate+ model. The
34577
+ * artifact is hub-local (landed by the server-side download), so the URL uses
34578
+ * the `camstack-local://` convention (D208) — other nodes get it via
34579
+ * Distribute.
34580
+ */
34581
+ function plusInfoToDescriptor(info, opts) {
34582
+ const traits = plusTypeTraits(info.type);
34583
+ const labels = orderedLabels(info.labelMap);
34584
+ return {
34585
+ stepId: "object-detection",
34586
+ entry: {
34587
+ id: opts.modelId,
34588
+ name: opts.name,
34589
+ description: `Frigate+ personal model (${info.type}) — imported via Model Studio`,
34590
+ formats: { [traits.format]: {
34591
+ url: `camstack-local://${opts.modelId}/${opts.filename}`,
34592
+ sizeMB: opts.sizeMB,
34593
+ runtimes: ["python"]
34594
+ } },
34595
+ inputSize: {
34596
+ width: info.width,
34597
+ height: info.height
34598
+ },
34599
+ labels: labels.map((name) => ({
34600
+ id: name,
34601
+ name
34602
+ })),
34603
+ preprocessMode: "letterbox",
34604
+ ...traits.postprocessor !== void 0 ? { postprocessor: traits.postprocessor } : {}
34605
+ }
34606
+ };
34607
+ }
34608
+ var MODEL_UPLOAD_ALLOWED_EXTENSIONS = [".onnx", ".tflite"];
34609
+ var FILENAME_RE = /^[a-zA-Z0-9._-]+$/;
34610
+ /**
34611
+ * Validate an upload filename: plain basename (no separators, no traversal),
34612
+ * conservative charset, allowlisted model extension. Returns it unchanged so
34613
+ * call sites read as an assertion. Throws with an actionable message.
34614
+ */
34615
+ function sanitizeModelFilename(filename) {
34616
+ 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`);
34617
+ 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(", ")}`);
34618
+ return filename;
34619
+ }
34620
+ var ModelUploadStore = class {
34621
+ uploads = /* @__PURE__ */ new Map();
34622
+ opts;
34623
+ maxBytes;
34624
+ constructor(opts) {
34625
+ this.opts = opts;
34626
+ this.maxBytes = opts.maxBytes ?? 536870912;
34627
+ }
34628
+ begin(input) {
34629
+ const filename = sanitizeModelFilename(input.filename);
34630
+ if (!Number.isFinite(input.totalBytes) || input.totalBytes <= 0) throw new Error("totalBytes must be a positive number");
34631
+ if (input.totalBytes > this.maxBytes) throw new Error(`Upload too large: ${input.totalBytes} bytes exceeds the ${this.maxBytes}-byte cap`);
34632
+ node_fs.mkdirSync(this.opts.stagingDir, { recursive: true });
34633
+ const uploadId = (0, node_crypto.randomUUID)();
34634
+ const stagingPath = node_path.join(this.opts.stagingDir, `${uploadId}-${filename}`);
34635
+ node_fs.writeFileSync(stagingPath, Buffer.alloc(0));
34636
+ this.uploads.set(uploadId, {
34637
+ filename,
34638
+ totalBytes: input.totalBytes,
34639
+ stagingPath,
34640
+ hash: (0, node_crypto.createHash)("sha256"),
34641
+ received: 0
34642
+ });
34643
+ return { uploadId };
34644
+ }
34645
+ append(input) {
34646
+ const upload = this.mustGet(input.uploadId);
34647
+ const chunk = Buffer.from(input.chunkBase64, "base64");
34648
+ if (chunk.length === 0) throw new Error("Empty chunk");
34649
+ if (upload.received + chunk.length > upload.totalBytes) {
34650
+ this.abort({ uploadId: input.uploadId });
34651
+ throw new Error(`Chunk exceeds the declared totalBytes (${upload.totalBytes}) — upload aborted`);
34652
+ }
34653
+ node_fs.appendFileSync(upload.stagingPath, chunk);
34654
+ upload.hash.update(chunk);
34655
+ upload.received += chunk.length;
34656
+ return { received: upload.received };
34657
+ }
34658
+ commit(input) {
34659
+ const upload = this.mustGet(input.uploadId);
34660
+ if (upload.received !== upload.totalBytes) throw new Error(`Upload incomplete: received ${upload.received} of ${upload.totalBytes} bytes`);
34661
+ node_fs.mkdirSync(this.opts.modelsDir, { recursive: true });
34662
+ const destPath = node_path.join(this.opts.modelsDir, upload.filename);
34663
+ node_fs.renameSync(upload.stagingPath, destPath);
34664
+ this.uploads.delete(input.uploadId);
34665
+ return {
34666
+ filename: upload.filename,
34667
+ bytes: upload.received,
34668
+ sha256: upload.hash.digest("hex")
34669
+ };
34670
+ }
34671
+ abort(input) {
34672
+ const upload = this.uploads.get(input.uploadId);
34673
+ if (upload) {
34674
+ try {
34675
+ node_fs.rmSync(upload.stagingPath, { force: true });
34676
+ } catch {}
34677
+ this.uploads.delete(input.uploadId);
34678
+ }
34679
+ return { ok: true };
34680
+ }
34681
+ mustGet(uploadId) {
34682
+ const upload = this.uploads.get(uploadId);
34683
+ if (!upload) throw new Error(`Unknown uploadId "${uploadId}" — begin a new upload`);
34684
+ return upload;
34685
+ }
34686
+ };
34088
34687
  //#endregion
34089
34688
  //#region src/model-studio-actions.ts
34090
34689
  /**
@@ -34125,6 +34724,100 @@ var modelStudioActions = defineCustomActions({
34125
34724
  }), { kind: "mutation" }),
34126
34725
  listAvailability: customAction(_void(), ModelAvailabilityMapSchema),
34127
34726
  /**
34727
+ * Chunked model-file upload (Frigate import). One tRPC mutation cannot
34728
+ * carry a model file (hub HTTP body limit ~1 MB), so the page streams
34729
+ * base64 chunks: begin → append× → commit. Commit lands the artifact in the
34730
+ * hub's shared modelsDir, where the `camstack-local://` catalog-URL
34731
+ * convention resolves it by basename and `model-distributor` can ship it to
34732
+ * agents. Abort (or an oversize chunk) discards the staging file.
34733
+ */
34734
+ beginModelUpload: customAction(object({
34735
+ filename: string(),
34736
+ totalBytes: number().int().positive()
34737
+ }), object({ uploadId: string() }), { kind: "mutation" }),
34738
+ appendModelUpload: customAction(object({
34739
+ uploadId: string(),
34740
+ chunkBase64: string()
34741
+ }), object({ received: number() }), { kind: "mutation" }),
34742
+ commitModelUpload: customAction(object({ uploadId: string() }), object({
34743
+ filename: string(),
34744
+ bytes: number(),
34745
+ sha256: string()
34746
+ }), { kind: "mutation" }),
34747
+ abortModelUpload: customAction(object({ uploadId: string() }), object({ ok: literal(true) }), { kind: "mutation" }),
34748
+ /**
34749
+ * Curated PUBLIC Frigate model catalog (D214 addendum) — models Frigate
34750
+ * uses/references that are downloadable without credentials, with verified
34751
+ * metadata so the operator types nothing. Import downloads SERVER-SIDE into
34752
+ * the hub's modelsDir and registers the descriptor with the public URL
34753
+ * (other nodes download on demand — no Distribute needed).
34754
+ */
34755
+ listFrigateCatalog: customAction(_void(), array(object({
34756
+ id: string(),
34757
+ label: string(),
34758
+ format: _enum(MODEL_FORMATS),
34759
+ sizeMB: number(),
34760
+ inputSize: number(),
34761
+ notes: string()
34762
+ })).readonly()),
34763
+ importFrigateCatalogModel: customAction(object({
34764
+ catalogId: string(),
34765
+ modelId: string().optional(),
34766
+ name: string().optional()
34767
+ }), object({
34768
+ ok: literal(true),
34769
+ modelId: string(),
34770
+ bytes: number()
34771
+ }), { kind: "mutation" }),
34772
+ /**
34773
+ * Frigate+ personal models. The API key lives in the addon's SETTINGS
34774
+ * (password field) and never leaves the hub: list / test / import all run
34775
+ * server-side against api.frigate.video. Status deliberately reveals only
34776
+ * booleans — never the key.
34777
+ */
34778
+ /**
34779
+ * Nodes whose engine can actually LOAD a tflite artifact (the pool's only
34780
+ * tflite runtime binds the Coral EdgeTPU delegate — no Coral, no tflite).
34781
+ * The UI gates tflite imports on this instead of letting them fail at load.
34782
+ */
34783
+ tfliteSupport: customAction(_void(), object({ nodes: array(string()).readonly() })),
34784
+ frigatePlusStatus: customAction(_void(), object({
34785
+ configured: boolean(),
34786
+ keyFormatValid: boolean()
34787
+ })),
34788
+ frigatePlusTest: customAction(_void(), object({
34789
+ ok: boolean(),
34790
+ error: string().optional()
34791
+ }), { kind: "mutation" }),
34792
+ frigatePlusListModels: customAction(_void(), array(object({
34793
+ id: string(),
34794
+ name: string(),
34795
+ type: string(),
34796
+ isBaseModel: boolean(),
34797
+ baseModel: string(),
34798
+ supportedDetectors: array(string()).readonly(),
34799
+ trainDate: string(),
34800
+ hailoDevice: string().optional(),
34801
+ verifiedImages: number(),
34802
+ width: number().optional(),
34803
+ height: number().optional()
34804
+ })).readonly()),
34805
+ importFrigatePlusModel: customAction(object({
34806
+ plusModelId: string(),
34807
+ modelId: string(),
34808
+ name: string().optional()
34809
+ }), object({
34810
+ ok: literal(true),
34811
+ modelId: string(),
34812
+ bytes: number(),
34813
+ conversions: array(object({
34814
+ format: _enum(["openvino", "coreml"]),
34815
+ ok: boolean(),
34816
+ nodeId: string().optional(),
34817
+ error: string().optional()
34818
+ })).readonly()
34819
+ }), { kind: "mutation" }),
34820
+ /**
34128
34821
  * Rank nodes for model conversion based on hardware capabilities.
34129
34822
  * CoreML targets require darwin; OpenVINO targets require an OpenVINO-capable node.
34130
34823
  */
@@ -34207,21 +34900,14 @@ function spawnRunConvert(jobPath, pythonPath, onProgress, _sessionId) {
34207
34900
  });
34208
34901
  });
34209
34902
  }
34210
- /**
34211
- * Model Studio provides the `custom-model-registry` collection cap (hub only)
34212
- * and the `model-convert` singleton cap (any node).
34213
- *
34214
- * P1 maintained the durable list of custom detection models and exposed them
34215
- * so the detection pipeline can offer them in its model picker.
34216
- *
34217
- * P3 adds the `model-convert` provider on EVERY node so agents can run
34218
- * local Python conversion without routing through the hub.
34219
- * The `custom-model-registry` cap and all customActions are hub-only.
34220
- */
34221
- var ModelStudioAddon = class extends BaseAddon {
34903
+ /** Global-fetch adapter for the injected Frigate+ client seam. */
34904
+ var plusFetch = async (url, init) => fetch(url, init);
34905
+ var ModelStudioAddon = class ModelStudioAddon extends BaseAddon {
34222
34906
  id = "model-studio";
34223
34907
  modelsState = null;
34224
34908
  availabilityState = null;
34909
+ /** Lazily-built chunked-upload store (hub only) — see `upload/model-upload-store.ts`. */
34910
+ uploadStore = null;
34225
34911
  /**
34226
34912
  * Module-Federation page declaration surfaced via the `addon-pages-source`
34227
34913
  * collection cap. Mirrors the manifest `pages[]` entry — admin-ui's
@@ -34238,7 +34924,21 @@ var ModelStudioAddon = class extends BaseAddon {
34238
34924
  section: "detection"
34239
34925
  }];
34240
34926
  constructor() {
34241
- super({});
34927
+ super({ frigatePlusApiKey: "" });
34928
+ }
34929
+ globalSettingsSchema() {
34930
+ return this.schema({ sections: [{
34931
+ id: "frigate-plus",
34932
+ title: "Frigate+",
34933
+ 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.",
34934
+ fields: [{
34935
+ type: "password",
34936
+ key: "frigatePlusApiKey",
34937
+ label: "Frigate+ API key",
34938
+ description: "Shape: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:40-hex-chars",
34939
+ showToggle: true
34940
+ }]
34941
+ }] });
34242
34942
  }
34243
34943
  makeConvertDeps() {
34244
34944
  return {
@@ -34308,11 +35008,62 @@ var ModelStudioAddon = class extends BaseAddon {
34308
35008
  providers,
34309
35009
  customActions: isHub ? modelStudioActions : void 0,
34310
35010
  actionHandlers: isHub ? {
35011
+ beginModelUpload: async (input) => (await this.getUploadStore()).begin(input),
35012
+ appendModelUpload: async (input) => (await this.getUploadStore()).append(input),
35013
+ commitModelUpload: async (input) => {
35014
+ const result = (await this.getUploadStore()).commit(input);
35015
+ this.ctx.logger.info("Model upload landed in modelsDir", { meta: {
35016
+ filename: result.filename,
35017
+ bytes: result.bytes,
35018
+ sha256: result.sha256
35019
+ } });
35020
+ return result;
35021
+ },
35022
+ abortModelUpload: async (input) => (await this.getUploadStore()).abort(input),
34311
35023
  registerCustomModel: async (input) => this.registerCustomModel(input),
34312
35024
  removeCustomModel: async (input) => this.removeCustomModel(input),
34313
35025
  listCustomModels: async () => this.listCustomModels(),
34314
35026
  distributeModel: async (input) => this.distributeModel(input),
34315
35027
  listAvailability: async () => this.availability_.get(),
35028
+ listFrigateCatalog: async () => FRIGATE_PUBLIC_CATALOG.map((c) => ({
35029
+ id: c.id,
35030
+ label: c.label,
35031
+ format: c.format,
35032
+ sizeMB: c.sizeMB,
35033
+ inputSize: c.inputSize,
35034
+ notes: c.notes
35035
+ })),
35036
+ importFrigateCatalogModel: async (input) => this.importFrigateCatalogModel(input),
35037
+ tfliteSupport: async () => {
35038
+ const api = this.ctx.api;
35039
+ if (!api) return { nodes: [] };
35040
+ const nodes = await this.recommendDeps().listNodes();
35041
+ const supported = [];
35042
+ for (const nId of nodes) try {
35043
+ if ((await api.platformProbe.getCapabilities.query({ nodeId: nId })).scores.some((sc) => sc.format === "tflite" && sc.available)) supported.push(nId);
35044
+ } catch {}
35045
+ return { nodes: supported };
35046
+ },
35047
+ frigatePlusStatus: async () => {
35048
+ const key = this.config.frigatePlusApiKey ?? "";
35049
+ return {
35050
+ configured: key.length > 0,
35051
+ keyFormatValid: isValidPlusApiKey(key)
35052
+ };
35053
+ },
35054
+ frigatePlusTest: async () => {
35055
+ try {
35056
+ await fetchPlusAccessToken(plusFetch, this.config.frigatePlusApiKey ?? "");
35057
+ return { ok: true };
35058
+ } catch (err) {
35059
+ return {
35060
+ ok: false,
35061
+ error: err instanceof Error ? err.message : "auth failed"
35062
+ };
35063
+ }
35064
+ },
35065
+ frigatePlusListModels: async () => listPlusModels(plusFetch, this.config.frigatePlusApiKey ?? ""),
35066
+ importFrigatePlusModel: async (input) => this.importFrigatePlusModel(input),
34316
35067
  recommendConvertNode: async (input) => {
34317
35068
  const api = this.ctx.api;
34318
35069
  if (!api) throw new Error("recommendConvertNode: ctx.api unavailable");
@@ -34389,6 +35140,22 @@ var ModelStudioAddon = class extends BaseAddon {
34389
35140
  } : void 0
34390
35141
  };
34391
35142
  }
35143
+ /**
35144
+ * Build (once) the upload store, anchored at this node's writable data root:
35145
+ * staging under `<dataDir>/model-uploads`, commits into the SHARED
35146
+ * `<nodeDataDir>/models` — the same directory every inference addon loads
35147
+ * from, which is what makes the `camstack-local://` URL convention resolve.
35148
+ */
35149
+ async getUploadStore() {
35150
+ if (this.uploadStore) return this.uploadStore;
35151
+ const modelsDir = await this.resolveModelsDir();
35152
+ const stagingDir = node_path.default.join(this.ctx.dataDir ?? node_os.default.tmpdir(), "model-uploads");
35153
+ this.uploadStore = new ModelUploadStore({
35154
+ stagingDir,
35155
+ modelsDir
35156
+ });
35157
+ return this.uploadStore;
35158
+ }
34392
35159
  get state_() {
34393
35160
  if (!this.modelsState) throw new Error("ModelStudioAddon: durable state not initialized");
34394
35161
  return this.modelsState;
@@ -34426,6 +35193,170 @@ var ModelStudioAddon = class extends BaseAddon {
34426
35193
  bytes: res.bytes
34427
35194
  };
34428
35195
  }
35196
+ /** listNodes/hardwareOf deps for `recommendConvertNode` — shared by the
35197
+ * convert actions and the Frigate+ auto-conversion. */
35198
+ recommendDeps() {
35199
+ const api = this.ctx.api;
35200
+ if (!api) throw new Error("model-studio: ctx.api unavailable");
35201
+ return {
35202
+ listNodes: async () => {
35203
+ return (await api.addons.listCapabilityProviders.query({ capName: "model-convert" })).map((p) => {
35204
+ const atIdx = p.addonId.indexOf("@");
35205
+ return atIdx !== -1 ? p.addonId.slice(atIdx + 1) : "hub";
35206
+ });
35207
+ },
35208
+ hardwareOf: async (nId) => {
35209
+ const hw = await api.platformProbe.getHardware.query({ nodeId: nId });
35210
+ const caps = await api.platformProbe.getCapabilities.query({ nodeId: nId });
35211
+ return {
35212
+ platform: hw.platform,
35213
+ gpu: hw.gpu ?? void 0,
35214
+ npu: hw.npu ?? void 0,
35215
+ openvino: caps.scores.some((s) => s.format === "openvino" && s.available)
35216
+ };
35217
+ }
35218
+ };
35219
+ }
35220
+ /** sha256 of a file on disk (streamed). */
35221
+ static async fileSha256(filePath) {
35222
+ const hash = node_crypto.default.createHash("sha256");
35223
+ await new Promise((resolve, reject) => {
35224
+ node_fs.default.createReadStream(filePath).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve()).on("error", reject);
35225
+ });
35226
+ return hash.digest("hex");
35227
+ }
35228
+ /**
35229
+ * Import a curated PUBLIC Frigate catalog model: download SERVER-SIDE into
35230
+ * the hub's modelsDir (size-verified against the curated declaration),
35231
+ * register the descriptor with the PUBLIC url (nodes download on demand),
35232
+ * and record hub availability.
35233
+ */
35234
+ async importFrigateCatalogModel(input) {
35235
+ const cat = FRIGATE_PUBLIC_CATALOG.find((c) => c.id === input.catalogId);
35236
+ if (!cat) throw new Error(`importFrigateCatalogModel: unknown catalog id "${input.catalogId}"`);
35237
+ const descriptor = buildFrigateCatalogDescriptor(input.catalogId, {
35238
+ ...input.modelId !== void 0 ? { modelId: input.modelId } : {},
35239
+ ...input.name !== void 0 ? { name: input.name } : {}
35240
+ });
35241
+ const modelsDir = await this.resolveModelsDir();
35242
+ const filename = cat.url.split("/").at(-1) ?? `${descriptor.entry.id}.${cat.format}`;
35243
+ const destPath = node_path.default.join(modelsDir, filename);
35244
+ await downloadFile(cat.url, destPath);
35245
+ const bytes = (await node_fs.default.promises.stat(destPath)).size;
35246
+ const declaredBytes = cat.sizeMB * 1e6;
35247
+ if (bytes < declaredBytes * .5 || bytes > declaredBytes * 2) {
35248
+ await node_fs.default.promises.rm(destPath, { force: true });
35249
+ throw new Error(`importFrigateCatalogModel: downloaded ${bytes} bytes but the catalog declares ~${Math.round(declaredBytes)} — refusing to register a suspicious artifact`);
35250
+ }
35251
+ const sha256 = await ModelStudioAddon.fileSha256(destPath);
35252
+ await this.registerCustomModel(descriptor);
35253
+ await this.availability_.update((prev) => upsertAvailability(prev, descriptor.entry.id, "hub", {
35254
+ format: cat.format,
35255
+ sha256,
35256
+ bytes,
35257
+ at: Date.now()
35258
+ }));
35259
+ this.ctx.logger.info("Imported Frigate catalog model", { meta: {
35260
+ catalogId: input.catalogId,
35261
+ modelId: descriptor.entry.id,
35262
+ bytes,
35263
+ sha256
35264
+ } });
35265
+ return {
35266
+ ok: true,
35267
+ modelId: descriptor.entry.id,
35268
+ bytes
35269
+ };
35270
+ }
35271
+ /**
35272
+ * Import one of the operator's personal Frigate+ models: resolve metadata +
35273
+ * presigned URL with the SECRET key (server-side only), download into the
35274
+ * hub's modelsDir, and register with the model-type-derived characteristics
35275
+ * (format, decode, input size, labelmap — nothing hand-typed). The artifact
35276
+ * is hub-local: other nodes get it via Models → Distribute.
35277
+ */
35278
+ async importFrigatePlusModel(input) {
35279
+ if (!/^[a-zA-Z0-9._-]+$/.test(input.modelId)) throw new Error(`importFrigatePlusModel: model id "${input.modelId}" is invalid`);
35280
+ const key = this.config.frigatePlusApiKey ?? "";
35281
+ const { info, downloadUrl } = await requestPlusModelDownload(plusFetch, key, input.plusModelId);
35282
+ const traits = plusTypeTraits(info.type);
35283
+ const filename = `${input.modelId}${traits.extension}`;
35284
+ const modelsDir = await this.resolveModelsDir();
35285
+ const destPath = node_path.default.join(modelsDir, filename);
35286
+ await downloadFile(downloadUrl, destPath);
35287
+ const bytes = (await node_fs.default.promises.stat(destPath)).size;
35288
+ if (bytes < 1e4) {
35289
+ await node_fs.default.promises.rm(destPath, { force: true });
35290
+ throw new Error(`importFrigatePlusModel: downloaded only ${bytes} bytes — the signed URL likely expired or the response was an error page`);
35291
+ }
35292
+ const sha256 = await ModelStudioAddon.fileSha256(destPath);
35293
+ const descriptor = plusInfoToDescriptor(info, {
35294
+ modelId: input.modelId,
35295
+ name: input.name?.trim() || `Frigate+ ${info.type} (${input.plusModelId.slice(0, 8)})`,
35296
+ filename,
35297
+ sizeMB: Number((bytes / 1e6).toFixed(1))
35298
+ });
35299
+ await this.registerCustomModel(descriptor);
35300
+ await this.availability_.update((prev) => upsertAvailability(prev, input.modelId, "hub", {
35301
+ format: traits.format,
35302
+ sha256,
35303
+ bytes,
35304
+ at: Date.now()
35305
+ }));
35306
+ this.ctx.logger.info("Imported Frigate+ model", { meta: {
35307
+ plusModelId: input.plusModelId,
35308
+ modelId: input.modelId,
35309
+ type: info.type,
35310
+ bytes
35311
+ } });
35312
+ let conversions = [];
35313
+ if (traits.format === "onnx") {
35314
+ const api = this.ctx.api;
35315
+ if (api) {
35316
+ conversions = await runPlusConversions({
35317
+ recommend: (i) => recommendConvertNode(i, this.recommendDeps()),
35318
+ convert: (convertInput, nId) => api.modelConvert.convert.mutate(convertInput, nodePin(nId)),
35319
+ freshSourceUrl: async () => (await requestPlusModelDownload(plusFetch, key, input.plusModelId)).downloadUrl,
35320
+ upsertEntry: async (entry) => {
35321
+ await this.state_.update((prev) => upsertCustomModel(prev, {
35322
+ stepId: descriptor.stepId,
35323
+ entry
35324
+ }));
35325
+ },
35326
+ markAvailable: async (nId, mId, artifacts) => {
35327
+ await this.availability_.update((prev) => {
35328
+ const seen = /* @__PURE__ */ new Set();
35329
+ let next = prev;
35330
+ for (const artifact of artifacts) {
35331
+ if (seen.has(artifact.format)) continue;
35332
+ seen.add(artifact.format);
35333
+ next = upsertAvailability(next, mId, nId, {
35334
+ format: artifact.format,
35335
+ sha256: "",
35336
+ bytes: Math.round(artifact.sizeMB * 1e6),
35337
+ at: Date.now()
35338
+ });
35339
+ }
35340
+ return next;
35341
+ });
35342
+ }
35343
+ }, {
35344
+ entry: descriptor.entry,
35345
+ targetFormats: ["openvino", "coreml"]
35346
+ });
35347
+ this.ctx.logger.info("Frigate+ import — engine conversions", { meta: {
35348
+ modelId: input.modelId,
35349
+ outcomes: conversions.map((o) => ({ ...o }))
35350
+ } });
35351
+ }
35352
+ }
35353
+ return {
35354
+ ok: true,
35355
+ modelId: input.modelId,
35356
+ bytes,
35357
+ conversions
35358
+ };
35359
+ }
34429
35360
  async registerCustomModel(input) {
34430
35361
  if (!input.stepId.trim()) throw new Error("registerCustomModel: stepId is required");
34431
35362
  if (!input.entry.id.trim()) throw new Error("registerCustomModel: entry.id is required");