Installation
Chargez un module ES depuis le CDN, montez-le dans un conteneur qui vous appartient, et le widget prend la suite
Saphere Scan est livré sous la forme d’un unique module ES. Il n’y a rien à installer, rien à empaqueter et aucune dépendance à concilier. Le code, le modèle de détection du visage et les ressources sont tous servis par le CDN.
Quel CDN
Il y en a deux, un par environnement, et celui depuis lequel vous importez décide de l’environnement contre lequel le widget travaille. Il en déduit seul l’adresse du service de mesure. Tous les exemples de cette page visent la Production ; remplacez l’hôte par celui du Test, et rien d’autre ne change.
| Environnement | URL du module |
|---|---|
| Production | https://cdn.saphere.ai/saphere-scan/v2/main.js |
| Test | https://cdn.test.saphere.ai/saphere-scan/v2/main.js |
Le Test est aussi l’environnement où les nouveaux développements sont validés : il est donc parfois légèrement en avance sur la Production. Voir Environnements pour ce qui diffère par ailleurs — au premier chef, que les clés d’API ne sont pas interchangeables.
Chargez le module depuis l’une de ces deux adresses, et depuis nulle autre. Le widget situe ses services d’après l’adresse d’où il a été chargé : une copie servie par votre propre serveur, ou relayée par un proxy à vous, ne le peut pas, et bootstrap() échoue alors sur Saphere Scan must be served by a CDN it can recognise, and was loaded from "…".
La page fonctionnelle la plus courte
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; height: 100%; }
#scan { height: 100dvh; }
</style>
</head>
<body>
<div id="scan"></div>
<script type="module">
import SaphereScan from "https://cdn.saphere.ai/saphere-scan/v2/main.js"
const instance = SaphereScan.create("#scan", {
lang: "fr",
proxy: {
retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" }
}
})
await instance.bootstrap()
</script>
</body>
</html>
C’est une intégration complète. Le widget mène l’utilisateur à travers le consentement, les consignes et la capture, puis rend compte par les événements.
Le cycle de vie
Sept méthodes, et elles constituent toute la surface publique. Deux d’entre elles — create et describeDefaults — sont statiques, appelées sur la classe ; les cinq autres sur une instance.
SaphereScan.create(root, options)
Valide les options et retourne une instance. Elle ne monte rien — aucune application Angular n’est construite, aucune caméra n’est touchée, aucune requête réseau n’est émise.
root est un sélecteur CSS ou un HTMLDivElement. Toute autre valeur lève Root element must be an HTMLDivElement.
Les options sont validées ici, de façon synchrone, contre l’arbre d’options complet. Une clé mal orthographiée est ignorée silencieusement ; une valeur de forme incorrecte lève. C’est délibéré : vous l’apprenez au moment du create(), sur votre propre machine, plutôt que trois écrans après le début du parcours d’un utilisateur.
Lisez ce qu’elle lève par String(error) plutôt que par error.message : une charge refusée lève le rapport de validation, qui n’est pas une Error et n’a pas de message, et dont la forme textuelle nomme chaque champ fautif et ce qu’il attend. Il en va de même pour setOptions() et describeDefaults().
await instance.bootstrap()
Construit l’application et la monte. Retourne l’instance, ce qui permet de chaîner.
L’appeler deux fois lève SaphereScan is already bootstrapped. Si le montage échoue, la promesse est rejetée avec l’erreur sous-jacente, l’application partiellement construite est démontée, et un événement widget:error porte le message — de sorte qu’un gestionnaire de supervision l’apprend même si personne n’attend la promesse.
instance.setOptions(options)
Remplace les options d’une instance, montée ou non, et l’écran suit — textes, couleurs, dimensions, langue, et jusqu’à la composition du parcours. Rien n’est remonté : le widget garde son application, ses workers et sa caméra.
// l'utilisateur choisit sa langue dans votre interface, pas dans le widget
instance.setOptions({ ...options, lang: "de" })
La charge remplace, elle ne complète pas. Elle prend exactement la forme de celle de create() et décrit l’état voulu : ce qu’elle ne dit pas revient au défaut du widget. C’est ce qui vous permet de retirer une personnalisation, et c’est aussi ce qui demande de redonner à chaque appel ce que vous voulez conserver — votre proxy, vos hooks et votre onEvent compris. L’omettre n’échoue pas : le widget cesse simplement de vous rapporter quoi que ce soit.
Les options sont validées comme à create(). Une charge refusée lève, et l’instance reste sur celles qu’elle appliquait : rien n’est appliqué à moitié.
Retirer du parcours l’écran affiché fait atterrir sur le premier écran restant ; retirer un écran situé avant lui le laisse en place, l’écran étant suivi par son identité et non par son rang. Une mesure en cours n’est jamais interrompue : les écrans de mesure, de résultat et d’erreur ne dépendent pas de la composition du parcours.
Retourne l’instance, ce qui permet de chaîner.
instance.previewScreen(nom)
Montre un écran d’essai — les écrans réels, alimentés par des entrées fabriquées — ou, appelée sans argument, rend le widget à son parcours.
// ce que vos utilisateurs verront à la fin d'une mesure, sans en faire une
instance.previewScreen("result")
// retour au parcours
instance.previewScreen()
Elle existe pour qu’on puisse juger d’une apparence sans provoquer la situation qui produit l’écran. L’écran de résultat suit une mesure terminée, les deux fins anormales une mesure interrompue, et les cinq écrans caméra un refus réel ; aucun n’est atteignable par les seuls réglages. Les écrans du parcours, eux, sont simplement parcourus : "onboarding3" mène à la route du troisième écran, avec sa numérotation et son cadre.
Tant qu’un écran d’essai est affiché, les commandes de navigation ne font rien. « Réessayer », « Précédent » et « Recharger la caméra » restent visibles et cliquables — leur apparence fait partie de ce que vous jugez — mais ne quittent pas l’écran, et la caméra n’est jamais ouverte.
| Nom | Ce qu’il montre |
|---|---|
journey | Le premier écran portant la barre d’outils : le cadre, le bouton du menu, les boutons |
intro | L’animation d’accueil |
onboarding1 … onboarding7 | Chaque écran de consignes, à son rang dans le parcours courant |
load-face-detectors | Le cadre simple, sans barre d’outils |
menu | Le menu ouvert, sur son voile |
lang-choice | Le panneau des langues |
got-it | L’infobulle, sur l’écran de consignes que vos options lui donnent |
tooltip | L’infobulle seule, quel que soit votre parcours |
slider | Les pastilles de progression des consignes, seules |
placement | Le départ de la mesure, dans le mode que vous avez réglé, et le chevron de retour |
computing | L’écran d’attente du calcul, et son bouton d’annulation |
realtime | Le bouton qui quitte l’écran temps réel pour le parcours certifié |
camera-not-allowed, camera-not-found, camera-not-readable, camera-abort, camera-not-supported | Chaque refus de la caméra |
end-canceled, end-failure | Les deux fins anormales |
result | L’écran de résultat, toutes ses valeurs renseignées |
result-hr, result-br, result-hrv, result-strs, result-bp, result-bpClass, result-bmi, result-faceBmi, result-physicalAge, result-healthScore | Le même écran, réduit à une seule valeur : la carte dont vous réglez les couleurs, sans les neuf autres |
Un nom inconnu lève, et l’appeler sur une instance qui n’est pas démarrée aussi — il n’y a pas de routeur avant. Un écran du parcours que vos options excluent renvoie vers un écran qui existe.
Cinq d’entre eux — slider, tooltip, placement, computing et realtime — ne doivent rien à votre parcours : ils s’affichent quelles que soient vos options, ce qui permet de juger un réglage dont vous avez retiré l’écran habituel. Les écrans placement et realtime ne portent pas l’image de la caméra — c’est la seule chose qu’ils ne savent pas fabriquer.
Retourne l’instance, ce qui permet de chaîner.
instance.getOptions()
Rend les options que l’instance applique en ce moment — résolues et validées, c’est-à-dire l’arbre complet, défauts compris, et non la charge que vous avez remise.
// dans quelle langue le widget s'affiche-t-il réellement ?
instance.getOptions().lang
Utile après un setOptions() dont vous avez composé la charge par fusion, et pour lire ce qu’un défaut vaut réellement sans avoir à le deviner.
SaphereScan.describeDefaults(options)
Rend ce que le widget appliquerait pour les options données, sans rien monter. Appelée sans argument, elle rend donc ses défauts : l’arborescence complète, en JSON.
// tout l'arbre de personnalisation par défaut, pour bâtir un éditeur dessus
const defauts = SaphereScan.describeDefaults()
// ce que porterait un parcours dont l'écran d'informations personnelles est activé
const avecFormulaire = SaphereScan.describeDefaults({ components: { onboarding: { onboarding3: { ignore: false } } } })
Elle est statique parce qu’il n’y a rien à monter et rien à conserver. L’argument n’est pas un ornement : plusieurs écrans sont ignorés par défaut, et ce qu’ils porteraient n’apparaît pas dans la charge sans argument — leur variante ignorée ne déclare que son ignore. Les résoudre demande de le dire.
Chaque appel rend une charge neuve, que vous pouvez modifier sans qu’un second appel en porte la trace. Une charge que le widget refuse lève, comme à create().
instance.destroy()
Démonte le widget et libère la caméra, le websocket et les workers.
Elle peut être appelée à tout moment. L’appeler sur une instance qui n’a jamais été démarrée, ou deux fois de suite, ne fait rien et ne lève rien. Vous n’avez pas à suivre l’état du widget pour le retirer.
const instance = SaphereScan.create("#scan", options)
await instance.bootstrap()
// plus tard — en quittant la page, en fermant une fenêtre modale, en démontant un composant
instance.destroy()
Toujours détruire au démontage
Le widget tient un flux caméra, un websocket et deux web workers. Une application monopage qui retire le conteneur du DOM sans appelerdestroy() laisse le voyant de la caméra allumé.Le contrat du conteneur
Le widget possède son conteneur. Trois conséquences à connaître avant de le mettre en forme.
Il remplace ce qui se trouve dans le conteneur. Tout ce que vous avez laissé dans le div, un contenu d’attente ou votre propre indicateur de chargement par exemple, est retiré au moment où bootstrap() s’exécute. Placez votre contenu d’attente à l’intérieur du conteneur et il disparaît exactement au bon moment, sans aucune coordination de votre côté.
Il se monte dans un shadow root. Le CSS de votre page ne peut pas atteindre l’intérieur du widget, et le CSS du widget ne peut pas déborder dans votre page. C’est ce qui rend le widget sûr à poser dans un système de design existant, et c’est aussi pourquoi vous ne pouvez pas le remettre en forme par une feuille de style — la personnalisation passe par les options.
Il a besoin d’un conteneur qui a une hauteur. Le widget remplit 100 % de son conteneur dans les deux dimensions. Un div sans hauteur s’écrase à rien et le widget ne sera pas visible.
/* Plein écran — le choix habituel sur mobile et en WebView */
#scan { height: 100dvh; }
/* Ou un cadre fixe dans une page plus large */
#scan { width: min(420px, 100%); aspect-ratio: 9 / 16; margin-inline: auto; }
Le parcours est conçu en mode portrait. Un cadre haut et étroit est ce pour quoi il est fait.
Ce que le navigateur doit fournir
- Un contexte sécurisé. La page doit être servie en
https(ou êtrelocalhost) : le navigateur refuse la caméra à tout le reste. - Une Content-Security-Policy qui laisse le widget fonctionner, si votre page en pose une. Les scripts et les images du CDN, et
'wasm-unsafe-eval'sousscript-src: le détecteur de visage est du WebAssembly.blob:sousworker-src, ou sous lescript-srcsur lequel il se replie : les workers du widget viennent du CDN, un navigateur refuse un script de worker d’une autre origine quels que soient ses en-têtes CORS, si bien que le widget démarre chacun par un scriptblob:d’une ligne, de l’origine de votre page. Et sousconnect-src, les adresses auxquelles il parle : le CDN et le service de mesure du même environnement (api-measure.suivi du domaine du CDN), tous deux aussi en WebSocket sécurisé ; votre propre point d’accès sous la stratégiedelegate, l’API sousunsafe-api-key; et en mode temps réel,api-webtransport.suivi du même domaine.
Sans instruction import
Certaines intégrations ne peuvent pas écrire d’import : un CMS qui ne laisse coller qu’une balise de script, une configuration d’empaqueteur que vous ne maîtrisez pas, une page dont le script est produit pour vous. Le paquet s’expose aussi globalement, si bien qu’une balise suffit :
<script type="module" src="https://cdn.saphere.ai/saphere-scan/v2/main.js"></script>
<script>
window.addEventListener("saphere-scan:ready", async ({ detail: { SaphereScan } }) => {
const instance = SaphereScan.create("#scan", { lang: "fr", proxy: { /* … */ } })
await instance.bootstrap()
})
</script>
Le CustomEvent saphere-scan:ready est émis sur window une fois le paquet évalué, et porte la classe dans detail.SaphereScan. La même classe est également posée sur window.IVirtual.SaphereScan.
La balise doit être de type module
Le paquet est un module ES. Chargé par une balise<script src> ordinaire, il ne s’exécute pas du tout : le navigateur s’arrête sur SyntaxError: Cannot use import statement outside a module, la variable globale n’est jamais posée et l’événement n’est jamais émis. C’est type="module" qu’il faut à la page ci-dessus, et il est servi partout où le widget l’est.Le paquet refuse d’être évalué deux fois. La même adresse importée deux fois n’est évaluée qu’une fois, comme pour tout module ; mais le paquet chargé une seconde fois sous une autre adresse — depuis l’autre environnement, ou avec un autre customizationId — lève IVirtual.SaphereScan is already defined. Pour faire tourner plusieurs widgets, créez plusieurs instances à partir de l’unique classe.
Les options
Chaque option peut être omise à create(), et le tableau dit ce qu’elle vaut alors. proxy est la seule sans laquelle une mesure ne peut pas démarrer.
| Option | Ce qu’elle porte | Défaut |
|---|---|---|
proxy | La façon dont le widget obtient un jeton d’accès — voir Jetons d’accès | Aucun : les écrans fonctionnent, une mesure échoue à son départ |
onEvent | Une fonction qui reçoit chaque événement | Aucun |
hooks | beforeScreenChange et beforeMeasureStart, les deux points où vous pouvez refuser — voir Hooks | Aucun |
createMeasureOptions | La mesure à créer — voir ci-dessous | Aucun |
desiredVariables | Les variables à calculer, au moins une | Toutes celles auxquelles votre compte a droit |
tags | Des étiquettes pour toutes les mesures de l’instance, au moins une | Aucun |
realtime | Ouvre le widget sur son écran temps réel — voir ci-dessous | false |
lang | ar, de, en, es, fr, it, pl, pt, pt_BR ou tr | La première des langues préférées du navigateur que le widget parle, sinon en |
components | Les textes, les couleurs et la composition du parcours — voir Personnalisation | Ceux du widget |
pt_BR s’écrit avec un tiret bas : pt-BR est refusé.
La mesure à créer
Tout ce que le widget a à dire de la mesure voyage dans une seule option, createMeasureOptions. Ses champs sont ceux qu’accepte POST /measures, et ils décrivent la mesure que le widget s’apprête à ouvrir.
| Champ | Ce qu’il porte |
|---|---|
userData | Sexe, âge ou date de naissance, poids, taille, statut tabagique, et un identifiant à vous, externalId — voir l’API pour les bornes. Plusieurs variables ne se calculent pas sans lui. |
tags | Ce sous quoi vous retrouverez la mesure dans la console. |
desiredVariables | Les variables que vous voulez voir calculées. Absente, toutes celles auxquelles votre compte a droit le sont. |
unwantedVariables | Les variables que vous ne voulez pas voir calculées. |
computeOptions | Les options qui orientent le calcul. |
Elle prend deux formes, et la seconde existe pour une raison précise.
Un objet décrit une mesure qui ne change pas d’une personne à l’autre.
createMeasureOptions: {
userData: { sex: "F", age: 35, weight: 58, height: 171, smokingStatus: "non-smoker" },
tags: ["borne-3"]
}
Une fonction est redemandée à chaque tentative, et reçoit ce que le formulaire du widget a collecté — null quand cet écran est désactivé, ou est resté incomplet —, puis l’AbortSignal de la tentative. C’est la seule façon de composer les deux sources : vous enrichissez ce que la personne vient de saisir de ce que vous détenez déjà, au lieu d’avoir à choisir entre les deux. Elle peut rendre une promesse, et dispose de cinq secondes pour aboutir.
createMeasureOptions: collected => ({
userData: { ...collected, sex: patient.sex, age: patient.age },
tags: ["consultation", consultation.id]
})
Ce qu’elle déclare l’emporte, champ par champ. Ce qu’elle laisse de côté retombe : sur les options desiredVariables et tags du premier niveau, qui valent pour toutes les mesures de l’instance, et pour userData sur ce que le formulaire du widget a collecté.
C'est vérifié avant que la mesure ne démarre
Un objet est refusé dèscreate(), et le retour d’une fonction au moment où elle répond — la tentative s’achève alors sur measure:failed, code CREATE_MEASURE_OPTIONS_ERROR, qui est aussi le code d’une fonction qui lève ; une fonction qui met plus de cinq secondes l’achève sur CREATE_MEASURE_OPTIONS_TIMEOUT. Les deux formes sont vérifiées exactement comme l’API les vérifie, de sorte qu’une valeur hors bornes est un refus clair plutôt qu’une connexion qui tombe quelques secondes après le début de l’acquisition.Le mode temps réel
realtime: true ouvre le widget sur un écran de mesure temps réel une fois l’accueil joué : la caméra, les ondes respiratoire et de pouls au fil de la mesure, et les grandeurs en direct. En bas de cet écran, un bouton démarre une mesure certifiée — le parcours habituel, consignes et consentement compris — et le bouton de l’écran de résultat devient alors Continuer et ramène à l’écran temps réel. Les deux bouclent tant que le widget est monté.
const instance = SaphereScan.create("#scan", {
realtime: true,
proxy: { retrieveAccessToken: { strategy: "delegate", url: "/saphere/token" } }
})
Ce que l’écran montre est la fréquence cardiaque, la fréquence respiratoire et une qualité de signal en étoiles, par-dessus l’image de la caméra. Une valeur n’apparaît qu’une fois que la mesure en est sûre — sous ce seuil la lecture reste sur des tirets plutôt que d’afficher un nombre que la chaîne n’assumerait pas, si bien que les premières secondes d’une session sont normalement vides.
Chaque session temps réel consomme un jeton à elle, demandé par le même proxy.retrieveAccessToken — avec measurement à "realtime" là où le parcours certifié demande "scan". Cette option activée, votre point d’accès doit donc répondre aux deux chaînes.
L’écran ne porte pas de barre d’outils : la caméra occupe tout le cadre, et le bouton vers la mesure certifiée en est la seule commande — ni le retour en arrière ni le menu (choix de la langue, documents du fabricant) n’y sont donc accessibles. Ils reviennent dès que le parcours certifié démarre.
Si la chaîne temps réel n’aboutit pas — navigateur dont WebTransport n’interopère pas avec le serveur, jeton refusé, détecteurs qui ne se chargent pas, lien qui tombe en cours de session —, le widget passe directement à la mesure certifiée et le signale par realtime:unavailable. Il n’y a pas d’écran de reprise.
L’écran se rapporte sous le nom realtime dans screen:enter, et les événements de jeton sont les mêmes que pour le parcours certifié. L’option est éteinte par défaut : une intégration existante ne change en rien.
C’est le widget qui mesure en temps réel depuis la caméra qu’il demande. Si vous détenez déjà la vidéo — une téléconsultation, un serveur de médias, un fichier à rejouer —, la chaîne est atteignable directement, et c’est ce que documente Realtime Measure.
Ce que le module exporte
Trois noms, et ce sont les seuls qu’une page puisse importer.
| Export | Ce que c’est |
|---|---|
default | La classe SaphereScan. |
SaphereScan | La même classe, en export nommé. |
SAPHERE_SCAN_EVENTS | Les noms des quarante-quatre événements sous forme de constantes gelées, pour les intégrations en JavaScript. |
SAPHERE_SCAN_EVENTS existe parce qu’en JavaScript un switch sur des littéraux de chaîne nus n’a aucun filet : une faute de frappe devient une branche qui ne s’exécute jamais et ne se plaint jamais.
import SaphereScan, { SAPHERE_SCAN_EVENTS } from "https://cdn.saphere.ai/saphere-scan/v2/main.js"
const onEvent = event => {
if (event.type === SAPHERE_SCAN_EVENTS.MEASURE_RESULT)
save(event.result)
}
Aucune déclaration de types n’est publiée, ni sur le CDN ni ailleurs. Une intégration écrite en TypeScript déclare le module elle-même, et la déclaration la plus courte sous laquelle les exemples de cette page passent le contrôle de types est celle-ci — l’arbre d’options et les événements restent non typés, la page des événements décrivant leurs formes :
// saphere-scan.d.ts
declare module "https://cdn.saphere.ai/saphere-scan/v2/main.js" {
export interface SaphereScanInstance {
bootstrap(): Promise<SaphereScanInstance>
setOptions(options: object): SaphereScanInstance
previewScreen(name?: string): SaphereScanInstance
getOptions(): Record<string, unknown>
destroy(): void
}
export const SaphereScan: {
create(root: string | HTMLDivElement, options?: object): SaphereScanInstance
describeDefaults(options?: object): Record<string, unknown>
}
export const SAPHERE_SCAN_EVENTS: Readonly<Record<string, string>>
export default SaphereScan
}
La validation à l'exécution est la garantie
L’arbre d’options est validé à l’exécution à chaque appel decreate() et de setOptions(), parce que le mode d’intégration principal est le JavaScript et que les types n’y protègent de rien. Une déclaration à vous ne vérifie votre code que contre ce que vous y avez écrit.La suite
Mettez en place la façon dont le widget obtient un jeton, car il ne démarrera aucune mesure sans elle. Branchez ensuite les événements.