@camstack/addon-post-analysis 1.2.104 → 1.2.107

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.
@@ -7821,7 +7821,7 @@ var RecordingBandSchema = object({
7821
7821
  * visit is derived, not a second copy of the files.
7822
7822
  */
7823
7823
  var VISIT_MERGE_GAP_MS = {
7824
- preBufferSec: 10,
7824
+ preBufferSec: 15,
7825
7825
  postBufferSec: 30
7826
7826
  }.postBufferSec * 1e3;
7827
7827
  /**
@@ -17239,7 +17239,7 @@ var TrackEnvelopeSchema = object({
17239
17239
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
17240
17240
  * keeps every scalar the list surfaces actually render (ids, class(es),
17241
17241
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
17242
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
17242
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
17243
17243
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
17244
17244
  * `getTrack`. Mirrors the event-store `projection` convention
17245
17245
  * (`getObjectEvents` et al.).
@@ -17534,6 +17534,35 @@ var TrackSchema = object({
17534
17534
  */
17535
17535
  hasFace: boolean().optional(),
17536
17536
  /**
17537
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
17538
+ * face an operator could ASSIGN to an identity.
17539
+ *
17540
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
17541
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
17542
+ * branch, before every gate, and means no more than "a face detector produced
17543
+ * a face detail". This one is stamped at the single moment the gallery row
17544
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
17545
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
17546
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
17547
+ * the crop-store drop. Everything between the detector and that insert can
17548
+ * legitimately refuse the face, so a flag written any earlier promises the
17549
+ * operator something to assign and delivers nothing.
17550
+ *
17551
+ * **Independent of recognition.** A face collected but never auto-matched is
17552
+ * still assignable — it is in fact the face an operator most wants to reach —
17553
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
17554
+ * `subLabel`; this says only that the raw material exists.
17555
+ *
17556
+ * **Set once, never cleared.** A track that produced a gallery row produced
17557
+ * one; deleting the row later is the gallery's business, not this flag's.
17558
+ *
17559
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
17560
+ * before the column omits it, and so does every server that predates the
17561
+ * field. A consumer must test `=== true` and render nothing otherwise —
17562
+ * never infer "no assignable face".
17563
+ */
17564
+ hasEmbeddedFace: boolean().optional(),
17565
+ /**
17537
17566
  * This subject CONTAINS a folded rider — a person the rider-pairing step
17538
17567
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
17539
17568
  * so the passage is tracked once and as a VEHICLE.
@@ -19330,6 +19359,26 @@ var DetailResultSchema = object({
19330
19359
  * the consuming gate treats as accept — never as reject.
19331
19360
  */
19332
19361
  embeddingMagnitude: number().optional(),
19362
+ /**
19363
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
19364
+ * `label`, and for the same reason: the producing node is the only side that
19365
+ * knows which model actually ran, and re-deriving it downstream is exactly
19366
+ * the inference this model exists to forbid.
19367
+ *
19368
+ * Additive and optional, so a runner that predates it degrades to
19369
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
19370
+ * its previous assumption instead of refusing.
19371
+ *
19372
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
19373
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
19374
+ * with nothing on the wire it took the id from the GALLERY, so the gate
19375
+ * compared the gallery against itself and could never fire. That turned the
19376
+ * one guard protecting the face feature space into a tautology, and it is
19377
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
19378
+ * gallery as the catalog comment predicts — it would have scored the new
19379
+ * model's vectors against the old model's samples in one cosine space.
19380
+ */
19381
+ embeddingModelId: string().optional(),
19333
19382
  label: string().optional(),
19334
19383
  /**
19335
19384
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -20205,6 +20254,12 @@ var CameraStatusSchema = object({
20205
20254
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
20206
20255
  fetchedAt: number()
20207
20256
  });
20257
+ var InferenceDeviceExclusionReasonSchema = _enum([
20258
+ "disabled",
20259
+ "unavailable",
20260
+ "cannot-host-camera-root",
20261
+ "accelerator-preferred"
20262
+ ]);
20208
20263
  var NodeInferenceDeviceSchema = object({
20209
20264
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
20210
20265
  key: string(),
@@ -20235,7 +20290,17 @@ var NodeInferenceDeviceSchema = object({
20235
20290
  * available per format; this is the stored selection that becomes the
20236
20291
  * default for EVERY camera landing on this accelerator.
20237
20292
  */
20238
- steps: record(string(), DeviceStepConfigSchema).optional()
20293
+ steps: record(string(), DeviceStepConfigSchema).optional(),
20294
+ /**
20295
+ * `null` when the device IS a camera-root candidate on this node; otherwise
20296
+ * the reason the dispatcher drops it. Computed by the SAME
20297
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
20298
+ * never disagree with the election — deriving it in the UI from
20299
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
20300
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
20301
+ * "an accelerator is serving" predicate).
20302
+ */
20303
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
20239
20304
  });
