@reftrixmcp/ml 0.1.7

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 (97) hide show
  1. package/LICENSE +661 -0
  2. package/dist/dinov2/index.d.ts +7 -0
  3. package/dist/dinov2/index.d.ts.map +1 -0
  4. package/dist/dinov2/index.js +7 -0
  5. package/dist/dinov2/index.js.map +1 -0
  6. package/dist/dinov2/service.d.ts +106 -0
  7. package/dist/dinov2/service.d.ts.map +1 -0
  8. package/dist/dinov2/service.js +446 -0
  9. package/dist/dinov2/service.js.map +1 -0
  10. package/dist/dinov2/worker-thread-types.d.ts +77 -0
  11. package/dist/dinov2/worker-thread-types.d.ts.map +1 -0
  12. package/dist/dinov2/worker-thread-types.js +4 -0
  13. package/dist/dinov2/worker-thread-types.js.map +1 -0
  14. package/dist/dinov2/worker-thread.d.ts +2 -0
  15. package/dist/dinov2/worker-thread.d.ts.map +1 -0
  16. package/dist/dinov2/worker-thread.js +231 -0
  17. package/dist/dinov2/worker-thread.js.map +1 -0
  18. package/dist/embeddings/index.d.ts +12 -0
  19. package/dist/embeddings/index.d.ts.map +1 -0
  20. package/dist/embeddings/index.js +13 -0
  21. package/dist/embeddings/index.js.map +1 -0
  22. package/dist/embeddings/multimodal-embedding.service.d.ts +400 -0
  23. package/dist/embeddings/multimodal-embedding.service.d.ts.map +1 -0
  24. package/dist/embeddings/multimodal-embedding.service.js +650 -0
  25. package/dist/embeddings/multimodal-embedding.service.js.map +1 -0
  26. package/dist/embeddings/service.d.ts +261 -0
  27. package/dist/embeddings/service.d.ts.map +1 -0
  28. package/dist/embeddings/service.js +1093 -0
  29. package/dist/embeddings/service.js.map +1 -0
  30. package/dist/embeddings/style-embedding.service.d.ts +117 -0
  31. package/dist/embeddings/style-embedding.service.d.ts.map +1 -0
  32. package/dist/embeddings/style-embedding.service.js +157 -0
  33. package/dist/embeddings/style-embedding.service.js.map +1 -0
  34. package/dist/embeddings/types.d.ts +83 -0
  35. package/dist/embeddings/types.d.ts.map +1 -0
  36. package/dist/embeddings/types.js +4 -0
  37. package/dist/embeddings/types.js.map +1 -0
  38. package/dist/embeddings/vision-embedding.service.d.ts +70 -0
  39. package/dist/embeddings/vision-embedding.service.d.ts.map +1 -0
  40. package/dist/embeddings/vision-embedding.service.js +221 -0
  41. package/dist/embeddings/vision-embedding.service.js.map +1 -0
  42. package/dist/embeddings/vision-embedding.types.d.ts +102 -0
  43. package/dist/embeddings/vision-embedding.types.d.ts.map +1 -0
  44. package/dist/embeddings/vision-embedding.types.js +4 -0
  45. package/dist/embeddings/vision-embedding.types.js.map +1 -0
  46. package/dist/embeddings/worker-thread-types.d.ts +152 -0
  47. package/dist/embeddings/worker-thread-types.d.ts.map +1 -0
  48. package/dist/embeddings/worker-thread-types.js +4 -0
  49. package/dist/embeddings/worker-thread-types.js.map +1 -0
  50. package/dist/embeddings/worker-thread.d.ts +4 -0
  51. package/dist/embeddings/worker-thread.d.ts.map +1 -0
  52. package/dist/embeddings/worker-thread.js +441 -0
  53. package/dist/embeddings/worker-thread.js.map +1 -0
  54. package/dist/index.d.ts +18 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +19 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/search/fulltext-helpers.d.ts +30 -0
  59. package/dist/search/fulltext-helpers.d.ts.map +1 -0
  60. package/dist/search/fulltext-helpers.js +40 -0
  61. package/dist/search/fulltext-helpers.js.map +1 -0
  62. package/dist/search/hybrid.d.ts +40 -0
  63. package/dist/search/hybrid.d.ts.map +1 -0
  64. package/dist/search/hybrid.js +61 -0
  65. package/dist/search/hybrid.js.map +1 -0
  66. package/dist/search/index.d.ts +12 -0
  67. package/dist/search/index.d.ts.map +1 -0
  68. package/dist/search/index.js +11 -0
  69. package/dist/search/index.js.map +1 -0
  70. package/dist/search/rrf.d.ts +69 -0
  71. package/dist/search/rrf.d.ts.map +1 -0
  72. package/dist/search/rrf.js +108 -0
  73. package/dist/search/rrf.js.map +1 -0
  74. package/dist/search/types.d.ts +130 -0
  75. package/dist/search/types.d.ts.map +1 -0
  76. package/dist/search/types.js +4 -0
  77. package/dist/search/types.js.map +1 -0
  78. package/package.json +65 -0
  79. package/src/dinov2/index.ts +21 -0
  80. package/src/dinov2/service.ts +539 -0
  81. package/src/dinov2/worker-thread-types.ts +103 -0
  82. package/src/dinov2/worker-thread.ts +306 -0
  83. package/src/embeddings/index.ts +74 -0
  84. package/src/embeddings/multimodal-embedding.service.ts +992 -0
  85. package/src/embeddings/service.ts +1287 -0
  86. package/src/embeddings/style-embedding.service.ts +201 -0
  87. package/src/embeddings/types.ts +95 -0
  88. package/src/embeddings/vision-embedding.service.ts +273 -0
  89. package/src/embeddings/vision-embedding.types.ts +117 -0
  90. package/src/embeddings/worker-thread-types.ts +194 -0
  91. package/src/embeddings/worker-thread.ts +529 -0
  92. package/src/index.ts +70 -0
  93. package/src/search/fulltext-helpers.ts +45 -0
  94. package/src/search/hybrid.ts +107 -0
  95. package/src/search/index.ts +25 -0
  96. package/src/search/rrf.ts +154 -0
  97. package/src/search/types.ts +141 -0
