Install

One ES module from the CDN, a video source, and one call.

Which CDN

One ES module, served per environment. The origin you import from decides which environment you measure against: the library derives the analysis server from that same origin, so there is nothing else to configure.

EnvironmentModule URL
Productionhttps://cdn.saphere.ai/realtime-measure/v1/main.js
Testhttps://cdn.test.saphere.ai/realtime-measure/v1/main.js

The shortest working page

<!doctype html>
<video id="camera" playsinline muted></video>

<script type="module">
    import RealtimeMeasure from "https://cdn.saphere.ai/realtime-measure/v1/main.js"

    const video = document.getElementById("camera")
    video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
    await video.play()

    const measure = RealtimeMeasure.create({
        video: "#camera",
        retrieveAccessToken: () => fetch("/my-server/realtime-token").then(response => response.text()),
        onEvent: event => {
            if (event.type === "measure:variables")
                console.log(event.variables.heartRate)
        }
    })

    await measure.start()
</script>

The module also registers itself as window.IVirtual.RealtimeMeasure and fires a realtime-measure:ready CustomEvent on window, carrying the class in detail. That is the way in for a page that cannot write an import: give a module script tag the CDN URL as its src, and listen for the event rather than reading the global, which is not guaranteed to be set when your own inline script runs. The tag has to carry type="module" — the bundle is an ES module, and a classic script tag stops on SyntaxError: Cannot use 'import.meta' outside a module without running at all. The bundle also refuses to be evaluated twice: several measurements come from several create() calls on the one class.

What the browser has to provide

Four requirements, and the first two rule out some browsers outright.

  • A secure context. The page must be served over https (or be localhost): both the camera and WebTransport refuse anything else.
  • WebTransport. Chromium-based browsers and Firefox serve it. WebKit does not interoperate with our server today — that is Safari, and every WebView on iOS, which are all WebKit whatever their badge says. On those, the models load and the session never opens: you get realtime:attached, then realtime:error. If you need those users covered, Saphere Scan measures over a WebSocket they do serve.
  • WebGL2, which the face detector runs on. Without it start() fails rather than falling back.
  • blob: in your CSP, under worker-src or the script-src it falls back to. The compression workers come from the CDN, and a browser refuses a worker script from another origin however permissive its CORS headers are — so the library loads each one through a one-line blob: script of your page’s own origin. Without the directive, start() fails on what Firefox reports as a blocked inline script, which points at the wrong thing.

Test on the browser you ship to, early

The WebTransport line above is the one that surprises integrations late. It costs a minute to check: load your page, call start(), and watch for realtime:error.

What start() does, and what it costs

start() attaches the source declared by the video option, loads the face-detection model and the compression workers, obtains an access token and opens the session. The first call costs seconds and several megabytes; the ones after it do not — what is loaded stays loaded, so stopping and measuring again reloads nothing. realtime:attached marks the passage from one to the other, and carries the dimensions of the source.

Only the models the settings ask for are downloaded, and bodyDetection is on by default: the upper body is what the breathing rate is read from, in the rise and fall of the chest. It costs a second model at start-up and one more compression per frame. Turning it off spares both, and the breathing rate then falls back on the modulation the pulse wave carries, which is the more fragile of the two readings. Turned on again later, the model loads then: the torso is simply missing from getDetection() until it arrives.

The access token is asked for after the loading, at every start(): it is single-use and the handshake consumes it, so nothing is minted until the chain is ready to use it.

await measure.start()    // attaches, loads, opens the session
// … later
await measure.stop()     // waits for the session to be closed; the instance stays loaded
measure.destroy()        // releases the model, the workers and the session

Two ways to stop

stop() waits. Its promise resolves once capture has stopped, the frames it had already taken have been sent, the streams are closed and the session is closed — which is also when realtime:stopped reaches your handler, not before. Calling it twice waits for the same closing, and a start() issued meanwhile waits for it rather than opening a session on a chain that is closing.

abort() cuts, and returns in the same turn. Whatever is queued is dropped. Use it where you have nowhere to wait: a component being unmounted, a route change, a beforeunload handler. destroy() uses it for that very reason.

await measure.stop()   // waits: what was captured is sent, then the session closes
measure.abort()        // cuts: what is queued is dropped, the call returns at once

Either way the session is recorded as closed on our side rather than as a dropped link, which is what separates both from simply walking away from the page. A saturated link cannot hang stop(): past its waiting limit the session is cut and the promise resolves all the same — a stop that never ends would be worse than a brutal one.

The video source

The source is the video option, in one of three forms:

RealtimeMeasure.create({ video: "#camera" })                        // a CSS selector
RealtimeMeasure.create({ video: document.querySelector("video") })  // the element itself
RealtimeMeasure.create({ video: mediaStream })                      // a bare MediaStream

A selector is only resolved at start(): you can write your options before the page carries the element, and ask for the camera in the meantime.

A <video> element you pass is taken as it is: the library never moves it, mutes it, mirrors it or hides it. If you pass a MediaStream instead, an off-screen video element is created for you and removed by destroy().

Handing setOptions() another source swaps it live, during a measurement included: a second realtime:attached then announces the dimensions of the new one, or realtime:error if it designates nothing. A payload that says nothing about video, on the other hand, detaches nothing — start() attaches, destroy() detaches.

Options

Options are validated when you pass them, and a payload that cannot be applied throws without changing anything. setOptions() replaces the payload rather than merging into it: whatever you leave out returns to its default, which is the only way a setting can be taken back.

OptionDefaultWhat it does
videononeThe source to measure: a CSS selector, a <video> element or a MediaStream. Required by start().
fps30Frames captured and sent per second, bounded to 15–60. Below 15 the measurement stops meaning anything.
faceDetectiontrueSend the face. It is what carries the pulse; turning it off measures nothing.
bodyDetectiontrueAlso send the upper body. It is what the breathing rate is read from; turning it off falls back on reading it from the pulse wave.
retrieveAccessTokennoneA function returning the session token, or a promise of it. See Access tokens.
onEventnoneYour handler. Everything the library reports comes through it. See Events.

The capture settings take effect immediately, including during a measurement. getOptions() returns the ones being applied.

Load once, measure many times

stop() leaves the model and the workers loaded, so a second start() opens a new session in milliseconds instead of downloading anything again. Create one instance per page, not one per measurement.

TypeScript

The module exports its types, so an integration written in TypeScript gets autocompletion over the options and an exhaustive switch over events: RealtimeMeasureOptions, RealtimeMeasureEvent, RealtimeStats and RealtimeDetection, alongside REALTIME_MEASURE_EVENTS, which carries the event names as constants for the JavaScript integrations where nothing else checks them.