Skip to main content

Core API — @saccadejs/core

The jsPsych-agnostic library: a camera, a face landmarker, an ONNX embedding model, a ridge calibration, and a timing loopback. The extension and the plugins are thin wrappers over what is on this page.

import { SaccadeTracker, runCalibration, runValidation, runLoopback } from "@saccadejs/core";

From a script tag the browser bundle defines one global, Saccade, holding the same named exports: new Saccade.SaccadeTracker(), await Saccade.runLoopback(tracker).

Times are performance.now() milliseconds. Coordinates are viewport fractions, 0–1, origin top left; converting to pixels is the caller's job. Nothing touches the camera until init().

SaccadeTracker

new SaccadeTracker(options?: SaccadeTrackerOptions)
OptionTypeDefaultMeaning
assetsSaccadeAssets{}Model and runtime URLs. See Hosting the assets.
video{ width?, height? }640 × 480getUserMedia ideals, user-facing camera.
ttanumber5Embeddings averaged before predicting.
executionProviders("webgpu" | "wasm")[]["webgpu", "wasm"]ONNX Runtime providers, tried in order.
onFrame(f: TrackerFrame) => voidEquivalent to calling onFrame() after construction.
onProgress(p: SaccadeProgress) => voidCalled during init() as each stage starts: camera, mediapipe, landmarker, ort, model, session, ready. The model stage also reports loaded and total bytes of the ONNX download, so you can show a progress bar.
streamMediaStreamUse this camera stream instead of calling getUserMedia. The tracker will not stop a stream it did not open.
modelEmbeddingModelSubstitute the embedding model, for tests or a pre-warmed session.

Members

MemberSignatureNotes
init(): Promise<InitResult>Requests the camera, loads MediaPipe and ONNX, warms them up. Idempotent. Rejects if the camera is denied.
initializedboolean (getter)
videoHTMLVideoElement (readonly)The live camera element, unmirrored. Mirror it with CSS if you show it; never mirror the pixels the model sees. Keep it in the document: Chrome delivers camera frames only for a rendered video, so hide it with opacity: 0 / a 2×2 px size, never display: none or by unmounting it. The tracker re-attaches it to an invisible holder on document.body if it is detached.
start(): voidStarts the frame loop. Safe before init().
stop(): voidStops the loop. The camera stays open.
dispose(): voidStops everything and releases the camera. Not reversible.
runningboolean (getter)
onFrame(cb: (f: TrackerFrame) => void) => () => voidSubscribe to every frame; returns the unsubscribe function.
nextFrame(): Promise<TrackerFrame>
nextEmbedding(): Promise<Float32Array | null>
nextGaze(): Promise<Gaze | null>
addCalibrationPoint(target: Gaze, embeddings: Float32Array[]): voidAdds one observation. Does not fit.
clearCalibration(): void
getCalibrationPoints(): CalPoint[]
fitCalibration(opts?: { lambda?, center? }) => { lambda, nPoints } | nullSolves the ridge map from the points added so far. null when there is nothing to fit.
calibratedboolean (getter)gaze stays null until this is true.
getKernel(): Float32Array | nullThe fitted 256-long kernel, x and y interleaved.
setTta / getTta(n: number): void / (): number
getCurrentGaze(): { gaze: Gaze; time: FrameTime } | null
sampleLuminance(): numberMean luminance of the current camera frame.
interface InitResult {
ep: "webgpu" | "wasm"; // the execution provider that loaded
videoWidth: number;
videoHeight: number;
}

TrackerFrame

One per camera frame, whether or not a face was found.

interface TrackerFrame {
gaze: Gaze | null; // viewport fractions; null until calibrated
faceFound: boolean;
crop: Uint8Array | null; // 144 x 36 grayscale, row-major
embedding: Float32Array | null; // 128 values, this frame only
meanEmbedding: Float32Array | null; // the TTA mean, which `gaze` came from
timings: { landmark: number; crop: number; embed: number; total: number; wait?: number };
time: FrameTime;
fps: number; // smoothed
error: string | null;
}

interface Gaze { x: number; y: number } // 0-1, origin top-left

timings is per-stage wall time in ms, diagnostic only.

FrameTime

FieldTypeMeaning
capturenumberThe frame's capture time: the sample time to record with a gaze estimate.
source"captureTime" | "receiveTime" | "callback"Which metadata field supplied capture. "callback" means the browser gave no video-frame metadata at all.
receivenumber | nullVideoFrameCallbackMetadata.receiveTime, if present.
presentedFramesnumber | nullThe camera's frame counter, if present.
droppednumber | nullFrames the camera presented but the loop never saw, since the previous frame.
callbacknumberWhen JavaScript received the frame.
emitnumberWhen the prediction became available.
meanCapturenumber | nullMean capture of the TTA ring buffer: the time the smoothed gaze refers to. Equals capture when tta is 1. Use this one.

What capture cannot see is display lag plus camera lag, which is what runLoopback measures.

Calibration and validation helpers

Both walk a list of targets and call back into your UI. They contain no DOM of their own.

interface CollectOptions { settleMs: number; captureMs: number; timeoutMs?: number }
interface TargetUi { showTarget: (t: Gaze | null, phase: "settle" | "capture") => void }

For each target: showTarget(target, "settle"), wait settleMs, showTarget(target, "capture"), collect for captureMs, then showTarget(null, …) at the end.