@@ -0,0 +1,539 @@
1
+ // SPDX-FileCopyrightText: 2026 TKMD and Reftrix Contributors
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * DINOv2 Embedding Service
6
+ *
7
+ * Generates visual embeddings using DINOv2 ViT-B/14 via ONNX Runtime.
8
+ * Inference is offloaded to a Worker Thread by default to keep the main
9
+ * event loop responsive for BullMQ heartbeats and IPC messages.
10
+ *
11
+ * Usage:
12
+ * const service = new DINOv2Service({ modelPath: '/path/to/model.onnx' });
13
+ * await service.initialize();
14
+ * const embedding = await service.generateEmbedding(imageBuffer);
15
+ * await service.dispose();
16
+ *
17
+ * Set env DINOV2_WORKER_THREAD=false to run inference in-process (for testing).
18
+ *
19
+ * @module dinov2/service
20
+ */
21
+
22
+ import { Worker } from "node:worker_threads";
23
+ import { fileURLToPath } from "node:url";
24
+ import path from "node:path";
25
+ import type { DINOv2WorkerMessage, DINOv2WorkerResponse } from "./worker-thread-types.js";
26
+
27
+ // =====================================================
28
+ // Constants
29
+ // =====================================================
30
+
31
+ /** DINOv2 ViT-B/14 output dimension */
32
+ export const DINOV2_EMBEDDING_DIMENSION = 768;
33
+
34
+ /** Expected input image size (width and height) */
35
+ export const DINOV2_INPUT_SIZE = 224;
36
+
37
+ /** Maximum number of automatic worker thread restarts after crash. */
38
+ const MAX_WORKER_RESTARTS = 5;
39
+
40
+ /** Timeout for worker thread responses (ms). Covers model loading + inference. */
41
+ const WORKER_RESPONSE_TIMEOUT_MS = 30_000;
42
+
43
+ // =====================================================
44
+ // Configuration
45
+ // =====================================================
46
+
47
+ /**
48
+ * Configuration for DINOv2Service
49
+ */
50
+ export interface DINOv2ServiceConfig {
51
+ /**
52
+ * Path to the ONNX model file.
53
+ * Can also be set via env var DINOV2_MODEL_PATH.
54
+ */
55
+ modelPath: string;
56
+ }
57
+
58
+ // =====================================================
59
+ // Pending request tracking
60
+ // =====================================================
61
+
62
+ interface PendingRequest {
63
+ resolve: (response: DINOv2WorkerResponse) => void;
64
+ reject: (error: Error) => void;
65
+ timer: ReturnType<typeof setTimeout>;
66
+ }
67
+
68
+ // =====================================================
69
+ // Helper: request ID generation
70
+ // =====================================================
71
+
72
+ let requestIdCounter = 0;
73
+ function generateRequestId(): string {
74
+ return `dinov2_${Date.now()}_${++requestIdCounter}`;
75
+ }
76
+
77
+ /**
78
+ * Resolve the path to the DINOv2 worker thread script.
79
+ */
80
+ function resolveWorkerScriptPath(): string {
81
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
82
+ return path.resolve(currentDir, "worker-thread.js");
83
+ }
84
+
85
+ /**
86
+ * Whether to use worker thread for ONNX inference.
87
+ * Disabled by env var DINOV2_WORKER_THREAD=false or in test environments.
88
+ */
89
+ function isWorkerThreadEnabled(): boolean {
90
+ const envVal = process.env.DINOV2_WORKER_THREAD;
91
+ if (envVal === "false" || envVal === "0") return false;
92
+ // Disable in Vitest to avoid worker thread complications in test harness
93
+ if (process.env.VITEST === "true" || process.env.VITEST_WORKER_ID !== undefined) return false;
94
+ return true;
95
+ }
96
+
97
+ // =====================================================
98
+ // DINOv2Service
99
+ // =====================================================
100
+
101
+ /**
102
+ * DINOv2Service - Generates visual embeddings using DINOv2 ViT-B/14
103
+ *
104
+ * ONNX inference is offloaded to a Worker Thread by default.
105
+ * The main thread manages the Worker lifecycle; the worker owns
106
+ * the ONNX InferenceSession lifecycle (init, inference, dispose).
107
+ */
108
+ export class DINOv2Service {
109
+ private modelPath: string;
110
+ private useWorkerThread: boolean;
111
+
112
+ // --- Worker Thread state ---
113
+ private worker: Worker | null = null;
114
+ private workerInitPromise: Promise<void> | null = null;
115
+ private workerReady = false;
116
+ private workerRestartCount = 0;
117
+ private pendingRequests: Map<string, PendingRequest> = new Map();
118
+ private lastCrashTime = 0;
119
+
120
+ // --- In-process fallback state ---
121
+ private inProcessSession: unknown | null = null;
122
+
123
+ private _isInitialized = false;
124
+
125
+ constructor(config: DINOv2ServiceConfig) {
126
+ this.modelPath = config.modelPath;
127
+ this.useWorkerThread = isWorkerThreadEnabled();
128
+ }
129
+
130
+ // =====================================================
131
+ // Worker Thread management
132
+ // =====================================================
133
+
134
+ /**
135
+ * Initialize the service. Spawns the worker thread and loads the model.
136
+ * Must be called before generateEmbedding().
137
+ */
138
+ async initialize(): Promise<void> {
139
+ if (this._isInitialized) return;
140
+
141
+ if (this.useWorkerThread) {
142
+ await this.ensureWorkerReady();
143
+ } else {
144
+ await this.initializeInProcess();
145
+ }
146
+
147
+ this._isInitialized = true;
148
+ }
149
+
150
+ /**
151
+ * Ensure the worker thread is spawned and initialized.
152
+ */
153
+ private async ensureWorkerReady(): Promise<void> {
154
+ if (this.workerReady && this.worker) return;
155
+ if (this.workerInitPromise) return this.workerInitPromise;
156
+
157
+ // Cooldown after crashes to prevent rapid spawn-crash cycles
158
+ if (this.lastCrashTime > 0) {
159
+ const CRASH_COOLDOWN_MS = 3_000;
160
+ const elapsed = Date.now() - this.lastCrashTime;
161
+ if (elapsed < CRASH_COOLDOWN_MS) {
162
+ const waitMs = CRASH_COOLDOWN_MS - elapsed;
163
+ await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
164
+ }
165
+ }
166
+
167
+ this.workerInitPromise = this.spawnAndInitWorker();
168
+ return this.workerInitPromise;
169
+ }
170
+
171
+ private async spawnAndInitWorker(): Promise<void> {
172
+ try {
173
+ const scriptPath = resolveWorkerScriptPath();
174
+
175
+ // Worker threads inherit the parent's execArgv by default.
176
+ // Pass empty array to prevent parent flags from propagating.
177
+ this.worker = new Worker(scriptPath, { execArgv: [] });
178
+
179
+ this.worker.on("message", (response: DINOv2WorkerResponse) => {
180
+ this.handleWorkerResponse(response);
181
+ });
182
+
183
+ this.worker.on("error", (error: Error) => {
184
+ console.warn("[DINOv2] Worker Thread error:", error.message);
185
+ this.handleWorkerCrash(error);
186
+ });
187
+
188
+ this.worker.on("exit", (code: number) => {
189
+ if (code !== 0) {
190
+ console.warn("[DINOv2] Worker Thread exited with code:", code);
191
+ this.handleWorkerCrash(new Error(`Worker thread exited with code ${code}`));
192
+ }
193
+ this.workerReady = false;
194
+ this.worker = null;
195
+ });
196
+
197
+ const response = await this.sendWorkerMessage({
198
+ type: "init",
199
+ requestId: generateRequestId(),
200
+ modelPath: this.modelPath,
201
+ });
202
+
203
+ if (response.type === "error") {
204
+ throw new Error(`Worker init failed: ${response.error}`);
205
+ }
206
+
207
+ this.workerReady = true;
208
+
209
+ if (response.type === "init") {
210
+ // eslint-disable-next-line no-console
211
+ console.log("[DINOv2] Worker Thread initialized in %dms", response.loadTimeMs);
212
+ }
213
+ } catch (error) {
214
+ this.workerInitPromise = null;
215
+ this.workerReady = false;
216
+ const message = error instanceof Error ? error.message : "Unknown error";
217
+ throw new Error(`Failed to initialize DINOv2 Worker Thread: ${message}`);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Send a message to the worker and return a Promise for the response.
223
+ */
224
+ private sendWorkerMessage(message: DINOv2WorkerMessage): Promise<DINOv2WorkerResponse> {
225
+ if (!this.worker) {
226
+ return Promise.reject(new Error("Worker thread not available"));
227
+ }
228
+
229
+ return new Promise<DINOv2WorkerResponse>((resolve, reject) => {
230
+ const timer = setTimeout(() => {
231
+ this.pendingRequests.delete(message.requestId);
232
+ reject(
233
+ new Error(
234
+ `Worker thread response timeout (${WORKER_RESPONSE_TIMEOUT_MS}ms) for ${message.type}`
235
+ )
236
+ );
237
+ }, WORKER_RESPONSE_TIMEOUT_MS);
238
+
239
+ this.pendingRequests.set(message.requestId, { resolve, reject, timer });
240
+ this.worker!.postMessage(message);
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Route a response from the worker to the pending request's promise.
246
+ */
247
+ private handleWorkerResponse(response: DINOv2WorkerResponse): void {
248
+ const pending = this.pendingRequests.get(response.requestId);
249
+ if (!pending) return;
250
+
251
+ clearTimeout(pending.timer);
252
+ this.pendingRequests.delete(response.requestId);
253
+ pending.resolve(response);
254
+ }
255
+
256
+ /**
257
+ * Handle worker thread crash: reject all pending requests, track restart count.
258
+ */
259
+ private handleWorkerCrash(error: Error): void {
260
+ for (const [id, pending] of this.pendingRequests) {
261
+ clearTimeout(pending.timer);
262
+ pending.reject(new Error(`Worker thread crashed: ${error.message}`));
263
+ this.pendingRequests.delete(id);
264
+ }
265
+
266
+ this.workerReady = false;
267
+ this.worker = null;
268
+ this.workerInitPromise = null;
269
+ this._isInitialized = false;
270
+ this.workerRestartCount++;
271
+ this.lastCrashTime = Date.now();
272
+ }
273
+
274
+ private canRestartWorker(): boolean {
275
+ return this.workerRestartCount < MAX_WORKER_RESTARTS;
276
+ }
277
+
278
+ // =====================================================
279
+ // In-process fallback (for testing / debugging)
280
+ // =====================================================
281
+
282
+ private async initializeInProcess(): Promise<void> {
283
+ const ortModule = await import("onnxruntime-node");
284
+ this.inProcessSession = await ortModule.InferenceSession.create(this.modelPath, {
285
+ executionProviders: ["cpu"],
286
+ });
287
+ // eslint-disable-next-line no-console
288
+ console.log("[DINOv2] Session initialized in-process:", this.modelPath);
289
+ }
290
+
291
+ private async generateInProcess(imageBuffer: Buffer): Promise<number[]> {
292
+ const ortModule = await import("onnxruntime-node");
293
+
294
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
295
+ const sess = this.inProcessSession as any;
296
+ if (!sess) throw new Error("DINOv2 in-process session not initialized");
297
+
298
+ const width = DINOV2_INPUT_SIZE;
299
+ const height = DINOV2_INPUT_SIZE;
300
+ const expectedSize = width * height * 3;
301
+ if (imageBuffer.length !== expectedSize) {
302
+ throw new Error(`Invalid buffer size: expected ${expectedSize}, got ${imageBuffer.length}`);
303
+ }
304
+
305
+ // Preprocess: HWC uint8 -> CHW float32 with ImageNet normalization
306
+ const MEAN = [0.485, 0.456, 0.406] as const;
307
+ const STD = [0.229, 0.224, 0.225] as const;
308
+ const float32 = new Float32Array(3 * width * height);
309
+ for (let c = 0; c < 3; c++) {
310
+ for (let h = 0; h < height; h++) {
311
+ for (let w = 0; w < width; w++) {
312
+ const srcIdx = (h * width + w) * 3 + c;
313
+ const dstIdx = c * height * width + h * width + w;
314
+ float32[dstIdx] = (imageBuffer[srcIdx]! / 255.0 - MEAN[c]!) / STD[c]!;
315
+ }
316
+ }
317
+ }
318
+
319
+ const inputTensor = new ortModule.Tensor("float32", float32, [1, 3, height, width]);
320
+ const output = await sess.run({ pixel_values: inputTensor });
321
+
322
+ const lastHiddenState = output.last_hidden_state;
323
+ if (!lastHiddenState) {
324
+ throw new Error("Model output missing last_hidden_state");
325
+ }
326
+
327
+ const data = lastHiddenState.data as Float32Array;
328
+
329
+ // Extract CLS token (first token)
330
+ const clsToken = new Float32Array(DINOV2_EMBEDDING_DIMENSION);
331
+ for (let i = 0; i < DINOV2_EMBEDDING_DIMENSION; i++) {
332
+ clsToken[i] = data[i]!;
333
+ }
334
+
335
+ // L2 normalize
336
+ let norm = 0;
337
+ for (let i = 0; i < clsToken.length; i++) {
338
+ const v = clsToken[i]!;
339
+ if (!Number.isFinite(v)) {
340
+ throw new Error(`NaN/Infinity detected at index ${i}`);
341
+ }
342
+ norm += v * v;
343
+ }
344
+ norm = Math.sqrt(norm);
345
+ if (norm === 0) {
346
+ throw new Error("Zero vector: L2 norm is 0");
347
+ }
348
+ const normalized = new Float32Array(DINOV2_EMBEDDING_DIMENSION);
349
+ for (let i = 0; i < DINOV2_EMBEDDING_DIMENSION; i++) {
350
+ normalized[i] = clsToken[i]! / norm;
351
+ }
352
+
353
+ return Array.from(normalized);
354
+ }
355
+
356
+ // =====================================================
357
+ // Public API
358
+ // =====================================================
359
+
360
+ /**
361
+ * Generate a visual embedding for a single image.
362
+ *
363
+ * The imageBuffer must contain raw 224x224x3 RGB pixel data (150,528 bytes).
364
+ * The caller is responsible for resizing/cropping to 224x224 via Sharp.
365
+ *
366
+ * @param imageBuffer - Raw 224x224x3 RGB pixel buffer
367
+ * @returns 768-dimensional L2-normalized embedding vector
368
+ */
369
+ async generateEmbedding(imageBuffer: Buffer): Promise<number[]> {
370
+ if (!this._isInitialized) {
371
+ await this.initialize();
372
+ }
373
+
374
+ const expectedSize = DINOV2_INPUT_SIZE * DINOV2_INPUT_SIZE * 3;
375
+ if (imageBuffer.length !== expectedSize) {
376
+ throw new Error(
377
+ `Invalid image buffer size: expected ${expectedSize} (224x224x3 RGB), got ${imageBuffer.length}`
378
+ );
379
+ }
380
+
381
+ if (this.useWorkerThread) {
382
+ return this.generateViaWorker(imageBuffer);
383
+ }
384
+ return this.generateInProcess(imageBuffer);
385
+ }
386
+
387
+ /**
388
+ * Generate embeddings for multiple images sequentially.
389
+ *
390
+ * DINOv2 processes one image at a time (no native batching for ViT),
391
+ * so this runs inference sequentially through the single Worker Thread.
392
+ *
393
+ * @param imageBuffers - Array of raw 224x224x3 RGB pixel buffers
394
+ * @returns Array of 768-dimensional L2-normalized embedding vectors
395
+ */
396
+ async generateBatchEmbeddings(imageBuffers: Buffer[]): Promise<number[][]> {
397
+ const results: number[][] = [];
398
+ for (const buffer of imageBuffers) {
399
+ const embedding = await this.generateEmbedding(buffer);
400
+ results.push(embedding);
401
+ }
402
+ return results;
403
+ }
404
+
405
+ /**
406
+ * Generate embedding via Worker Thread with crash recovery.
407
+ */
408
+ private async generateViaWorker(imageBuffer: Buffer): Promise<number[]> {
409
+ if (!this.workerReady || !this.worker) {
410
+ if (!this.canRestartWorker() && this.workerRestartCount > 0) {
411
+ throw new Error(`Worker thread exceeded max restarts (${MAX_WORKER_RESTARTS})`);
412
+ }
413
+ await this.ensureWorkerReady();
414
+ }
415
+
416
+ // Copy to a fresh ArrayBuffer for structured clone transfer
417
+ const arrayBuffer = new ArrayBuffer(imageBuffer.byteLength);
418
+ new Uint8Array(arrayBuffer).set(
419
+ new Uint8Array(imageBuffer.buffer, imageBuffer.byteOffset, imageBuffer.byteLength)
420
+ );
421
+
422
+ const response = await this.sendWorkerMessage({
423
+ type: "infer",
424
+ requestId: generateRequestId(),
425
+ imageBuffer: arrayBuffer,
426
+ width: DINOV2_INPUT_SIZE,
427
+ height: DINOV2_INPUT_SIZE,
428
+ });
429
+
430
+ if (response.type === "error") {
431
+ throw new Error(`DINOv2 inference failed: ${response.error}`);
432
+ }
433
+
434
+ if (response.type !== "infer") {
435
+ throw new Error(`Unexpected worker response type: ${response.type}`);
436
+ }
437
+
438
+ return response.embedding;
439
+ }
440
+
441
+ /**
442
+ * Dispose the ONNX session to free native memory.
443
+ * After disposal, the next generateEmbedding() call will re-initialize.
444
+ */
445
+ async dispose(): Promise<void> {
446
+ if (this.useWorkerThread) {
447
+ if (this.worker && this.workerReady) {
448
+ try {
449
+ await this.sendWorkerMessage({
450
+ type: "dispose",
451
+ requestId: generateRequestId(),
452
+ });
453
+ } catch {
454
+ // Disposal failure should not break the workflow
455
+ }
456
+ }
457
+ } else {
458
+ if (this.inProcessSession) {
459
+ try {
460
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
461
+ await (this.inProcessSession as any).release();
462
+ } catch {
463
+ // Disposal failure should not break the workflow
464
+ }
465
+ this.inProcessSession = null;
466
+ }
467
+ }
468
+
469
+ this._isInitialized = false;
470
+ }
471
+
472
+ /**
473
+ * Terminate the worker thread completely and clean up.
474
+ * After this, the service cannot be used until a new instance is created.
475
+ */
476
+ async terminate(): Promise<void> {
477
+ if (this.worker) {
478
+ try {
479
+ await this.sendWorkerMessage({
480
+ type: "dispose",
481
+ requestId: generateRequestId(),
482
+ });
483
+ } catch {
484
+ // May fail if worker already exited
485
+ }
486
+
487
+ try {
488
+ await this.worker.terminate();
489
+ } catch {
490
+ // Already terminated
491
+ }
492
+
493
+ this.worker = null;
494
+ this.workerReady = false;
495
+ this.workerInitPromise = null;
496
+ }
497
+
498
+ // Also dispose in-process session if present
499
+ if (this.inProcessSession) {
500
+ try {
501
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
502
+ await (this.inProcessSession as any).release();
503
+ } catch {
504
+ // Disposal failure should not break the workflow
505
+ }
506
+ this.inProcessSession = null;
507
+ }
508
+
509
+ this._isInitialized = false;
510
+
511
+ // Reject any remaining pending requests
512
+ for (const [id, pending] of this.pendingRequests) {
513
+ clearTimeout(pending.timer);
514
+ pending.reject(new Error("Service terminated"));
515
+ this.pendingRequests.delete(id);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Whether the service is initialized and ready for inference.
521
+ */
522
+ get initialized(): boolean {
523
+ return this._isInitialized;
524
+ }
525
+
526
+ /**
527
+ * Whether this service is using a Worker Thread for inference.
528
+ */
529
+ isUsingWorkerThread(): boolean {
530
+ return this.useWorkerThread;
531
+ }
532
+
533
+ /**
534
+ * Get the number of worker thread restarts that have occurred.
535
+ */
536
+ getWorkerRestartCount(): number {
537
+ return this.workerRestartCount;
538
+ }
539
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-FileCopyrightText: 2026 TKMD and Reftrix Contributors
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * Worker Thread message types for DINOv2 ONNX inference.
6
+ *
7
+ * Communication protocol between main thread (DINOv2Service) and
8
+ * worker thread (worker-thread.ts). Messages are serialized via
9
+ * structured clone (MessagePort), so only plain objects and typed arrays
10
+ * are used -- no ONNX Session/Tensor objects cross the boundary.
11
+ *
12
+ * @module dinov2/worker-thread-types
13
+ */
14
+
15
+ // =====================================================
16
+ // Main -> Worker messages
17
+ // =====================================================
18
+
19
+ /**
20
+ * Initialize the ONNX InferenceSession in the worker thread.
21
+ */
22
+ export interface DINOv2WorkerInitMessage {
23
+ type: "init";
24
+ requestId: string;
25
+ modelPath: string;
26
+ }
27
+
28
+ /**
29
+ * Run DINOv2 inference on a preprocessed image buffer.
30
+ * The main thread sends raw 224x224x3 RGB pixel data.
31
+ */
32
+ export interface DINOv2WorkerInferMessage {
33
+ type: "infer";
34
+ requestId: string;
35
+ imageBuffer: ArrayBuffer; // 224x224x3 RGB raw pixels
36
+ width: number;
37
+ height: number;
38
+ }
39
+
40
+ /**
41
+ * Dispose the ONNX session to free native memory.
42
+ */
43
+ export interface DINOv2WorkerDisposeMessage {
44
+ type: "dispose";
45
+ requestId: string;
46
+ }
47
+
48
+ export type DINOv2WorkerMessage =
49
+ | DINOv2WorkerInitMessage
50
+ | DINOv2WorkerInferMessage
51
+ | DINOv2WorkerDisposeMessage;
52
+
53
+ // =====================================================
54
+ // Worker -> Main messages
55
+ // =====================================================
56
+
57
+ /**
58
+ * Successful response from init.
59
+ */
60
+ export interface DINOv2WorkerInitResponse {
61
+ type: "init";
62
+ requestId: string;
63
+ success: true;
64
+ loadTimeMs: number;
65
+ }
66
+
67
+ /**
68
+ * Successful response from infer.
69
+ * Embedding is a plain number[] (structured clone compatible).
70
+ */
71
+ export interface DINOv2WorkerInferResponse {
72
+ type: "infer";
73
+ requestId: string;
74
+ success: true;
75
+ embedding: number[]; // 768D, L2 normalized
76
+ inferenceTimeMs: number;
77
+ }
78
+
79
+ /**
80
+ * Successful response from dispose.
81
+ */
82
+ export interface DINOv2WorkerDisposeResponse {
83
+ type: "dispose";
84
+ requestId: string;
85
+ success: true;
86
+ }
87
+
88
+ /**
89
+ * Error response for any message type.
90
+ */
91
+ export interface DINOv2WorkerErrorResponse {
92
+ type: "error";
93
+ requestId: string;
94
+ success: false;
95
+ error: string;
96
+ originalType: DINOv2WorkerMessage["type"];
97
+ }
98
+
99
+ export type DINOv2WorkerResponse =
100
+ | DINOv2WorkerInitResponse
101
+ | DINOv2WorkerInferResponse
102
+ | DINOv2WorkerDisposeResponse
103
+ | DINOv2WorkerErrorResponse;