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.

EnvironnementURL du module
Productionhttps://cdn.saphere.ai/saphere-scan/v2/main.js
Testhttps://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.

NomCe qu’il montre
journeyLe premier écran portant la barre d’outils : le cadre, le bouton du menu, les boutons
introL’animation d’accueil
onboarding1 … onboarding7Chaque écran de consignes, à son rang dans le parcours courant
load-face-detectorsLe cadre simple, sans barre d’outils
menuLe menu ouvert, sur son voile
lang-choiceLe panneau des langues
got-itL’infobulle, sur l’écran de consignes que vos options lui donnent
tooltipL’infobulle seule, quel que soit votre parcours
sliderLes pastilles de progression des consignes, seules
placementLe départ de la mesure, dans le mode que vous avez réglé, et le chevron de retour
computingL’écran d’attente du calcul, et son bouton d’annulation
realtimeLe 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-supportedChaque refus de la caméra
end-canceled, end-failureLes deux fins anormales
resultL’é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-healthScoreLe 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 appeler destroy() 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 être localhost) : 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' sous script-src : le détecteur de visage est du WebAssembly. blob: sous worker-src, ou sous le script-src sur 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 script blob: d’une ligne, de l’origine de votre page. Et sous connect-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égie delegate, l’API sous unsafe-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.
Écoutez l’événement plutôt que de lire la variable globale directement. Une balise de module est différée, donc elle n’a pas fini de s’exécuter quand votre propre script en ligne démarre, et l’événement supprime cette course.

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.

OptionCe qu’elle porteDéfaut
proxyLa façon dont le widget obtient un jeton d’accès — voir Jetons d’accèsAucun : les écrans fonctionnent, une mesure échoue à son départ
onEventUne fonction qui reçoit chaque événementAucun
hooksbeforeScreenChange et beforeMeasureStart, les deux points où vous pouvez refuser — voir HooksAucun
createMeasureOptionsLa mesure à créer — voir ci-dessousAucun
desiredVariablesLes variables à calculer, au moins uneToutes celles auxquelles votre compte a droit
tagsDes étiquettes pour toutes les mesures de l’instance, au moins uneAucun
realtimeOuvre le widget sur son écran temps réel — voir ci-dessousfalse
langar, de, en, es, fr, it, pl, pt, pt_BR ou trLa première des langues préférées du navigateur que le widget parle, sinon en
componentsLes textes, les couleurs et la composition du parcours — voir PersonnalisationCeux 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.

ChampCe qu’il porte
userDataSexe, â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.
tagsCe sous quoi vous retrouverez la mesure dans la console.
desiredVariablesLes variables que vous voulez voir calculées. Absente, toutes celles auxquelles votre compte a droit le sont.
unwantedVariablesLes variables que vous ne voulez pas voir calculées.
computeOptionsLes 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ès create(), 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.

ExportCe que c’est
defaultLa classe SaphereScan.
SaphereScanLa même classe, en export nommé.
SAPHERE_SCAN_EVENTSLes 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 de create() 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.