20240
20305
  var NodeInferenceDevicesSchema = object({
20241
20306
  nodeId: string(),
@@ -25156,7 +25221,12 @@ var ListResultSchema = object({
25156
25221
  probedAt: number()
25157
25222
  });
25158
25223
  var PreferredSchema = LocalInterfaceSchema.nullable();
25159
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
25224
+ /**
25225
+ * Candidate base URL for the SDK to race on connect. Order matters —
25226
+ * the SDK should attempt these top-to-bottom with a short per-candidate
25227
+ * timeout (e.g. 1500ms) and cache the winner for the session.
25228
+ */
25229
+ var ConnectionEndpointSchema = object({
25160
25230
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
25161
25231
  label: string(),
25162
25232
  /** Fully-formed base URL with scheme + host + port. */
@@ -25199,7 +25269,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
25199
25269
  * ordering between polls.
25200
25270
  */
25201
25271
  priority: number()
25202
- })).readonly() });
25272
+ });
25273
+ /**
25274
+ * Where the advertised local port came from. Ordered most → least
25275
+ * authoritative, and the whole point of returning it: a client must be able to
25276
+ * tell a FACT about the hub's socket from an echo of its own guess.
25277
+ */
25278
+ var LocalPortSourceEnum = _enum([
25279
+ "server-config",
25280
+ "server-env",
25281
+ "caller-hint",
25282
+ "default"
25283
+ ]);
25284
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
25285
+ var AdvertisedLocalPortSchema = object({
25286
+ port: number().int().min(1).max(65535),
25287
+ source: LocalPortSourceEnum
25288
+ });
25289
+ var GetConnectionEndpointsResultSchema = object({
25290
+ endpoints: array(ConnectionEndpointSchema).readonly(),
25291
+ /**
25292
+ * The port the hub built the LAN/loopback URLs with, and where that number
25293
+ * came from.
25294
+ *
25295
+ * Returned rather than merely applied, because "the URL is right" and "the
25296
+ * client can KNOW the URL is right" are different properties. A client that
25297
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
25298
+ * a hub that echoed the port the client sent, so it cannot decide whether to
25299
+ * race the candidate or discard it. With `source` it can: anything but
25300
+ * `caller-hint` is the hub's own socket.
25301
+ *
25302
+ * Absent on hubs predating this field — a client that finds it missing is
25303
+ * talking to an echoing hub and must degrade exactly as it does for
25304
+ * `caller-hint`.
25305
+ */
25306
+ localPort: AdvertisedLocalPortSchema
25307
+ });
25203
25308
  /**
25204
25309
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
25205
25310
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -25220,8 +25325,13 @@ var AllowedAddressesSchema = object({
25220
25325
  */
25221
25326
  addresses: array(string()).readonly() });
25222
25327
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
25223
- /** Local hub HTTP port to use in base URLs. */
25224
- port: number().int().min(1).max(65535),
25328
+ /**
25329
+ * LEGACY HINT — do not send from new code. Kept optional so clients
25330
+ * written against the echoing contract keep working; the hub uses it
25331
+ * only when it cannot read its own port, and says so via
25332
+ * `localPort.source === 'caller-hint'`.
25333
+ */
25334
+ port: number().int().min(1).max(65535).optional(),
25225
25335
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
25226
25336
  * candidate. Default `true`. */
25227
25337
  includeLoopback: boolean().optional(),
@@ -38916,6 +39026,140 @@ function readTimelapseGeneratedAt(rule, deviceId) {
38916
39026
  if (map !== void 0) return map[String(deviceId)] ?? 0;
38917
39027
  return rule.lastGeneratedAt ?? 0;
38918
39028
  }
