@smartspectra/node-sdk 3.4.0-rc.5 → 3.4.0-rc.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.
package/README.md CHANGED
@@ -106,7 +106,7 @@ the missing package.
106
106
  - Electron desktop app: use `@smartspectra/node-sdk/main`,
107
107
  `@smartspectra/node-sdk/preload`, and `@smartspectra/node-sdk/renderer`.
108
108
  - Headless Node process with a local camera: use `@smartspectra/node-sdk`
109
- directly with `useCamera()`.
109
+ directly with `useCamera(CameraSelection.default)`.
110
110
  - Headless or server-side Node process with host-provided frames: use
111
111
  `useCustomInput()` / `sendFrame()`.
112
112
  - Runnable reference app: [electron-quickstart](https://github.com/Presage-Security/SmartSpectra/tree/main/nodejs/samples/electron-quickstart)
@@ -118,7 +118,7 @@ the missing package.
118
118
  > wrap the `await` calls in an `async` function.
119
119
 
120
120
  ```ts
121
- import { SmartSpectraSDK, breathingMetrics, cardioMetrics, decodeMetrics } from '@smartspectra/node-sdk';
121
+ import { SmartSpectraSDK, CameraSelection, breathingMetrics, cardioMetrics, decodeMetrics } from '@smartspectra/node-sdk';
122
122
 
123
123
  const sdk = new SmartSpectraSDK({
124
124
  apiKey: 'YOUR_API_KEY',
@@ -134,7 +134,7 @@ sdk.on('metrics', (buf, ts) => {
134
134
  sdk.on('error', (code, message, retryable) =>
135
135
  console.error('SmartSpectra error', code, message, 'retryable=', retryable));
136
136
 
137
- sdk.useCamera();
137
+ sdk.useCamera(CameraSelection.default);
138
138
  sdk.start();
139
139
 
140
140
  console.log('Measuring from the default camera. Press Ctrl+C to stop.');
@@ -147,10 +147,30 @@ process.on('SIGINT', async () => {
147
147
  });
148
148
  ```
149
149
 
150
- `useCamera()` opens the default camera and sends frames after `start()`. Pass a
151
- device index or capture dimensions to select another camera; see the [API
152
- reference](docs/api-reference.md#cameraoptions). Use the custom-input path
153
- below only when your app already owns frame capture.
150
+ `useCamera(CameraSelection.default)` selects the default camera while stopped; capture begins at
151
+ `start()`. You can discover cameras without creating an SDK instance:
152
+
153
+ ```ts
154
+ import { CameraSelection, SmartSpectraSDK } from '@smartspectra/node-sdk';
155
+
156
+ const cameras = SmartSpectraSDK.availableCameras();
157
+ if (cameras.length > 0) {
158
+ sdk.useCamera(CameraSelection.byId(cameras[0].id), { width: 1280, height: 720 });
159
+ }
160
+ ```
161
+
162
+ `CameraSelection.default`, `.front`, `.back`, and `.byId(id)` share the camera
163
+ selection contract across SDKs. Explicit requests never fall back. Stop/start/reset
164
+ retain the chosen input. Native Node.js supports camera discovery and typed
165
+ selection on macOS, Linux, and Windows. Linux and Windows cameras may report
166
+ unknown facing or lens type, so select those cameras by discovered ID. On
167
+ Windows, `.front` and `.back` fail with `kInputUnavailable` because facing is
168
+ not reported. See the
169
+ [migration guide](docs/migration-guide.md) and [API reference](docs/api-reference.md#cameraoptions).
170
+
171
+ Electron renderer capture uses browser-owned streams and `useMediaStream()`;
172
+ browser device IDs cannot be used for native camera selection. Use the
173
+ custom-input path below when your app already owns frame capture.
154
174
 
155
175
  ## Custom Input Quickstart
156
176
 
@@ -0,0 +1,57 @@
1
+ // Copyright (C) 2026 Presage Technologies, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Proprietary
3
+ 'use strict';
4
+
5
+ const { SmartSpectraErrorCode } = require('./constants');
6
+
7
+ const CameraSelection = Object.freeze({
8
+ default: Object.freeze({ kind: 'default' }),
9
+ front: Object.freeze({ kind: 'front' }),
10
+ back: Object.freeze({ kind: 'back' }),
11
+ byId: (id) => Object.freeze({ kind: 'byId', id }),
12
+ });
13
+
14
+ function configurationError(message) {
15
+ return Object.assign(new Error(message), {
16
+ code: SmartSpectraErrorCode.kConfigurationFailed, retryable: false,
17
+ });
18
+ }
19
+
20
+ function normalizeCameraSelection(selection) {
21
+ if (!selection || typeof selection !== 'object') {
22
+ throw configurationError('useCamera: expected a CameraSelection');
23
+ }
24
+ switch (selection.kind) {
25
+ case 'default': case 'front': case 'back':
26
+ if (selection.id !== undefined) throw configurationError('Camera ID requires byId selection');
27
+ return Object.freeze({ kind: selection.kind });
28
+ case 'byId':
29
+ if (typeof selection.id !== 'string' || !selection.id || selection.id.includes('\0')) {
30
+ throw configurationError('Camera ID must be a non-empty string without NUL characters');
31
+ }
32
+ return CameraSelection.byId(selection.id);
33
+ default: throw configurationError('Unknown camera selection kind');
34
+ }
35
+ }
36
+
37
+ function normalizeCameraOptions(options) {
38
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
39
+ throw configurationError('useCamera: expected capture options');
40
+ }
41
+ if ('deviceIndex' in options) throw configurationError('Use CameraSelection.byId(id) instead of deviceIndex');
42
+ const copy = {};
43
+ for (const field of ['width', 'height', 'fps', 'frameTransform']) {
44
+ const value = options[field] ?? 0;
45
+ const max = field === 'frameTransform' ? 5 : 2147483647;
46
+ if (!Number.isInteger(value) || value < 0 || value > max) {
47
+ throw configurationError(`Invalid camera ${field}`);
48
+ }
49
+ copy[field] = value;
50
+ }
51
+ if (Boolean(copy.width) !== Boolean(copy.height)) {
52
+ throw configurationError('Camera width and height must be specified together');
53
+ }
54
+ return Object.freeze(copy);
55
+ }
56
+
57
+ module.exports = { CameraSelection, normalizeCameraSelection, normalizeCameraOptions };
package/js/constants.js CHANGED
@@ -3,9 +3,9 @@
3
3
  //
4
4
  // SPDX-License-Identifier: LicenseRef-Proprietary
5
5
  //
6
- // Pure-value exports — public enums and metric bundles. No Node-only
7
- // dependencies so this module can be bundled into a browser renderer
8
- // without dragging in koffi, fs, or path.
6
+ // Public enums and metric bundles. No Node-only dependencies, so this module
7
+ // can be bundled into a browser renderer without dragging in koffi, fs, or
8
+ // path. MetricType is sourced from the generated protobuf module below.
9
9
  //
10
10
  // Integer values must match the SmartSpectra C++ headers and
11
11
  // metric_types.proto wire values; stable across SDK versions.
@@ -14,6 +14,8 @@
14
14
 
15
15
  'use strict';
16
16
 
17
+ const generatedMetricType = require('./messages/generated').presage.smartspectra.MetricType;
18
+
17
19
  const ProcessingStatus = Object.freeze({
18
20
  kUninitialized: 0,
19
21
  kIdle: 1,
@@ -91,6 +93,12 @@ const FrameTransform = Object.freeze({
91
93
  kMirrorVertical: 5,
92
94
  });
93
95
 
96
+ // protobufjs enums include reverse numeric keys. Keep the existing public
97
+ // name-to-number-only shape while deriving every value from metric_types.proto.
98
+ const MetricType = Object.freeze(Object.fromEntries(
99
+ Object.entries(generatedMetricType).filter(([name]) => Number.isNaN(Number(name))),
100
+ ));
101
+
94
102
  // MetricType integer codes grouped by measurement type. Spread into
95
103
  // `requestedMetrics`; omitting `requestedMetrics` is equivalent to passing
96
104
  // `breathingMetrics`.
@@ -138,6 +146,7 @@ module.exports = {
138
146
  ValidationCode,
139
147
  SmartSpectraErrorCode,
140
148
  FrameTransform,
149
+ MetricType,
141
150
  breathingMetrics,
142
151
  cardioMetrics,
143
152
  faceMetrics,
package/js/ffi.js CHANGED
@@ -27,6 +27,8 @@ const lib = koffi.load(resolveNativeLibrary());
27
27
 
28
28
  // Opaque session token. C side: `struct smartspectra_session_s*`.
29
29
  koffi.opaque('smartspectra_session_t');
30
+ koffi.opaque('smartspectra_camera_list_t');
31
+ koffi.struct('smartspectra_camera_info_t', { id: 'const char *', name: 'const char *', facing: 'int', lens_type: 'int' });
30
32
 
31
33
  // smartspectra_error_t — keep field order identical to the C struct; koffi
32
34
  // computes offsets by declaration order.
@@ -102,13 +104,6 @@ koffi.struct('smartspectra_file_options_t', {
102
104
  max_duration_ms: 'int',
103
105
  frame_transform: 'int',
104
106
  });
105
- koffi.struct('smartspectra_camera_options_t', {
106
- device_index: 'int',
107
- width: 'int',
108
- height: 'int',
109
- fps: 'int',
110
- frame_transform: 'int',
111
- });
112
107
 
113
108
  // Non-blocking file source: configure + start, returns immediately. Caller
114
109
  // blocks via sessionWait when it wants end-of-file.
@@ -117,10 +112,48 @@ const sessionStartFile = lib.func(
117
112
  'const char *video_path, const char *timestamps_path, ' +
118
113
  'const smartspectra_file_options_t *opts, _Out_ smartspectra_error_t *out_err)');
119
114
 
120
- const sessionStartCamera = lib.func(
115
+ koffi.struct('smartspectra_camera_options_t', {
116
+ device_index: 'int', width: 'int', height: 'int', fps: 'int', frame_transform: 'int',
117
+ });
118
+ const sessionStartLegacyCamera = lib.func(
121
119
  'int smartspectra_session_start_camera(smartspectra_session_t *sess, ' +
122
120
  'const smartspectra_camera_options_t *opts, _Out_ smartspectra_error_t *out_err)');
123
121
 
122
+ koffi.struct('smartspectra_camera_capture_options_t', {
123
+ width: 'int', height: 'int', fps: 'int', frame_transform: 'int',
124
+ });
125
+ const sessionStartCamera = lib.func(
126
+ 'int smartspectra_session_start_selected_camera(smartspectra_session_t *sess, ' +
127
+ 'int selection_kind, const char *camera_id, const smartspectra_camera_capture_options_t *opts, ' +
128
+ '_Out_ smartspectra_error_t *out_err)');
129
+ const discoverCameras = lib.func('int smartspectra_available_cameras(' +
130
+ '_Out_ smartspectra_camera_list_t **out_list, _Out_ smartspectra_error_t *out_err)');
131
+ const cameraListSize = lib.func('size_t smartspectra_camera_list_size(const smartspectra_camera_list_t *list)');
132
+ const cameraListGet = lib.func('const smartspectra_camera_info_t *smartspectra_camera_list_get(' +
133
+ 'const smartspectra_camera_list_t *list, size_t index)');
134
+ const cameraListFree = lib.func('void smartspectra_camera_list_free(smartspectra_camera_list_t *list)');
135
+
136
+ function availableCameras() {
137
+ const out = [null];
138
+ const error = {};
139
+ if (discoverCameras(out, error) !== 0) throw makeFfiError(error);
140
+ const list = out[0];
141
+ try {
142
+ const cameras = [];
143
+ const count = Number(cameraListSize(list));
144
+ for (let index = 0; index < count; index++) {
145
+ const camera = koffi.decode(cameraListGet(list, index), 'smartspectra_camera_info_t');
146
+ cameras.push({ id: camera.id, name: camera.name,
147
+ facing: camera.facing === 1 ? 'front' : camera.facing === 2 ? 'back' : 'unknown',
148
+ lensType: camera.lens_type === 1 ? 'wideAngle' : camera.lens_type === 2 ? 'ultraWide' :
149
+ camera.lens_type === 3 ? 'telephoto' : 'unknown' });
150
+ }
151
+ return cameras;
152
+ } finally {
153
+ cameraListFree(list);
154
+ }
155
+ }
156
+
124
157
  const sessionStartCustom = lib.func(
125
158
  'int smartspectra_session_start_custom(smartspectra_session_t *sess, ' +
126
159
  'int frame_transform, _Out_ smartspectra_error_t *out_err)');
@@ -343,8 +376,7 @@ class Session {
343
376
  this._takeKeepAlive();
344
377
  }
345
378
 
346
- // options (optional): { deviceIndex, width, height, fps, frameTransform }
347
- startCamera(options = null) {
379
+ startLegacyCamera(options = null) {
348
380
  this._assertAlive();
349
381
  const err = {};
350
382
  const opts = options ? {
@@ -354,7 +386,23 @@ class Session {
354
386
  fps: options.fps | 0,
355
387
  frame_transform: options.frameTransform | 0,
356
388
  } : null;
357
- const rc = sessionStartCamera(this._sess, opts, err);
389
+ const rc = sessionStartLegacyCamera(this._sess, opts, err);
390
+ if (rc !== 0) throw makeFfiError(err);
391
+ this._takeKeepAlive();
392
+ }
393
+
394
+ // Selection and capture settings are separate, matching the C++ API.
395
+ startCamera(selection, options = null) {
396
+ this._assertAlive();
397
+ const err = {};
398
+ const opts = options ? {
399
+ width: options.width | 0,
400
+ height: options.height | 0,
401
+ fps: options.fps | 0,
402
+ frame_transform: options.frameTransform | 0,
403
+ } : null;
404
+ const kind = { default: 0, front: 1, back: 2, byId: 3 }[selection.kind];
405
+ const rc = sessionStartCamera(this._sess, kind, selection.id ?? null, opts, err);
358
406
  if (rc !== 0) throw makeFfiError(err);
359
407
  this._takeKeepAlive();
360
408
  }
@@ -416,8 +464,18 @@ class Session {
416
464
  return sessionGetStatus(this._sess);
417
465
  }
418
466
 
419
- reset() {
467
+ assertSourceSelectionAllowed() {
468
+ const status = this.getStatus();
469
+ if (this._destroyed || this._stopAsyncPromise || (status !== 0 && status !== 1)) {
470
+ throw Object.assign(new Error('Select an input while stopped'), { code: 1, retryable: false });
471
+ }
472
+ }
473
+
474
+ reset(requireStopSettled = false) {
420
475
  this._assertAlive();
476
+ if (requireStopSettled && this._stopAsyncPromise) {
477
+ throw Object.assign(new Error('Await stopAsync before reset'), { code: 1, retryable: false });
478
+ }
421
479
  const err = {};
422
480
  const rc = sessionReset(this._sess, err);
423
481
  if (rc !== 0) throw makeFfiError(err);
@@ -587,6 +645,7 @@ function ensurePreconfigured() {
587
645
 
588
646
  module.exports = {
589
647
  Session,
648
+ availableCameras,
590
649
  preconfigure,
591
650
  ensurePreconfigured,
592
651
  };
package/js/index.d.ts CHANGED
@@ -90,6 +90,31 @@ export declare const FrameTransform: {
90
90
  };
91
91
  export type FrameTransformValue = typeof FrameTransform[keyof typeof FrameTransform];
92
92
 
93
+ /** MetricType integer codes accepted by `requestedMetrics`. Runtime values are generated from protobuf. */
94
+ export declare const MetricType: {
95
+ readonly CHEST_BREATHING: 0;
96
+ readonly ABDOMEN_BREATHING: 1;
97
+ readonly BREATHING_RATE: 2;
98
+ readonly BREATHING_AMPLITUDE: 3;
99
+ readonly APNEA: 4;
100
+ readonly RESPIRATORY_LINE_LENGTH: 5;
101
+ readonly BASELINE: 6;
102
+ readonly INHALE_EXHALE_RATIO: 7;
103
+ readonly GLUTES_MICROMOTION: 8;
104
+ readonly KNEES_MICROMOTION: 9;
105
+ readonly EDA_TRACE: 10;
106
+ readonly FACE_LANDMARKS: 11;
107
+ readonly BLINKING: 12;
108
+ readonly TALKING: 13;
109
+ readonly EXPRESSIONS: 14;
110
+ readonly PULSE_RATE: 15;
111
+ readonly ARTERIAL_PRESSURE_TRACE: 16;
112
+ readonly HRV: 17;
113
+ readonly PULSE_RATE_STRICT: 18;
114
+ readonly BREATHING_RATE_STRICT: 19;
115
+ };
116
+ export type MetricTypeValue = typeof MetricType[keyof typeof MetricType];
117
+
93
118
  /** Options passed to the SmartSpectra constructor. */
94
119
  export interface SmartSpectraOptions {
95
120
  /** API key for server-validated auth. */
@@ -127,9 +152,36 @@ export interface VideoFileOptions {
127
152
  frameTransform?: FrameTransformValue;
128
153
  }
129
154
 
130
- /** Camera capture options for useCamera(). */
155
+ /** Camera request resolved at capture startup. Explicit requests never fall back. */
156
+ export type CameraSelection =
157
+ | Readonly<{ kind: 'default' }>
158
+ | Readonly<{ kind: 'front' }>
159
+ | Readonly<{ kind: 'back' }>
160
+ | Readonly<{ kind: 'byId'; id: string }>;
161
+
162
+ export declare const CameraSelection: {
163
+ readonly default: Readonly<{ kind: 'default' }>;
164
+ readonly front: Readonly<{ kind: 'front' }>;
165
+ readonly back: Readonly<{ kind: 'back' }>;
166
+ readonly byId: (id: string) => Readonly<{ kind: 'byId'; id: string }>;
167
+ };
168
+
169
+ export type CameraFacing = 'front' | 'back' | 'unknown';
170
+
171
+ /** Best-effort classification. Missing metadata and multi-lens cameras are unknown; independent of digital zoom. */
172
+ export type CameraLensType = 'unknown' | 'wideAngle' | 'ultraWide' | 'telephoto';
173
+
174
+ /** Discovery snapshot. IDs are opaque and platform-local; rediscover before reuse. */
175
+ export interface CameraInfo {
176
+ readonly id: string;
177
+ readonly name: string | null;
178
+ readonly facing: CameraFacing;
179
+ readonly lensType: CameraLensType;
180
+ }
181
+
182
+ /** Capture settings, separate from camera selection. */
131
183
  export interface CameraOptions {
132
- /** Camera device index; omit/0 = default device. */
184
+ /** @deprecated Use useCamera(CameraSelection.byId(id), options) instead. */
133
185
  deviceIndex?: number;
134
186
  /** Capture width in px; omit/0 = SDK default. */
135
187
  width?: number;
@@ -163,15 +215,21 @@ export declare class SmartSpectraSDK {
163
215
  /** SDK package version. */
164
216
  static readonly version: string;
165
217
 
218
+ /** No SDK instance, authentication, or capture required. Throws when discovery is unsupported. */
219
+ static availableCameras(): CameraInfo[];
220
+
166
221
  constructor(options?: SmartSpectraOptions);
167
222
 
168
- /** Initialize and begin a custom-input session. */
223
+ /** Initialize and begin the input configured with useCamera, useCustomInput, or useFile. */
169
224
  start(): void;
170
225
  /** Request the session to stop. Idempotent. */
171
226
  stop(): void;
172
227
  /** Async variant of stop() — the native stop blocks until the pipeline drains; prefer this in event-loop-sensitive contexts. */
173
228
  stopAsync(): Promise<void>;
174
- /** Rebuild the processing pipeline after kError; source must be reconfigured before next start(). */
229
+ /**
230
+ * Rebuild after kError. Clears the source unless useCamera(selection, options) opted into retention.
231
+ * After typed selection, throws kInvalidState while stopAsync() is pending; await it before resetting.
232
+ */
175
233
  reset(): void;
176
234
  /**
177
235
  * Block until the session reaches a terminal state (idle or error), or the
@@ -211,6 +269,15 @@ export declare class SmartSpectraSDK {
211
269
  * pumps frames internally on `start()` — no `sendFrame()` needed. Returns
212
270
  * `this` for chaining. Captures in THIS process — for Electron, prefer
213
271
  * the renderer SDK's `useMediaStream()`.
272
+ * Select while stopped; explicit requests never fall back. Selection is
273
+ * resolved at startup and retained across stop/start/reset. Invalid or busy
274
+ * requests preserve the previous source. Pass CameraSelection.default for SDK selection.
275
+ */
276
+ useCamera(selection: CameraSelection, options?: Omit<CameraOptions, 'deviceIndex'>): this;
277
+
278
+ /**
279
+ * Select a camera by legacy index (default 0). Reset clears the source.
280
+ * @deprecated Pass an explicit CameraSelection and separate capture options instead.
214
281
  */
215
282
  useCamera(options?: CameraOptions): this;
216
283
 
@@ -278,16 +345,6 @@ export declare class SmartSpectraSDK {
278
345
  * MetricType integer codes grouped by measurement type. Spread into
279
346
  * `requestedMetrics`; omitting it is equivalent to passing
280
347
  * `breathingMetrics`.
281
- *
282
- * @example
283
- * ```ts
284
- * import { SmartSpectra, breathingMetrics, cardioMetrics } from '@smartspectra/node-sdk';
285
- *
286
- * const ss = new SmartSpectra({
287
- * apiKey: 'YOUR_KEY',
288
- * requestedMetrics: [...breathingMetrics, ...cardioMetrics],
289
- * });
290
- * ```
291
348
  */
292
349
  export declare const breathingMetrics: readonly number[];
293
350
  export declare const cardioMetrics: readonly number[];
package/js/index.js CHANGED
@@ -7,12 +7,14 @@
7
7
 
8
8
  const {
9
9
  SmartSpectraSDK,
10
+ CameraSelection,
10
11
  SmartSpectraLogLevel,
11
12
  ProcessingStatus,
12
13
  PixelFormat,
13
14
  ValidationCode,
14
15
  SmartSpectraErrorCode,
15
16
  FrameTransform,
17
+ MetricType,
16
18
  breathingMetrics,
17
19
  cardioMetrics,
18
20
  faceMetrics,
@@ -45,12 +47,14 @@ function decodeMetrics(buf) {
45
47
 
46
48
  module.exports = {
47
49
  SmartSpectraSDK,
50
+ CameraSelection,
48
51
  SmartSpectraLogLevel,
49
52
  ProcessingStatus,
50
53
  PixelFormat,
51
54
  ValidationCode,
52
55
  SmartSpectraErrorCode,
53
56
  FrameTransform,
57
+ MetricType,
54
58
  breathingMetrics,
55
59
  cardioMetrics,
56
60
  faceMetrics,