timeoutMs (default 5000, 0 to wait indefinitely) is the stall guard: if one camera frame takes longer than that — a <video> the page stopped rendering, a camera another program took, a track that ended — the run rejects with no camera frames for 5000 ms instead of waiting forever on a target that never moves. The plugins put that message on screen.

runCalibration(tracker, targets: Gaze[], opts: CollectOptions, ui: TargetUi): Promise<CalPoint[]>

interface CalPoint { target: Gaze; embeddings: Float32Array[]; meanEmbedding: Float32Array }

runCalibration collects embeddings and adds them to the tracker. It does not fit: call tracker.fitCalibration() afterwards.

runValidation(
tracker,
targets: Gaze[],
opts: CollectOptions & { roiRadiusPx: number; viewport: { width: number; height: number } },
ui: TargetUi,
): Promise<ValidationResult>

interface ValidationResult {
points: {
target: Gaze;
samples: { gaze: Gaze; time: number }[];
meanGaze: Gaze;
errorViewport: number; // Euclidean error in viewport fractions
errorPx: number; // the same error in pixels
percentInRoi: number; // 0-100, share of samples within roiRadiusPx
}[];
medianErrorViewport: number;
meanErrorPx: number;
percentInRoi: number;
}

runValidation collects gaze rather than embeddings, so the tracker must already be calibrated. Targets that produced no gaze are reported with NaN errors rather than dropped.

Grids

defaultGrid13(): Gaze[]     // 3x3 at 5/50/95% plus 4 inner points at 27.5/72.5%
trainingGrid20(): Gaze[] // 4x5, denser
validationGrid9(): Gaze[] // 3x3 at 15/50/85%, off the calibration grid
lambdaFor(nPoints: number): number // 3 when nPoints <= 9, else 1

Validate on validationGrid9(), not on the calibration points.

runLoopback

runLoopback(tracker: SaccadeTracker, opts?: LoopbackOptions): Promise<LoopbackResult>

Measures display lag plus camera lag as one number by flashing the page and watching it with the camera. The tracker must be initialised; if it is running, gaze processing pauses for the duration and resumes after.

OptionDefaultMeaning
durationMs15000
gapMinMs / gapMaxMs500 / 1000Random interval between flips.
levels["#000", "#fff"][dark, light] CSS colours. ["#333", "#ccc"] is gentler and needs a longer run.
seedrandomFor a reproducible schedule.
containera full-viewport div on document.bodyWhere to draw.
onProgress(fractionDone: number) => void.
Result fieldMeaning
lagMsThe correction: display lag plus camera lag.
plateauWidthMsWidth of the range of lags consistent with every edge.
peakDHeight of the edge-difference peak, 0–1.
nEdgesUsable edges found.
halves{ first, second }, the estimate from each half of the run.
cameraPeriodMs, cameraJitterMs, droppedFramesCamera health.
rafPeriodMs, rafMaxMsAnimation-frame interval and its worst case.
clockSourceThe FrameTime.source in force. Anything but "captureTime" makes lagMs advisory.
verdict"OK" | "INCONCLUSIVE" | "UNRELIABLE".
reasonWhy, when the verdict is not "OK".
flips, samplesRaw flip times and per-frame luminance, for re-analysis.
seed, settingsWhat was actually run.

The verdict is "OK" when peakD is at least 0.5, the plateau is at most 34 ms, and the halves agree: within 8 ms when there are at least 15 edges per half, within 20 ms otherwise.

The estimator is exported so stored flips and samples can be re-analysed offline: estimateLagEdges, estimateLag, refineLag, sparseSchedule, mSequence, seededRandom, intervalStats, splitHalves, stimulusAt.

Assets

interface SaccadeAssets {
modelUrl?: string; // eye_embedding.onnx
ortWasmUrl?: string; // directory URL for onnxruntime-web's .wasm/.mjs
mediapipeWasmUrl?: string; // directory URL for @mediapipe/tasks-vision wasm
faceLandmarkerUrl?: string; // face_landmarker.task
ortModuleUrl?: string; // script-tag build only
mediapipeModuleUrl?: string; // script-tag build only
}

Defaults resolve relative to the package under a bundler, and to jsDelivr otherwise. The pinned versions and default URLs are exported as ORT_VERSION, MEDIAPIPE_VERSION, DEFAULT_ORT_WASM_URL, DEFAULT_MEDIAPIPE_WASM_URL and DEFAULT_FACE_LANDMARKER_URL. See Hosting the assets.

Lower-level exports

ExportWhat
cropBBox(lm, W, H)The eye bounding box from a landmark list.
rgbaToGray, resizeBilinearCv, claheThe three preprocessing steps, each matching OpenCV byte for byte.
extractEyeCrop(frameRGBA, W, H, lm)All of the above in one call.
solveRidge, predict, calWeight, fitRidgeThe calibration mathematics.
Landmarker, createLandmarker(opts?)The MediaPipe wrapper on its own.
OrtEmbeddingModel, StubEmbeddingModelThe ONNX model, and a stub for tests.
PipelineThe frame loop without camera management.
loadOrt, loadVision, presetModules, modelUrlAsset loading, and a way to inject already-loaded modules.
EYE_W (144), EYE_H (36), EMB_DIM (128), CENTER, CAL_HEADConstants.
DEFAULT_SETTLE_MS (1000), DEFAULT_CAPTURE_MS (500)Calibration timing defaults.
median, meanEmbeddingSmall helpers.
versionThe package version, as published.