39029
+ /**
39030
+ * A model choice has a SCOPE, and a scope change has a workflow.
39031
+ *
39032
+ * ## The two scopes
39033
+ *
39034
+ * Almost every pipeline step's model is a **node** question: which build runs
39035
+ * fastest on THIS accelerator. Two nodes may legitimately answer differently —
39036
+ * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
39037
+ * are compared against nothing. They produce boxes, and a box is a box.
39038
+ *
39039
+ * Two steps are not like that. `face-embedding` and `clip-embedding` produce
39040
+ * **vectors that land in ONE shared index** (`identity_samples`,
39041
+ * `vec_object_clip`) and are ranked against each other by cosine similarity.
39042
+ * Cosine similarity between two encoders is not a weak signal, it is a
39043
+ * meaningless number — and nothing inside a vector can tell you it happened.
39044
+ * So for those two steps the model is a **cluster** question with exactly one
39045
+ * answer, stored in exactly one row.
39046
+ *
39047
+ * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
39048
+ * for the crop convention (`detail-crop.ts`), one level up: the crop decides
39049
+ * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
39050
+ * cluster-wide for the same reason and neither may become per-node.
39051
+ *
39052
+ * ## Why the registry lives here and not in the step catalog
39053
+ *
39054
+ * The scope itself is declared on the step, in
39055
+ * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
39056
+ * — that is where a new step declares what kind of choice its model is. But the
39057
+ * catalog lives inside `addon-pipeline`, and three packages that must NOT
39058
+ * import an addon need to know the set: the orchestrator (which renders and
39059
+ * stores the row), post-analysis (which pins the row for a re-embed pass) and
39060
+ * admin-ui (which mirrors it). So the set is mirrored here, and
39061
+ * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
39062
+ * A hand-copied key that stops matching is exactly how a setting silently stops
39063
+ * arriving while both sides still look correct.
39064
+ *
39065
+ * ## The defaults ARE today's behaviour
39066
+ *
39067
+ * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
39068
+ * of both steps. Declaring the scope therefore changes nothing on the day it
39069
+ * ships: the cluster row starts out saying what every node was already doing.
39070
+ * Changing it is legitimate — that is what the knob is for — but it invalidates
39071
+ * the stored vectors of that surface, and must be followed by a re-embed pass
39072
+ * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
39073
+ */
39074
+ /**
39075
+ * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
39076
+ * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
39077
+ */
39078
+ var CLUSTER_MODEL_SCOPED_STEPS = [{
39079
+ stepId: "face-embedding",
39080
+ label: "Face recognition model",
39081
+ defaultModelId: "arcface-r100",
39082
+ indexName: "the enrolled face gallery",
39083
+ options: [
39084
+ {
39085
+ id: "arcface-r100",
39086
+ label: "ArcFace (ResNet34)"
39087
+ },
39088
+ {
39089
+ id: "auraface-r100",
39090
+ label: "AuraFace R100"
39091
+ },
39092
+ {
39093
+ id: "inception-resnet-v1",
39094
+ label: "Inception ResNet V1"
39095
+ }
39096
+ ]
39097
+ }, {
39098
+ stepId: "clip-embedding",
39099
+ label: "Semantic search model",
39100
+ defaultModelId: "mobileclip-s1",
39101
+ indexName: "the object semantic-search index",
39102
+ options: [{
39103
+ id: "mobileclip-s1",
39104
+ label: "MobileCLIP S1"
39105
+ }, {
39106
+ id: "mobileclip-s2",
39107
+ label: "MobileCLIP S2"
39108
+ }]
39109
+ }];
39110
+ /**
39111
+ * The settings key holding one step's cluster model choice.
39112
+ *
39113
+ * Step-qualified so the keys stay unique across the addon's whole schema, which
39114
+ * is what lets {@link pickClusterStepModels} walk every section instead of
39115
+ * trusting the section id.
39116
+ */
39117
+ function clusterModelSettingKey(stepId) {
39118
+ return `clusterModel:${stepId}`;
39119
+ }
39120
+ /** The cluster row when nobody has configured one — today's catalog defaults. */
39121
+ var DEFAULT_CLUSTER_STEP_MODELS = Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
39122
+ /** A stored choice is a non-empty model id, or it is not a choice. */
39123
+ var ChosenModelIdSchema = string().min(1);
39124
+ /**
39125
+ * Narrow a FLAT settings record to the cluster row.
39126
+ *
39127
+ * Per-FIELD fallback, deliberately (same rule as `readDetailCropConvention`): a
39128
+ * junk face model must not also discard a valid clip model. An absent, empty or
39129
+ * non-string value resolves to the step's catalog default — the historical
39130
+ * behaviour — never to a blank id, because a blank id downstream becomes
39131
+ * "substitute the format default", which is precisely the substitution this
39132
+ * scope exists to forbid.
39133
+ */
39134
+ function readClusterStepModels(config) {
39135
+ const out = {};
39136
+ for (const step of CLUSTER_MODEL_SCOPED_STEPS) {
39137
+ const parsed = ChosenModelIdSchema.safeParse(config[clusterModelSettingKey(step.stepId)]);
39138
+ out[step.stepId] = parsed.success ? parsed.data : step.defaultModelId;
39139
+ }
39140
+ return out;
39141
+ }
39142
+ function isHydratedField$2(entry) {
39143
+ return typeof entry === "object" && entry !== null && "key" in entry;
39144
+ }
39145
+ /**
39146
+ * Extract the cluster row from an `addon-settings.getGlobalSettings` payload.
39147
+ *
39148
+ * Walks EVERY section rather than looking inside {@link CLUSTER_MODEL_SECTION_ID}
39149
+ * alone: the keys are unique across the addon's schema, and a section rename
39150
+ * must not silently revert the whole cluster to the defaults. A `null` payload
39151
+ * (addon mid-boot) is the defaults.
39152
+ */
39153
+ function pickClusterStepModels(view) {
39154
+ if (view === null) return DEFAULT_CLUSTER_STEP_MODELS;
39155
+ const flat = {};
39156
+ const wanted = new Set(CLUSTER_MODEL_SCOPED_STEPS.map((s) => clusterModelSettingKey(s.stepId)));
39157
+ for (const section of view.sections) for (const entry of section.fields) {
39158
+ if (!isHydratedField$2(entry) || typeof entry.key !== "string") continue;
39159
+ if (wanted.has(entry.key)) flat[entry.key] = entry.value;
39160
+ }
39161
+ return readClusterStepModels(flat);
39162
+ }
38919
39163
  object({
38920
39164
  /**
38921
39165
  * Fraction of the box's own size added on EACH side before cutting.
@@ -38936,10 +39180,39 @@ object({
38936
39180
  */
38937
39181
  square: boolean()
38938
39182
  });
