@smartspectra/node-sdk 3.2.0-rc.6

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.
package/js/ffi.js ADDED
@@ -0,0 +1,494 @@
1
+ // ffi.js
2
+ // Copyright (C) 2026 Presage Technologies, Inc.
3
+ //
4
+ // SPDX-License-Identifier: LicenseRef-Proprietary
5
+ //
6
+ // Pure-FFI bindings for smartspectra/capi. Type declarations mirror
7
+ // smartspectra/capi/smartspectra_capi.h one-to-one — field order,
8
+ // append-only struct evolution, and callback signatures are load-bearing.
9
+ // The header's `_Static_assert` layout locks are the source of truth.
10
+ //
11
+ // Exposes a low-level `Session` class. The public, event-oriented
12
+ // `SmartSpectraSDK` lives in smartspectra.js and wraps Session.
13
+
14
+ 'use strict';
15
+
16
+ const koffi = require('koffi');
17
+ const { resolveNativeLibrary } = require('./resolve-native');
18
+
19
+ const lib = koffi.load(resolveNativeLibrary());
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Type declarations — mirror smartspectra_capi.h
23
+ // ---------------------------------------------------------------------------
24
+
25
+ // Opaque session token. C side: `struct smartspectra_session_s*`.
26
+ koffi.opaque('smartspectra_session_t');
27
+
28
+ // smartspectra_error_t — keep field order identical to the C struct; koffi
29
+ // computes offsets by declaration order.
30
+ koffi.struct('smartspectra_error_t', {
31
+ code: 'int',
32
+ message: koffi.array('char', 512, 'String'), // NUL-terminated, decoded to JS string
33
+ retryable: 'int',
34
+ });
35
+
36
+ // smartspectra_config_t — append-only ABI.
37
+ koffi.struct('smartspectra_config_t', {
38
+ api_key: 'const char *',
39
+ requested_metrics: 'const int32_t *',
40
+ requested_metrics_len: 'int',
41
+ enable_accumulated_output: 'int',
42
+ });
43
+
44
+ // Callback prototypes. koffi.register() returns thread-safe thunks: a call
45
+ // originating on an SDK worker thread is marshalled to the V8 event loop
46
+ // via libuv before invoking the JS function.
47
+ koffi.proto('void smartspectra_on_status(uintptr_t handle, int status)');
48
+ koffi.proto('void smartspectra_on_validation(uintptr_t handle, int code, ' +
49
+ 'const char *hint, int64_t timestamp_us)');
50
+ koffi.proto('void smartspectra_on_metrics(uintptr_t handle, ' +
51
+ 'const uint8_t *proto_bytes, int proto_len, int64_t timestamp_us)');
52
+ koffi.proto('void smartspectra_on_error(uintptr_t handle, int code, ' +
53
+ 'const char *message, int retryable)');
54
+ koffi.proto('void smartspectra_on_frame(uintptr_t handle, int sent, int64_t timestamp_us)');
55
+ koffi.proto('void smartspectra_on_accumulated_metrics(uintptr_t handle, ' +
56
+ 'const uint8_t *proto_bytes, int proto_len, int64_t timestamp_us)');
57
+ koffi.proto('void smartspectra_on_insight(uintptr_t handle, ' +
58
+ 'const uint8_t *proto_bytes, int proto_len, int32_t request_id)');
59
+ koffi.proto('void smartspectra_on_video_output(uintptr_t handle, ' +
60
+ 'const uint8_t *data, int width, int height, int stride_bytes, ' +
61
+ 'int pixel_format, int64_t timestamp_us)');
62
+
63
+ // smartspectra_callbacks_t — slots are append-only.
64
+ koffi.struct('smartspectra_callbacks_t', {
65
+ on_status: 'smartspectra_on_status *',
66
+ on_validation: 'smartspectra_on_validation *',
67
+ on_metrics: 'smartspectra_on_metrics *',
68
+ on_error: 'smartspectra_on_error *',
69
+ on_frame: 'smartspectra_on_frame *',
70
+ on_accumulated_metrics: 'smartspectra_on_accumulated_metrics *',
71
+ on_insight: 'smartspectra_on_insight *',
72
+ on_video_output: 'smartspectra_on_video_output *',
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Entry-point bindings. `_Out_` marks pure-output struct args so koffi
77
+ // allocates the backing storage and decodes the result back into the
78
+ // caller-supplied JS object.
79
+ // ---------------------------------------------------------------------------
80
+ const preconfigureFn = lib.func(
81
+ 'int smartspectra_preconfigure(const char *directory, ' +
82
+ 'const char *device_id, _Out_ smartspectra_error_t *out_err)');
83
+
84
+ const sessionCreate = lib.func(
85
+ 'smartspectra_session_t *smartspectra_session_create(' +
86
+ 'const smartspectra_config_t *cfg, uintptr_t handle, ' +
87
+ 'const smartspectra_callbacks_t *callbacks, ' +
88
+ '_Out_ smartspectra_error_t *out_err)');
89
+
90
+ // Source option structs — field order matches smartspectra_capi.h (locked by
91
+ // its layout asserts).
92
+ koffi.struct('smartspectra_file_options_t', {
93
+ interframe_delay_ms: 'int',
94
+ start_offset_ms: 'int',
95
+ max_duration_ms: 'int',
96
+ frame_transform: 'int',
97
+ });
98
+ koffi.struct('smartspectra_camera_options_t', {
99
+ device_index: 'int',
100
+ width: 'int',
101
+ height: 'int',
102
+ fps: 'int',
103
+ frame_transform: 'int',
104
+ });
105
+
106
+ // Non-blocking file source: configure + start, returns immediately. Caller
107
+ // blocks via sessionWait when it wants end-of-file.
108
+ const sessionStartFile = lib.func(
109
+ 'int smartspectra_session_start_file(smartspectra_session_t *sess, ' +
110
+ 'const char *video_path, const char *timestamps_path, ' +
111
+ 'const smartspectra_file_options_t *opts, _Out_ smartspectra_error_t *out_err)');
112
+
113
+ const sessionStartCamera = lib.func(
114
+ 'int smartspectra_session_start_camera(smartspectra_session_t *sess, ' +
115
+ 'const smartspectra_camera_options_t *opts, _Out_ smartspectra_error_t *out_err)');
116
+
117
+ const sessionStartCustom = lib.func(
118
+ 'int smartspectra_session_start_custom(smartspectra_session_t *sess, ' +
119
+ 'int frame_transform, _Out_ smartspectra_error_t *out_err)');
120
+
121
+ const sessionSendFrame = lib.func(
122
+ 'int smartspectra_session_send_frame(smartspectra_session_t *sess, ' +
123
+ 'const uint8_t *data, size_t data_len, int width, int height, ' +
124
+ 'int stride_bytes, int pixel_format, int64_t timestamp_us, ' +
125
+ '_Out_ smartspectra_error_t *out_err)');
126
+
127
+ const sessionStop = lib.func('void smartspectra_session_stop(smartspectra_session_t *sess)');
128
+ const sessionWait = lib.func('int smartspectra_session_wait(smartspectra_session_t *sess, int timeout_ms)');
129
+ const sessionGetStatus = lib.func('int smartspectra_session_get_status(smartspectra_session_t *sess)');
130
+ const sessionReset = lib.func(
131
+ 'int smartspectra_session_reset(smartspectra_session_t *sess, ' +
132
+ '_Out_ smartspectra_error_t *out_err)');
133
+ const sessionRequestInsight = lib.func(
134
+ 'int smartspectra_session_request_insight(smartspectra_session_t *sess, ' +
135
+ 'const char *text, _Out_ int32_t *out_request_id, ' +
136
+ '_Out_ smartspectra_error_t *out_err)');
137
+ const sessionDestroy = lib.func('void smartspectra_session_destroy(smartspectra_session_t *sess)');
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Trampoline adapters. `const char *` args are auto-decoded by koffi to JS
141
+ // strings (don't call koffi.decode — throws "expected external or
142
+ // TypedArray"). `const uint8_t *` is NOT auto-decoded (no length); we
143
+ // explicitly decode + copy into a fresh Buffer so the user can retain the
144
+ // data past callback return (the C-side pointer is borrowed for the call
145
+ // only).
146
+ //
147
+ // Plain functions, not class methods, so a future edit can't introduce a
148
+ // hidden `this` dependency.
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function wrapStatus(jsFn) {
152
+ return (_handle, status) => jsFn(status);
153
+ }
154
+
155
+ function wrapValidation(jsFn) {
156
+ return (_handle, code, hint, timestampUs) => jsFn(code, hint ?? '', timestampUs);
157
+ }
158
+
159
+ function wrapMetrics(jsFn) {
160
+ return (_handle, bytesPtr, length, timestampUs) => {
161
+ const len = length | 0;
162
+ const buf = len > 0
163
+ ? Buffer.from(koffi.decode(bytesPtr, koffi.array('uint8_t', len)))
164
+ : Buffer.alloc(0);
165
+ jsFn(buf, timestampUs);
166
+ };
167
+ }
168
+
169
+ function wrapError(jsFn) {
170
+ return (_handle, code, message, retryable) =>
171
+ jsFn(code, message ?? '', retryable !== 0);
172
+ }
173
+
174
+ function wrapFrame(jsFn) {
175
+ return (_handle, sent, timestampUs) => jsFn(sent !== 0, timestampUs);
176
+ }
177
+
178
+ function wrapInsight(jsFn) {
179
+ return (_handle, bytesPtr, length, requestId) => {
180
+ const len = length | 0;
181
+ const buf = len > 0
182
+ ? Buffer.from(koffi.decode(bytesPtr, koffi.array('uint8_t', len)))
183
+ : Buffer.alloc(0);
184
+ jsFn(buf, requestId | 0);
185
+ };
186
+ }
187
+
188
+ // Bytes occupied by a frame buffer, mirroring smartspectra_session_send_frame's
189
+ // size rule: packed formats span stride_bytes * height; planar YUV (NV12=4,
190
+ // NV21=5) adds a half-height chroma plane at the same stride.
191
+ function frameByteLength(height, strideBytes, pixelFormat) {
192
+ if (pixelFormat === 4 || pixelFormat === 5) {
193
+ return strideBytes * height + strideBytes * Math.ceil(height / 2);
194
+ }
195
+ return strideBytes * height;
196
+ }
197
+
198
+ function wrapVideoOutput(jsFn) {
199
+ return (_handle, dataPtr, width, height, strideBytes, pixelFormat, timestampUs) => {
200
+ const w = width | 0, h = height | 0, stride = strideBytes | 0, fmt = pixelFormat | 0;
201
+ const len = frameByteLength(h, stride, fmt);
202
+ // Copy the borrowed pixel buffer so the consumer can retain it past
203
+ // callback return (same contract as wrapMetrics).
204
+ const buf = len > 0
205
+ ? Buffer.from(koffi.decode(dataPtr, koffi.array('uint8_t', len)))
206
+ : Buffer.alloc(0);
207
+ jsFn(buf, w, h, stride, fmt, timestampUs);
208
+ };
209
+ }
210
+
211
+ // Coerce a sendFrame() timestamp into a BigInt int64. Throws up front for
212
+ // non-finite/non-numeric input so the caller sees an actionable message
213
+ // instead of an opaque BigInt() RangeError from inside the FFI call.
214
+ function toMicroseconds(timestampUs) {
215
+ if (typeof timestampUs === 'bigint') return timestampUs;
216
+ if (typeof timestampUs !== 'number' || !Number.isFinite(timestampUs)) {
217
+ throw new TypeError(
218
+ 'sendFrame: timestampUs must be a finite number or bigint (microseconds)');
219
+ }
220
+ return BigInt(Math.trunc(timestampUs));
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Session — thin wrapper over the C ABI. Owns the opaque C session pointer
225
+ // and the set of koffi-registered callback thunks.
226
+ //
227
+ // Cleanup ordering (load-bearing):
228
+ // stop() → wait() → destroy() → unregister(thunks)
229
+ // destroy() joins SDK worker threads before returning, so unregistering
230
+ // after destroy() is safe. Doing it before would invalidate trampolines
231
+ // while threads are still tearing down.
232
+ // ---------------------------------------------------------------------------
233
+
234
+ class Session {
235
+ /**
236
+ * @param {Object} cfg
237
+ * @param {string} cfg.apiKey
238
+ * @param {number[]} [cfg.requestedMetrics]
239
+ * @param {boolean} [cfg.enableAccumulatedOutput=false]
240
+ * @param {Object} callbacks
241
+ * @param {(status: number) => void} [callbacks.onStatus]
242
+ * @param {(code: number, hint: string, timestampUs: bigint) => void} [callbacks.onValidation]
243
+ * @param {(bytes: Buffer, timestampUs: bigint) => void} callbacks.onMetrics (required)
244
+ * @param {(code: number, message: string, retryable: boolean) => void} [callbacks.onError]
245
+ * @param {(sent: boolean, timestampUs: bigint) => void} [callbacks.onFrame]
246
+ * @param {(bytes: Buffer, timestampUs: bigint) => void} [callbacks.onAccumulatedMetrics]
247
+ * @param {(bytes: Buffer, requestId: number) => void} [callbacks.onInsight]
248
+ * @param {(bytes: Buffer, width: number, height: number, stride: number, pixelFormat: number, timestampUs: bigint) => void} [callbacks.onVideoOutput]
249
+ */
250
+ constructor(cfg, callbacks) {
251
+ if (!callbacks || typeof callbacks.onMetrics !== 'function') {
252
+ throw new TypeError('Session: callbacks.onMetrics is required');
253
+ }
254
+
255
+ this._registeredThunks = [];
256
+ this._destroyed = false;
257
+ // In-flight stopAsync() promise, if any. session_stop runs on a koffi
258
+ // worker thread; destroy() must wait for it to settle before deleting
259
+ // the session (see stopAsync / destroy below).
260
+ this._stopAsyncPromise = null;
261
+ // Resolves once native teardown has actually completed, so callers can
262
+ // serialize a replacement session against this one's wind-down.
263
+ this._teardownPromise = null;
264
+
265
+ // Null slots in the C struct mean the shim won't attempt to dispatch.
266
+ const cbs = {
267
+ on_status: this._registerThunk(callbacks.onStatus, 'smartspectra_on_status *', wrapStatus),
268
+ on_validation: this._registerThunk(callbacks.onValidation, 'smartspectra_on_validation *', wrapValidation),
269
+ on_metrics: this._registerThunk(callbacks.onMetrics, 'smartspectra_on_metrics *', wrapMetrics),
270
+ on_error: this._registerThunk(callbacks.onError, 'smartspectra_on_error *', wrapError),
271
+ on_frame: this._registerThunk(callbacks.onFrame, 'smartspectra_on_frame *', wrapFrame),
272
+ on_accumulated_metrics: this._registerThunk(callbacks.onAccumulatedMetrics, 'smartspectra_on_accumulated_metrics *', wrapMetrics),
273
+ on_insight: this._registerThunk(callbacks.onInsight, 'smartspectra_on_insight *', wrapInsight),
274
+ on_video_output: this._registerThunk(callbacks.onVideoOutput, 'smartspectra_on_video_output *', wrapVideoOutput),
275
+ };
276
+
277
+ // Keep the Int32Array on `this` until session_create returns so the
278
+ // GC can't recycle the buffer mid-call.
279
+ const requested = cfg.requestedMetrics ?? [];
280
+ this._requestedMetricsBuf = requested.length > 0
281
+ ? new Int32Array(requested)
282
+ : null;
283
+
284
+ const cConfig = {
285
+ api_key: cfg.apiKey ?? '',
286
+ requested_metrics: this._requestedMetricsBuf,
287
+ requested_metrics_len: requested.length,
288
+ enable_accumulated_output: cfg.enableAccumulatedOutput ? 1 : 0,
289
+ };
290
+
291
+ const err = {};
292
+ // `handle` is an opaque cookie threaded back into callbacks. Each
293
+ // Session captures `this` via the trampoline closures, so 0 is fine.
294
+ const sess = sessionCreate(cConfig, 0n, cbs, err);
295
+ if (sess === null) {
296
+ this._releaseThunks();
297
+ throw makeFfiError(err);
298
+ }
299
+ this._sess = sess;
300
+ }
301
+
302
+ // Non-blocking file source. timestampsPath + options (optional):
303
+ // { interframeDelayMs, startOffsetMs, maxDurationMs, frameTransform }.
304
+ startFile(videoPath, timestampsPath = null, options = null) {
305
+ this._assertAlive();
306
+ const err = {};
307
+ const opts = options ? {
308
+ interframe_delay_ms: options.interframeDelayMs | 0,
309
+ start_offset_ms: options.startOffsetMs | 0,
310
+ max_duration_ms: options.maxDurationMs | 0,
311
+ frame_transform: options.frameTransform | 0,
312
+ } : null;
313
+ const rc = sessionStartFile(this._sess, videoPath, timestampsPath, opts, err);
314
+ if (rc !== 0) throw makeFfiError(err);
315
+ }
316
+
317
+ // options (optional): { deviceIndex, width, height, fps, frameTransform }
318
+ startCamera(options = null) {
319
+ this._assertAlive();
320
+ const err = {};
321
+ const opts = options ? {
322
+ device_index: options.deviceIndex | 0,
323
+ width: options.width | 0,
324
+ height: options.height | 0,
325
+ fps: options.fps | 0,
326
+ frame_transform: options.frameTransform | 0,
327
+ } : null;
328
+ const rc = sessionStartCamera(this._sess, opts, err);
329
+ if (rc !== 0) throw makeFfiError(err);
330
+ }
331
+
332
+ startCustom(frameTransform = 0) {
333
+ this._assertAlive();
334
+ const err = {};
335
+ const rc = sessionStartCustom(this._sess, frameTransform | 0, err);
336
+ if (rc !== 0) throw makeFfiError(err);
337
+ }
338
+
339
+ sendFrame(buffer, width, height, strideBytes, pixelFormat, timestampUs) {
340
+ this._assertAlive();
341
+ const err = {};
342
+ const rc = sessionSendFrame(
343
+ this._sess, buffer, buffer.byteLength,
344
+ width | 0, height | 0, strideBytes | 0, pixelFormat | 0,
345
+ toMicroseconds(timestampUs), err);
346
+ if (rc !== 0) throw makeFfiError(err);
347
+ return true;
348
+ }
349
+
350
+ stop() {
351
+ if (this._destroyed) return;
352
+ sessionStop(this._sess);
353
+ }
354
+
355
+ /**
356
+ * Async variant of `stop()`. `SmartSpectra::Stop()` blocks until the
357
+ * processing pipeline drains — can take multiple seconds with inference
358
+ * in flight. Run on a koffi worker thread so the JS event loop stays
359
+ * responsive during the drain.
360
+ */
361
+ stopAsync() {
362
+ if (this._destroyed) return Promise.resolve();
363
+ // Dedupe: a second stopAsync() while one is still draining returns the
364
+ // same promise rather than launching a second session_stop.
365
+ if (this._stopAsyncPromise) return this._stopAsyncPromise;
366
+ const p = new Promise((resolve, reject) => {
367
+ sessionStop.async(this._sess, (err) => {
368
+ if (err) reject(err);
369
+ else resolve();
370
+ });
371
+ });
372
+ this._stopAsyncPromise = p;
373
+ const clear = () => { if (this._stopAsyncPromise === p) this._stopAsyncPromise = null; };
374
+ p.then(clear, clear);
375
+ return p;
376
+ }
377
+
378
+ wait(timeoutMs = 0) {
379
+ if (this._destroyed) return true;
380
+ return sessionWait(this._sess, timeoutMs | 0) !== 0;
381
+ }
382
+
383
+ getStatus() {
384
+ if (this._destroyed) return 0; // kUninitialized
385
+ return sessionGetStatus(this._sess);
386
+ }
387
+
388
+ reset() {
389
+ this._assertAlive();
390
+ const err = {};
391
+ const rc = sessionReset(this._sess, err);
392
+ if (rc !== 0) throw makeFfiError(err);
393
+ }
394
+
395
+ /**
396
+ * Dispatch an on-demand insight prompt; returns the request id. The
397
+ * matching Insight response is delivered asynchronously through the
398
+ * `onInsight` callback supplied at construction time.
399
+ */
400
+ requestInsight(text) {
401
+ this._assertAlive();
402
+ if (typeof text !== 'string') {
403
+ throw new TypeError('requestInsight: text must be a string');
404
+ }
405
+ const err = {};
406
+ // koffi marshals the typed-array view as a writable pointer to the
407
+ // first element and writes the int32_t result into slot 0.
408
+ const out = new Int32Array(1);
409
+ const rc = sessionRequestInsight(this._sess, text, out, err);
410
+ if (rc !== 0) throw makeFfiError(err);
411
+ return out[0];
412
+ }
413
+
414
+ /**
415
+ * Tear down the native session. Returns a promise that resolves once the
416
+ * native teardown has completed — callers serializing a replacement
417
+ * session must await it, because SDK state is process-global and two
418
+ * sessions must not overlap. Idempotent.
419
+ */
420
+ destroy() {
421
+ if (this._destroyed) return this._teardownPromise || Promise.resolve();
422
+ this._destroyed = true;
423
+ // A stopAsync() may still be draining the graph on a koffi worker
424
+ // thread. Deleting the session (and unregistering its trampolines)
425
+ // while that thread is inside session_stop is a use-after-free, so
426
+ // defer the native teardown until the in-flight stop settles. The
427
+ // _destroyed guard already blocks any new stop from starting.
428
+ if (this._stopAsyncPromise) {
429
+ this._teardownPromise = this._stopAsyncPromise.then(
430
+ () => this._teardownNative(),
431
+ () => this._teardownNative());
432
+ } else {
433
+ this._teardownNative();
434
+ this._teardownPromise = Promise.resolve();
435
+ }
436
+ return this._teardownPromise;
437
+ }
438
+
439
+ _teardownNative() {
440
+ if (this._sess === null) return;
441
+ // stop → wait → destroy → unregister. session_destroy joins worker
442
+ // threads, so no SDK thread can call our trampolines after it returns.
443
+ sessionStop(this._sess);
444
+ sessionWait(this._sess, 0);
445
+ sessionDestroy(this._sess);
446
+ this._sess = null;
447
+ this._releaseThunks();
448
+ }
449
+
450
+ // ---------- internal ----------------------------------------------------
451
+
452
+ _assertAlive() {
453
+ if (this._destroyed) throw new Error('Session has been destroyed');
454
+ }
455
+
456
+ _registerThunk(jsFn, protoTypeName, makeAdapter) {
457
+ if (typeof jsFn !== 'function') return null;
458
+ const thunk = koffi.register(makeAdapter(jsFn), protoTypeName);
459
+ this._registeredThunks.push(thunk);
460
+ return thunk;
461
+ }
462
+
463
+ _releaseThunks() {
464
+ for (const t of this._registeredThunks) koffi.unregister(t);
465
+ this._registeredThunks = [];
466
+ }
467
+ }
468
+
469
+ // Wrap a populated smartspectra_error_t into a JS Error with numeric `code`
470
+ // and boolean `retryable`.
471
+ function makeFfiError(err) {
472
+ const msg = err.message || `SmartSpectra error code ${err.code}`;
473
+ const e = new Error(msg);
474
+ e.code = err.code;
475
+ e.retryable = err.retryable !== 0;
476
+ return e;
477
+ }
478
+
479
+ // Internal one-time SDK initialization. Called by the Electron main-process
480
+ // glue in `@smartspectra/node-sdk/main`. Not part of the package's public
481
+ // exports map — host apps don't reach for it.
482
+ function preconfigure(sdkDirectory, deviceId) {
483
+ if (typeof sdkDirectory !== 'string' || sdkDirectory.length === 0) {
484
+ throw new TypeError('preconfigure: directory must be a non-empty string');
485
+ }
486
+ const err = {};
487
+ const rc = preconfigureFn(sdkDirectory, deviceId || null, err);
488
+ if (rc !== 0) throw makeFfiError(err);
489
+ }
490
+
491
+ module.exports = {
492
+ Session,
493
+ preconfigure,
494
+ };