@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.
@@ -0,0 +1,113 @@
1
+ // resolve-native.js
2
+ // Copyright (C) 2026 Presage Technologies, Inc.
3
+ //
4
+ // SPDX-License-Identifier: LicenseRef-Proprietary
5
+ //
6
+ // Picks the right libsmartspectra_capi.{dll,dylib,so} for the current
7
+ // platform+arch and returns a path that koffi.load() can consume.
8
+ //
9
+ // The native runtime closure (shim + libsmartspectra + OpenCV + Vulkan +
10
+ // graph data) ships in per-platform packages — `@smartspectra/node-sdk-<plat>-<arch>`
11
+ // — declared as regular (HARD) dependencies of the main package. There is no
12
+ // postinstall download and no install script in the published tarball.
13
+ //
14
+ // Why hard deps and not optionalDependencies gated by os/cpu: GitLab's npm
15
+ // registry strips optionalDependencies/os/cpu from the packument, so optional
16
+ // gating never resolves. As hard deps every platform installs on every host,
17
+ // and this resolver picks the one matching process.platform/process.arch. See
18
+ // .session-logs/nodejs-bundle-all-platforms.md and package.json#_nativeRuntime_doc.
19
+ //
20
+ // Resolution order:
21
+ // 1. SMARTSPECTRA_CAPI_PATH env var — full path to the shared library.
22
+ // The sole dev/override escape hatch (e.g. running against a local
23
+ // source build, or a custom-bundled closure). In a source checkout the
24
+ // platform packages are NOT installed, so dev uses this override.
25
+ // 2. The host platform package's root, located via require.resolve. The shim
26
+ // sits at the package root; the rest of the closure sits beside it with
27
+ // install_names / RPATHs pre-rewritten so it loads with zero env vars.
28
+ //
29
+ // If neither resolves, we throw a single clear error naming the missing
30
+ // platform package and the SMARTSPECTRA_CAPI_PATH override.
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+
37
+ const LIB_BASENAME = {
38
+ darwin: 'libsmartspectra_capi.dylib',
39
+ linux: 'libsmartspectra_capi.so',
40
+ win32: 'smartspectra_capi.dll',
41
+ };
42
+
43
+ // `<platform>-<arch>` — matches process.platform / process.arch and the
44
+ // per-platform package suffix.
45
+ function platformArch() {
46
+ return `${process.platform}-${process.arch}`;
47
+ }
48
+
49
+ function platformPackageName() {
50
+ return `@smartspectra/node-sdk-${platformArch()}`;
51
+ }
52
+
53
+ function resolveNativeLibrary() {
54
+ const libName = LIB_BASENAME[process.platform];
55
+ if (!libName) {
56
+ throw new Error(
57
+ `@smartspectra/node-sdk: unsupported platform ${process.platform}. ` +
58
+ `Supported: darwin (macOS), linux, win32 (Windows).`);
59
+ }
60
+
61
+ const override = process.env.SMARTSPECTRA_CAPI_PATH;
62
+ if (override) {
63
+ if (!fs.existsSync(override)) {
64
+ throw new Error(
65
+ `@smartspectra/node-sdk: SMARTSPECTRA_CAPI_PATH=${override} ` +
66
+ `points at a file that does not exist.`);
67
+ }
68
+ return override;
69
+ }
70
+
71
+ const platformPkg = platformPackageName();
72
+ let pkgRoot;
73
+ try {
74
+ // Resolve the package's own package.json, then take its directory —
75
+ // require.resolve(pkgName) would need a "main"/"exports" entry the
76
+ // binary-only package intentionally lacks.
77
+ pkgRoot = path.dirname(require.resolve(`${platformPkg}/package.json`));
78
+ } catch (e) {
79
+ // A corrupt install (broken symlink, permissions) surfaces as something
80
+ // other than a missing module — don't bury its real cause under the
81
+ // "not installed" guidance, which would send the user down the wrong path.
82
+ if (e && e.code !== 'MODULE_NOT_FOUND') {
83
+ throw new Error(
84
+ `@smartspectra/node-sdk: failed to resolve the native runtime package ` +
85
+ `"${platformPkg}" (${e.code || 'unknown error'}): ${e.message}`);
86
+ }
87
+ throw new Error(
88
+ `@smartspectra/node-sdk: the native runtime package "${platformPkg}" is not installed.\n` +
89
+ `It is a regular dependency of @smartspectra/node-sdk, so this usually means your ` +
90
+ `platform (${platformArch()}) is unsupported, or the install was incomplete ` +
91
+ `(e.g. \`npm install --omit=optional\` does NOT skip it, but a partial/offline ` +
92
+ `install might).\n` +
93
+ `Supported platforms: darwin-arm64, linux-x64, linux-arm64, win32-x64 ` +
94
+ `(glibc only — musl/Alpine Linux is not supported).\n` +
95
+ `To point at a custom build, set SMARTSPECTRA_CAPI_PATH to the shared library path.`);
96
+ }
97
+
98
+ const libPath = path.join(pkgRoot, libName);
99
+ if (!fs.existsSync(libPath)) {
100
+ throw new Error(
101
+ `@smartspectra/node-sdk: "${platformPkg}" is installed but ${libName} is missing from it ` +
102
+ `(${libPath}). The package may be corrupt — try reinstalling.`);
103
+ }
104
+ return libPath;
105
+ }
106
+
107
+ module.exports = {
108
+ resolveNativeLibrary,
109
+ // Exposed for diagnostics and tests.
110
+ LIB_BASENAME,
111
+ platformArch,
112
+ platformPackageName,
113
+ };
@@ -0,0 +1,293 @@
1
+ // smartspectra.js
2
+ // Copyright (C) 2026 Presage Technologies, Inc.
3
+ //
4
+ // SPDX-License-Identifier: LicenseRef-Proprietary
5
+
6
+ 'use strict';
7
+
8
+ const { Session } = require('./ffi');
9
+ const {
10
+ ProcessingStatus,
11
+ PixelFormat,
12
+ ValidationCode,
13
+ SmartSpectraErrorCode,
14
+ FrameTransform,
15
+ breathingMetrics,
16
+ cardioMetrics,
17
+ faceMetrics,
18
+ micromotionMetrics,
19
+ edaMetrics,
20
+ defaultSupportedMetrics,
21
+ } = require('./constants');
22
+ const { version: PACKAGE_VERSION } = require('../package.json');
23
+
24
+ const SUPPORTED_EVENTS = new Set([
25
+ 'processingStatus',
26
+ 'validationStatus',
27
+ 'metrics',
28
+ 'accumulatedMetrics',
29
+ 'insight',
30
+ 'error',
31
+ 'frameSentThrough',
32
+ 'videoOutput',
33
+ ]);
34
+
35
+ // FrameTransform.kNone — kept here so the constructor doesn't depend on the
36
+ // FrameTransform object declared later.
37
+ const DEFAULT_FRAME_TRANSFORM = 0;
38
+
39
+ class SmartSpectraSDK {
40
+ /** SDK package version (matches `package.json#version`). */
41
+ static get version() { return PACKAGE_VERSION; }
42
+
43
+ /**
44
+ * @param {Object} [options]
45
+ * @param {string} [options.apiKey]
46
+ * @param {number[]} [options.requestedMetrics]
47
+ * MetricType integer codes. Defaults to `breathingMetrics`.
48
+ * @param {boolean} [options.enableAccumulatedOutput=false]
49
+ */
50
+ constructor(options = {}) {
51
+ this._options = {
52
+ apiKey: options.apiKey ?? '',
53
+ requestedMetrics: options.requestedMetrics,
54
+ enableAccumulatedOutput: !!options.enableAccumulatedOutput,
55
+ };
56
+ this._listeners = new Map();
57
+ // Pending input source set by useCustomInput()/useCamera()/useFile() and
58
+ // consumed by start(). Shape: { kind: 'custom'|'camera'|'file', ... }.
59
+ // The last use* call wins.
60
+ this._source = null;
61
+ this._session = null; // lazy: created on start()
62
+ }
63
+
64
+ // ---------- public API --------------------------------------------------
65
+
66
+ /**
67
+ * Register a callback for a named event. Returns `this` for chaining.
68
+ * One listener per event; re-registration replaces.
69
+ */
70
+ on(event, callback) {
71
+ if (typeof event !== 'string' || typeof callback !== 'function') {
72
+ throw new TypeError('on(event: string, callback: Function)');
73
+ }
74
+ if (!SUPPORTED_EVENTS.has(event)) {
75
+ throw new RangeError(`on(): unsupported event ${event}`);
76
+ }
77
+ this._listeners.set(event, callback);
78
+ return this;
79
+ }
80
+
81
+ /**
82
+ * Select the custom frame-push input source (you push frames via
83
+ * sendFrame() after start()). Returns `this` for chaining.
84
+ *
85
+ * @param {number} [frameTransform=FrameTransform.kNone]
86
+ * Spatial transform applied to every pushed frame.
87
+ */
88
+ useCustomInput(frameTransform = DEFAULT_FRAME_TRANSFORM) {
89
+ this._source = { kind: 'custom', frameTransform: frameTransform | 0 };
90
+ return this;
91
+ }
92
+
93
+ /**
94
+ * Select a live camera as the input source. The SDK opens the camera and
95
+ * pumps frames internally on start() — no sendFrame() needed. Returns
96
+ * `this` for chaining.
97
+ *
98
+ * @param {Object} [options] { deviceIndex?, width?, height?, fps?, frameTransform? }
99
+ *
100
+ * Captures from a camera in THIS process (headless / Node use). In an
101
+ * Electron app the camera is normally owned by the renderer via
102
+ * getUserMedia — use the renderer SDK's useMediaStream() there instead.
103
+ */
104
+ useCamera(options = null) {
105
+ this._source = { kind: 'camera', options };
106
+ return this;
107
+ }
108
+
109
+ /**
110
+ * Select a pre-recorded video file as the input source. start() begins
111
+ * playback (non-blocking — it runs on SDK worker threads); use
112
+ * waitUntilComplete() or watch the `'processingStatus'` event for the idle
113
+ * transition to detect end-of-file. Returns `this` for chaining.
114
+ *
115
+ * @param {string} videoPath
116
+ * @param {Object} [options]
117
+ * { timestampsPath?, interframeDelayMs?, startOffsetMs?, maxDurationMs?, frameTransform? }
118
+ */
119
+ useFile(videoPath, options = null) {
120
+ if (typeof videoPath !== 'string' || !videoPath) {
121
+ throw new TypeError('useFile: videoPath must be a non-empty string');
122
+ }
123
+ this._source = { kind: 'file', videoPath, options };
124
+ return this;
125
+ }
126
+
127
+ /**
128
+ * Initialize the session and start the configured input source. Returns
129
+ * once running — for custom input the graph is armed for sendFrame(); for
130
+ * camera/file the SDK pumps frames itself. Configure a source with
131
+ * useCustomInput() / useCamera() / useFile() first.
132
+ */
133
+ start() {
134
+ const source = this._source;
135
+ if (!source) {
136
+ throw new Error(
137
+ 'start(): no input source configured. Call useCustomInput(), ' +
138
+ 'useCamera(), or useFile() first.');
139
+ }
140
+ this._ensureSession();
141
+ switch (source.kind) {
142
+ case 'custom':
143
+ this._session.startCustom(source.frameTransform);
144
+ break;
145
+ case 'camera':
146
+ this._session.startCamera(source.options);
147
+ break;
148
+ case 'file':
149
+ this._session.startFile(
150
+ source.videoPath,
151
+ (source.options && source.options.timestampsPath) || null,
152
+ source.options);
153
+ break;
154
+ }
155
+ }
156
+
157
+ stop() {
158
+ if (!this._session) return;
159
+ this._session.stop();
160
+ }
161
+
162
+ /**
163
+ * Async variant of `stop()`. The underlying C++ stop blocks until the
164
+ * processing pipeline drains — can take multiple seconds. Prefer this in
165
+ * event-loop-sensitive contexts (e.g. the Electron main process serving
166
+ * IPC) so the runtime stays responsive.
167
+ */
168
+ stopAsync() {
169
+ if (!this._session) return Promise.resolve();
170
+ return this._session.stopAsync();
171
+ }
172
+
173
+ /**
174
+ * Recover from a kError state. Rebuilds the internal graph. The input
175
+ * source is discarded — call useCustomInput() / useCamera() / useFile()
176
+ * AND start() again to drive new input.
177
+ */
178
+ reset() {
179
+ if (!this._session) return;
180
+ this._session.reset();
181
+ this._source = null;
182
+ }
183
+
184
+ /**
185
+ * Block until the session reaches a terminal state (idle or error), or the
186
+ * timeout elapses. Returns true if the session settled, false on timeout.
187
+ * `timeoutMs <= 0` (the default) waits indefinitely. With no active session
188
+ * there is nothing to wait for, so it returns true immediately.
189
+ *
190
+ * Useful after useFile() + start() to block until end-of-file.
191
+ */
192
+ waitUntilComplete(timeoutMs = 0) {
193
+ if (!this._session) return true;
194
+ return this._session.wait(timeoutMs);
195
+ }
196
+
197
+ /**
198
+ * Dispatch an on-demand insight prompt against the running session.
199
+ * Returns the request id; the matching Insight response is delivered
200
+ * asynchronously through the `'insight'` event with the same id.
201
+ */
202
+ requestInsight(text) {
203
+ if (typeof text !== 'string') {
204
+ throw new TypeError('requestInsight: text must be a string');
205
+ }
206
+ this._ensureSession();
207
+ return this._session.requestInsight(text);
208
+ }
209
+
210
+ /** Current ProcessingStatus integer value (kUninitialized=0..kError=5). */
211
+ get processingStatus() {
212
+ return this._session ? this._session.getStatus() : 0;
213
+ }
214
+
215
+ /**
216
+ * Push a raw video frame. Requires useCustomInput() + start() first.
217
+ * Returns true on success; throws on input-validation or SDK-level
218
+ * failure.
219
+ */
220
+ sendFrame(buffer, width, height, strideBytes, pixelFormat, timestampUs) {
221
+ if (!this._session || !this._source || this._source.kind !== 'custom') {
222
+ throw new Error('sendFrame(): call useCustomInput() + start() first');
223
+ }
224
+ return this._session.sendFrame(
225
+ buffer, width, height, strideBytes, pixelFormat, timestampUs);
226
+ }
227
+
228
+ /**
229
+ * Tear down the session. Idempotent. Always call on shutdown so the SDK
230
+ * worker threads join cleanly before the koffi trampolines are released.
231
+ * Returns a promise that resolves once native teardown completes (a
232
+ * stopAsync() in flight defers it); await it before standing up a
233
+ * replacement session, since SDK state is process-global.
234
+ */
235
+ destroy() {
236
+ if (!this._session) return Promise.resolve();
237
+ const teardown = this._session.destroy();
238
+ this._session = null;
239
+ this._source = null;
240
+ return teardown;
241
+ }
242
+
243
+ // ---------- internal ----------------------------------------------------
244
+
245
+ _ensureSession() {
246
+ if (this._session) return;
247
+ // Listeners may be added/replaced after start(), so the lookup
248
+ // happens at emit time rather than capture time.
249
+ this._session = new Session(this._options, {
250
+ onStatus: (status) => this._emit('processingStatus', status),
251
+ onValidation: (code, hint, ts) =>
252
+ this._emit('validationStatus', code, Number(ts), hint),
253
+ onMetrics: (buf, ts) => this._emit('metrics', buf, Number(ts)),
254
+ onError: (code, message, retryable) =>
255
+ this._emit('error', code, message, retryable),
256
+ onFrame: (sent, ts) =>
257
+ this._emit('frameSentThrough', sent, Number(ts)),
258
+ onAccumulatedMetrics: (buf, ts) =>
259
+ this._emit('accumulatedMetrics', buf, Number(ts)),
260
+ onInsight: (buf, requestId) =>
261
+ this._emit('insight', buf, requestId),
262
+ onVideoOutput: (buf, width, height, stride, pixelFormat, ts) =>
263
+ this._emit('videoOutput', buf, width, height, stride, pixelFormat, Number(ts)),
264
+ });
265
+ }
266
+
267
+ _emit(event, ...args) {
268
+ const cb = this._listeners.get(event);
269
+ if (!cb) return;
270
+ try {
271
+ cb(...args);
272
+ } catch (e) {
273
+ // Isolate listener exceptions so a user-code throw on a
274
+ // worker-thread callback doesn't tear down the SDK.
275
+ console.error(`SmartSpectraSDK: listener for '${event}' threw:`, e);
276
+ }
277
+ }
278
+ }
279
+
280
+ module.exports = {
281
+ SmartSpectraSDK,
282
+ ProcessingStatus,
283
+ PixelFormat,
284
+ ValidationCode,
285
+ SmartSpectraErrorCode,
286
+ FrameTransform,
287
+ breathingMetrics,
288
+ cardioMetrics,
289
+ faceMetrics,
290
+ micromotionMetrics,
291
+ edaMetrics,
292
+ defaultSupportedMetrics,
293
+ };
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@smartspectra/node-sdk",
3
+ "version": "3.2.0-rc.6",
4
+ "description": "Node.js (Electron) FFI binding for SmartSpectra vitals measurement",
5
+ "author": "Presage Technologies, Inc.",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "main": "js/index.js",
8
+ "types": "js/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./js/index.d.ts",
12
+ "default": "./js/index.js"
13
+ },
14
+ "./main": {
15
+ "types": "./js/main/index.d.ts",
16
+ "default": "./js/main/index.js"
17
+ },
18
+ "./preload": "./js/preload/index.js",
19
+ "./renderer": {
20
+ "types": "./js/renderer/index.d.ts",
21
+ "default": "./js/renderer/index.js"
22
+ },
23
+ "./messages": {
24
+ "types": "./js/messages/index.d.ts",
25
+ "default": "./js/messages/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "js/**/*.js",
31
+ "js/**/*.d.ts",
32
+ "LICENSE",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "prepare": "node scripts/gen-proto.cjs",
37
+ "build:proto": "node scripts/gen-proto.cjs",
38
+ "test": "node test/smoke.js",
39
+ "test:models-allowlist": "node test/models-allowlist.js"
40
+ },
41
+ "keywords": [
42
+ "smartspectra",
43
+ "vitals",
44
+ "physiology",
45
+ "electron",
46
+ "nodejs",
47
+ "ffi",
48
+ "koffi"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/Presage-Security/SmartSpectra.git"
53
+ },
54
+ "homepage": "https://smartspectra.presagetech.com/",
55
+ "bugs": {
56
+ "url": "https://github.com/Presage-Security/SmartSpectra/issues"
57
+ },
58
+ "publishConfig": {
59
+ "registry": "https://registry.npmjs.org/"
60
+ },
61
+ "engines": {
62
+ "node": ">=18.0.0"
63
+ },
64
+ "_nativeRuntime_doc": "Per-platform native packages (@smartspectra/node-sdk-<plat>-<arch>) are injected into dependencies at publish time (absent here so source installs don't fetch them). Why hard deps, not optionalDependencies: see js/resolve-native.js.",
65
+ "dependencies": {
66
+ "koffi": "^2.10.0",
67
+ "protobufjs": "^7.5.0",
68
+ "@smartspectra/node-sdk-linux-x64": "3.2.0-rc.6",
69
+ "@smartspectra/node-sdk-linux-arm64": "3.2.0-rc.6",
70
+ "@smartspectra/node-sdk-darwin-arm64": "3.2.0-rc.6",
71
+ "@smartspectra/node-sdk-win32-x64": "3.2.0-rc.6"
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^20.0.0",
75
+ "protobufjs-cli": "^1.1.3"
76
+ },
77
+ "_overrides_doc": "tmp is pulled in transitively (protobufjs-cli → tmp) as a build-only devDependency; it is never a runtime dependency and is not shipped to consumers (the published tarball ships js/ + prebuilds only, no node_modules). Pinned to an exact version (not a >= range) to clear GHSA-ph9p-34f9-6g65 (path traversal, affects tmp <0.2.6) AND to keep installs deterministic — an open range would let a future install pull a brand-new, not-yet-vetted release. Bump deliberately. Drop this once protobufjs-cli ships a release whose tmp range is already >=0.2.6.",
78
+ "overrides": {
79
+ "tmp": "0.2.6"
80
+ }
81
+ }