38939
- ({
39183
+ /**
39184
+ * The convention in force when nobody has configured one — byte-for-byte the
39185
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
39186
+ */
39187
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
38940
39188
  paddingRatio: .15,
38941
39189
  square: false
38942
- }).paddingRatio;
39190
+ };
39191
+ /**
39192
+ * The normalised box that means "the image IS the subject".
39193
+ *
39194
+ * Used when the stored pixels are ALREADY the model's input — the 112×112
39195
+ * aligned face template a gallery sample carries. There is no rectangle left to
39196
+ * choose, and the convention must not choose one: re-detecting and re-aligning
39197
+ * over a template applies a SECOND warp, passes every gate, and produces a
39198
+ * plausible useless vector (session 2026-08-20 §3).
39199
+ *
39200
+ * Sending it as the whole frame with this box is exact rather than merely
39201
+ * convenient, and the reason is a property of {@link deriveDetailCropRect}, not
39202
+ * a coincidence: padding a full-frame box can only push OUTSIDE the frame, and
39203
+ * both edge modes bring it straight back — unsquared truncates, squared cannot
39204
+ * exceed the frame's short side and then slides inside. So for a SQUARE image
39205
+ * the derivation is the identity under every convention value, and an operator
39206
+ * changing the crop margin cannot silently re-cut an enrolled template.
39207
+ * `face-reembed-pass.spec.ts` asserts that identity against the real function.
39208
+ */
39209
+ var FULL_IMAGE_BBOX = Object.freeze({
39210
+ x: 0,
39211
+ y: 0,
39212
+ w: 1,
39213
+ h: 1
39214
+ });
39215
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
38943
39216
  /**
38944
39217
  * WHICH delivered frames the decode worker retains a native copy of.
38945
39218
  *
@@ -39407,6 +39680,12 @@ Object.defineProperty(exports, "BaseDevice", {
39407
39680
  return BaseDevice;
39408
39681
  }
39409
39682
  });
39683
+ Object.defineProperty(exports, "CLUSTER_MODEL_SCOPED_STEPS", {
39684
+ enumerable: true,
39685
+ get: function() {
39686
+ return CLUSTER_MODEL_SCOPED_STEPS;
39687
+ }
39688
+ });
39410
39689
  Object.defineProperty(exports, "DEFAULT_EVENT_COLOR", {
39411
39690
  enumerable: true,
39412
39691
  get: function() {
@@ -39461,6 +39740,12 @@ Object.defineProperty(exports, "EventCategory", {
39461
39740
  return EventCategory;
39462
39741
  }
39463
39742
  });
39743
+ Object.defineProperty(exports, "FULL_IMAGE_BBOX", {
39744
+ enumerable: true,
39745
+ get: function() {
39746
+ return FULL_IMAGE_BBOX;
39747
+ }
39748
+ });
39464
39749
  Object.defineProperty(exports, "LabelAttributionSchema", {
39465
39750
  enumerable: true,
39466
39751
  get: function() {
@@ -39863,6 +40148,12 @@ Object.defineProperty(exports, "partialRecord", {
39863
40148
  return partialRecord;
39864
40149
  }
39865
40150
  });
40151
+ Object.defineProperty(exports, "pickClusterStepModels", {
40152
+ enumerable: true,
40153
+ get: function() {
40154
+ return pickClusterStepModels;
40155
+ }
40156
+ });
39866
40157
  Object.defineProperty(exports, "pipelineAnalyticsCapability", {
39867
40158
  enumerable: true,
39868
40159
  get: function() {
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-KXhjlCmh.js");
5
+ const require_dist = require("../dist-wQmgHP6h.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -340,139 +340,99 @@ var MLPACKAGE_FILES = [
340
340
  "Data/com.apple.CoreML/model.mlmodel",
341
341
  "Data/com.apple.CoreML/weights/weight.bin"
342
342
  ];
343
- var CLIP_IMAGE_MODELS = [
344
- {
345
- id: "mobileclip-s0",
346
- name: "MobileCLIP S0",
347
- description: "Apple MobileCLIP S0 fast vision encoder, 512-dim, 256×256 (40 MB ONNX)",
348
- inputSize: {
349
- width: 256,
350
- height: 256
351
- },
352
- labels: [],
353
- inputNormalization: "none",
354
- formats: {
355
- onnx: {
356
- url: hf("clip/mobileclip-s0/onnx/camstack-mobileclip-s0-vision.onnx"),
357
- sizeMB: 40
358
- },
359
- openvino: ovFormat(hf("clip/mobileclip-s0/openvino/camstack-mobileclip-s0-vision.xml"), 25),
360
- coreml: {
361
- url: hf("clip/mobileclip-s0/coreml/camstack-mobileclip-s0-vision.mlpackage"),
362
- sizeMB: 30,
363
- isDirectory: true,
364
- files: [...MLPACKAGE_FILES],
365
- runtimes: ["python"]
366
- }
367
- }
343
+ /**
344
+ * NO onnx vision builds, deliberately (2026-08-21). The int8 ONNX vision
345
+ * exports were measured misaligned with the text encoders (matched image↔text
346
+ * cosine ≈ 0.01 vs ≈ 0.22 for the fp16 openvino/coreml builds) — see the
347
+ * catalog note in `addon-pipeline/.../model-catalogs.ts`. The image-encode leg
348
+ * that consumed them here (`embeddingEncoder.encode` → PythonRawTensorEngine)
349
+ * is retired with them: its only caller discarded the vector, and its
350
+ * `preprocessForClip` applied OpenAI mean/std that MobileCLIP never used.
351
+ * S0 was retired the same day (measured worst of the family on fleet crops).
352
+ */
353
+ var CLIP_IMAGE_MODELS = [{
354
+ id: "mobileclip-s1",
355
+ name: "MobileCLIP S1",
356
+ description: "Apple MobileCLIP S1 — balanced vision encoder, 512-dim, 256×256 (fp16 OpenVINO/CoreML)",
357
+ inputSize: {
358
+ width: 256,
359
+ height: 256
368
360
  },
369
- {
370
- id: "mobileclip-s1",
371
- name: "MobileCLIP S1",
372
- description: "Apple MobileCLIP S1 — balanced vision encoder, 512-dim, 256×256 (90 MB ONNX)",
373
- inputSize: {
374
- width: 256,
375
- height: 256
376
- },
377
- labels: [],
378
- inputNormalization: "none",
379
- formats: {
380
- onnx: {
381
- url: hf("clip/mobileclip-s1/onnx/camstack-mobileclip-s1-vision.onnx"),
382
- sizeMB: 90
383
- },
384
- openvino: ovFormat(hf("clip/mobileclip-s1/openvino/camstack-mobileclip-s1-vision.xml"), 55),
385
- coreml: {
386
- url: hf("clip/mobileclip-s1/coreml/camstack-mobileclip-s1-vision.mlpackage"),
387
- sizeMB: 65,
388
- isDirectory: true,
389
- files: [...MLPACKAGE_FILES],
390
- runtimes: ["python"]
391
- }
361
+ labels: [],
362
+ inputNormalization: "none",
363
+ formats: {
364
+ openvino: ovFormat(hf("clip/mobileclip-s1/openvino/camstack-mobileclip-s1-vision.xml"), 55),
365
+ coreml: {
366
+ url: hf("clip/mobileclip-s1/coreml/camstack-mobileclip-s1-vision.mlpackage"),
367
+ sizeMB: 65,
368
+ isDirectory: true,
369
+ files: [...MLPACKAGE_FILES],
370
+ runtimes: ["python"]
392
371
  }
372
+ }
373
+ }, {
374
+ id: "mobileclip-s2",
375
+ name: "MobileCLIP S2",
376
+ description: "Apple MobileCLIP S2 — high-accuracy vision encoder, 512-dim, 256×256 (fp16 OpenVINO/CoreML)",
377
+ inputSize: {
378
+ width: 256,
379
+ height: 256
393
380
  },
394
- {
395
- id: "mobileclip-s2",
396
- name: "MobileCLIP S2",
397
- description: "Apple MobileCLIP S2 — high-accuracy vision encoder, 512-dim, 256×256 (150 MB ONNX)",
398
- inputSize: {
399
- width: 256,
400
- height: 256
401
- },
402
- labels: [],
403
- inputNormalization: "none",
404
- formats: {
405
- onnx: {
406
- url: hf("clip/mobileclip-s2/onnx/camstack-mobileclip-s2-vision.onnx"),
407
- sizeMB: 150
408
- },
409
- openvino: ovFormat(hf("clip/mobileclip-s2/openvino/camstack-mobileclip-s2-vision.xml"), 90),
410
- coreml: {
411
- url: hf("clip/mobileclip-s2/coreml/camstack-mobileclip-s2-vision.mlpackage"),
412
- sizeMB: 110,
413
- isDirectory: true,
414
- files: [...MLPACKAGE_FILES],
415
- runtimes: ["python"]
416
- }
381
+ labels: [],
382
+ inputNormalization: "none",
383
+ formats: {
384
+ openvino: ovFormat(hf("clip/mobileclip-s2/openvino/camstack-mobileclip-s2-vision.xml"), 90),
385
+ coreml: {
386
+ url: hf("clip/mobileclip-s2/coreml/camstack-mobileclip-s2-vision.mlpackage"),
387
+ sizeMB: 110,
388
+ isDirectory: true,
389
+ files: [...MLPACKAGE_FILES],
390
+ runtimes: ["python"]
417
391
  }
418
392
  }
419
- ];
420
- var CLIP_TEXT_MODELS = [
421
- {
422
- id: "mobileclip-s0-text",
423
- name: "MobileCLIP S0 Text Encoder",
424
- description: "Text encoder for MobileCLIP S0, 512-dim, int8 quantized (35 MB)",
425
- inputSize: {
426
- width: 0,
427
- height: 0
428
- },
429
- labels: [],
430
- formats: {
431
- onnx: {
432
- url: hf("clip/mobileclip-s0/onnx/camstack-mobileclip-s0-text.onnx"),
433
- sizeMB: 35,
434
- files: [TOKENIZER_FILE]
435
- },
436
- openvino: ovFormat(hf("clip/mobileclip-s0/openvino/camstack-mobileclip-s0-text.xml"), 22)
437
- }
393
+ }];
394
+ /**
395
+ * The int8 TEXT onnx encoders are healthy — unlike the retired int8 vision
396
+ * exports. Verified 2026-08-21: the live search path (fp16 openvino vision
397
+ * vectors int8 onnx text queries) ranks correctly on the real index, and the
398
+ * local cross-check aligns them with the fp16 vision space (cos ≈ 0.22 on
399
+ * matched pairs).
400
+ */
401
+ var CLIP_TEXT_MODELS = [{
402
+ id: "mobileclip-s1-text",
403
+ name: "MobileCLIP S1 Text Encoder",
404
+ description: "Text encoder for MobileCLIP S1, 512-dim, int8 quantized (61 MB)",
405
+ inputSize: {
406
+ width: 0,
407
+ height: 0
438
408
  },
439
- {
440
- id: "mobileclip-s1-text",
441
- name: "MobileCLIP S1 Text Encoder",
442
- description: "Text encoder for MobileCLIP S1, 512-dim, int8 quantized (35 MB)",
443
- inputSize: {
444
- width: 0,
445
- height: 0
409
+ labels: [],
410
+ formats: {
411
+ onnx: {
412
+ url: hf("clip/mobileclip-s1/onnx/camstack-mobileclip-s1-text.onnx"),
413
+ sizeMB: 61,
414
+ files: [TOKENIZER_FILE]
446
415
  },
447
- labels: [],
448
- formats: {
449
- onnx: {
450
- url: hf("clip/mobileclip-s1/onnx/camstack-mobileclip-s1-text.onnx"),
451
- sizeMB: 35,
452
- files: [TOKENIZER_FILE]
453
- },
454
- openvino: ovFormat(hf("clip/mobileclip-s1/openvino/camstack-mobileclip-s1-text.xml"), 22)
455
- }
416
+ openvino: ovFormat(hf("clip/mobileclip-s1/openvino/camstack-mobileclip-s1-text.xml"), 121)
417
+ }
418
+ }, {
419
+ id: "mobileclip-s2-text",
420
+ name: "MobileCLIP S2 Text Encoder",
421
+ description: "Text encoder for MobileCLIP S2, 512-dim, int8 quantized (61 MB)",
422
+ inputSize: {
423
+ width: 0,
424
+ height: 0
456
425
  },
457
- {
458
- id: "mobileclip-s2-text",
459
- name: "MobileCLIP S2 Text Encoder",
460
- description: "Text encoder for MobileCLIP S2, 512-dim, int8 quantized (35 MB)",
461
- inputSize: {
462
- width: 0,
463
- height: 0
426
+ labels: [],
427
+ formats: {
428
+ onnx: {
429
+ url: hf("clip/mobileclip-s2/onnx/camstack-mobileclip-s2-text.onnx"),
430
+ sizeMB: 61,
431
+ files: [TOKENIZER_FILE]
464
432
  },
465
- labels: [],
466
- formats: {
467
- onnx: {
468
- url: hf("clip/mobileclip-s2/onnx/camstack-mobileclip-s2-text.onnx"),
469
- sizeMB: 35,
470
- files: [TOKENIZER_FILE]
471
- },
472
- openvino: ovFormat(hf("clip/mobileclip-s2/openvino/camstack-mobileclip-s2-text.xml"), 22)
473
- }
433
+ openvino: ovFormat(hf("clip/mobileclip-s2/openvino/camstack-mobileclip-s2-text.xml"), 121)
474
434
  }
475
- ];
435
+ }];
476
436
  //#endregion
477
437
  //#region src/embedding-encoder/shared/noop-logger.ts
478
438
  var noop = () => {};
@@ -749,14 +709,12 @@ var PythonTextEncoderEngine = class {
749
709
  };
750
710
  //#endregion
751
711
  //#region src/embedding-encoder/addon/clip-models.ts
712
+ /**
713
+ * `mobileclip-s0` was retired on 2026-08-21 (measured worst of the family on
714
+ * real fleet crops). A stored config still naming it resolves to the default
715
+ * via {@link getModelMeta}'s fallback — never to a dangling id.
716
+ */
752
717
  var CLIP_MODEL_META = {
753
- "mobileclip-s0": {
754
- imageModelId: "mobileclip-s0",
755
- textModelId: "mobileclip-s0-text",
756
- embeddingDim: 512,
757
- inputSize: 256,
758
- tokenizerType: "clip"
759
- },
760
718
  "mobileclip-s1": {
761
719
  imageModelId: "mobileclip-s1",
762
720
  textModelId: "mobileclip-s1-text",