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.
| Environment | Module URL |
|---|---|
| Production | https://cdn.saphere.ai/realtime-measure/v1/main.js |
| Test | https://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 belocalhost): 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, thenrealtime: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, underworker-srcor thescript-srcit 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-lineblob: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, callstart(), 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.
| Option | Default | What it does |
|---|---|---|
video | none | The source to measure: a CSS selector, a <video> element or a MediaStream. Required by start(). |
fps | 30 | Frames captured and sent per second, bounded to 15–60. Below 15 the measurement stops meaning anything. |
faceDetection | true | Send the face. It is what carries the pulse; turning it off measures nothing. |
bodyDetection | true | Also 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. |
retrieveAccessToken | none | A function returning the session token, or a promise of it. See Access tokens. |
onEvent | none | Your 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.