Événements
Quarante-quatre événements de cycle de vie, de la demande de caméra à l’arrivée du résultat — tout ce que le widget sait de lui-même
Le widget rapporte ce qu’il fait par options.onEvent. Quarante-quatre événements couvrent tout le parcours : l’instance, les écrans, le consentement, le détecteur de visage, la caméra, le placement, les jetons, la mesure, le transport, la chaîne temps réel, et les panneaux superposés au parcours.
const instance = SaphereScan.create("#scan", {
onEvent: event => console.log(event.type, event),
proxy: { retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" } }
})
Deux canaux, une différence
onEvent et les hooks se ressemblent et ne sont pas la même chose. La différence est le contrat, non le vocabulaire.
onEvent | hooks | |
|---|---|---|
| Objet | Observer | Agir |
| Valeur de retour | Ignorée | Un false littéral refuse |
| Attendu | Non | Oui, dans la limite d’un délai de garde de 2 secondes |
| Une exception ou un rejet | Rapporté en console, le parcours continue | Rapporté en console, traité comme une autorisation |
Le widget n’attend pas onEvent et ne lit pas ce qu’il retourne. C’est ce qui garantit qu’un gestionnaire lent ou fautif ne peut pas emporter une mesure avec lui. Si vous avez besoin de refuser quelque chose plutôt que de l’observer, c’est à cela que servent les hooks.
Lire un événement
Les noms sont de la forme domaine:événement. Chaque événement porte à plat ce qui le décrit — votre gestionnaire lit event.percent, non event.data.percent.
Le widget est distribué en JavaScript : aucune déclaration de type n’est servie avec le paquet. L’union que suivent tous les événements s’appelle SaphereScanEvent dans le widget ; déclarée de votre côté, elle rend exhaustif un switch sur event.type, chaque branche étant affinée à sa charge utile :
import type { SaphereScanEvent } from "./saphere-scan-events" // votre propre déclaration de l'union
const onEvent = (event: SaphereScanEvent) => {
switch (event.type) {
case "measure:result": return save(event.result)
case "measure:failed": return report(event.code)
case "camera:error": return explain(event.code)
}
}
En JavaScript, utilisez SAPHERE_SCAN_EVENTS, exporté par le module à côté de la classe, pour disposer des noms sous forme de constantes — une faute de frappe dans un littéral de chaîne nu est une branche qui ne s’exécute jamais en silence.
widget — l’instance
| Événement | Charge utile | Quand |
|---|---|---|
widget:ready | — | Le widget est monté. Toujours le premier événement. |
widget:error | message | bootstrap() a échoué. La promesse qu’il retourne est rejetée avec la même erreur. Rien d’autre n’est rapporté pour ce montage. |
widget:destroyed | — | destroy() a été appelé sur un widget monté. Rien ne suit — sauf si l’instance est redémarrée. |
widget:error existe parce que la promesse rejetée est facile à manquer : une intégration qui appelle bootstrap() sans l’attendre n’aurait autrement aucun signe de l’échec du montage.
screen — le parcours
| Événement | Charge utile | Quand |
|---|---|---|
screen:enter | path, name | Un écran est affiché, y compris le premier. |
screen:blocked | from, to | Votre hook beforeScreenChange a refusé la transition. |
path est la route (/onboarding/1, /measurement, /result) ; name est l’écran (intro, onboarding, load-face-detectors, measurement, realtime, result, error).
Suivez `name`, non `path`
L’indice d’onboarding dépend des écrans que le parcours retient. D’origine, le troisième écran de consignes — le formulaire d’informations personnelles — est désactivé, si bien que/onboarding/2 est le quatrième écran ; une intégration qui active le formulaire y trouve le troisième. name est stable d’une configuration à l’autre.Un écran d’essai affiché par previewScreen() rapporte le nom de l’écran qu’il représente, sous un chemin /preview/….
consent et form — ce que l’utilisateur accorde
| Événement | Charge utile | Quand |
|---|---|---|
consent:changed | checks | Une case de consentement a changé. |
consent:accepted | — | L’écran a été quitté, toutes les cases montrées cochées. |
form:completed | — | Le formulaire d’informations personnelles a été quitté, valide. |
checks porte une clé par case que l’écran montre : { check1: true, check2: false }. L’écran en propose quatre, dont les deux premières sont montrées d’origine ; une case que votre customisation retire ne demande rien, elle n’y figure donc pas. Toute case montrée doit être cochée pour que l’utilisateur puisse avancer.
consent:accepted est émis en quittant l’écran dans les deux sens, non au moment où la dernière case est cochée : tant que l’écran est là, l’utilisateur peut encore décocher. Un écran dont toutes les cases ont été retirées ne demande rien, et ne rapporte rien.
form:completed ne porte rien. Ce sont des données de santé, et elles vous parviennent déjà dans le userData du résultat — elles n’ont aucune raison de voyager deux fois.
detector — le détecteur de visage
| Événement | Quand |
|---|---|
detector:loading | Le chargement commence, au montage. |
detector:loaded | Le détecteur est prêt. |
detector:error | Le détecteur n’a pas pu être chargé : aucune mesure ne sera possible. |
detector:error mérite d’être branché. Il laisse l’écran de chargement sans issue : une seule ligne disant que les détecteurs n’ont pas pu être chargés — en anglais, quelle que soit la langue — et aucun bouton, rien sur quoi l’utilisateur puisse agir. C’est le seul cas où votre propre traitement des erreurs doit prendre le relais.
Ces trois-là concernent le détecteur de la mesure de trente secondes. L’écran temps réel charge les siens, et en signale l’échec par realtime:unavailable.
camera — la caméra
| Événement | Charge utile | Quand |
|---|---|---|
camera:requesting | — | L’autorisation est demandée. Une fois par tentative d’ouverture. |
camera:ready | width, height, frameRate | Le flux est ouvert. |
camera:error | code, name | Le flux a été refusé. |
camera:ended | — | La caméra s’est arrêtée d’elle-même : débranchée, prise par une autre application. |
camera:released | — | Le widget a rendu la caméra. |
camera:devices-changed | — | Un périphérique a été branché ou débranché ; le flux se recharge. |
code vaut NOT_ALLOWED, NOT_FOUND, NOT_READABLE, ABORT, NOT_SUPPORTED ou UNKNOWN. name est le nom d’exception brut produit par le navigateur, les navigateurs ne les employant pas tous de la même façon — conservez-le pour le diagnostic, branchez sur code. Dans camera:ready, chacun des trois réglages vaut null quand le navigateur ne le rapporte pas.
`camera:ready` rapporte ce qui a été accordé, non ce qui a été demandé
Le widget demande 30 images par seconde en 640×480 à titre de préférence, et réessaie silencieusement sans aucune contrainte si la caméra refuse. UnframeRate de 15 explique une mesure de moindre qualité, et cet événement est le seul moyen de le voir. Les identifiants de matériel ne sont jamais transmis.camera:ended et camera:released sont deux états distincts. Le premier n’est pas voulu — la source s’est arrêtée d’elle-même, et la mesure est compromise. Le second est le widget qui fait le ménage.
placement — le cadrage du visage
| Événement | Charge utile | Quand |
|---|---|---|
placement:started | — | L’écran de placement est actif. Peut être émis deux fois si la caméra change. |
placement:guidance | guidance, stable | La consigne affichée, ou l’immobilité, a changé. |
placement:accepted | — | Le visage est accepté et le cadrage figé. |
guidance vaut "up", "down", "back" ou null — null signifiant soit que rien n’est à corriger, soit qu’aucun visage n’est vu. stable vaut true tant que le visage reste immobile — il a bougé d’au plus un pixel depuis la détection précédente — et false quand aucun visage n’est vu ; le départ attend un visage à la fois bien placé et immobile.
L’événement n’est émis qu’au changement. La détection tourne à chaque image de la caméra ; rapporter chaque image noierait un gestionnaire sans bénéfice.
token — le jeton d’accès
| Événement | Charge utile | Quand |
|---|---|---|
token:requesting | strategy, measurement | Un jeton est demandé, à chaque tentative. measurement est la chaîne de mesure pour laquelle le jeton est demandé : scan pour la mesure de trente secondes, realtime pour l’écran temps réel. |
token:retrieved | — | Un jeton exploitable a été obtenu. |
token:error | code | Il n’a pas pu l’être. |
code sépare votre échec du nôtre — voyez les jetons d’accès pour le tableau. C’est le seul point du parcours où la faute peut être de votre côté, ce qui est la raison pour laquelle un code générique unique ne suffisait pas.
Sans proxy.retrieveAccessToken déclaré, token:error (NO_PROXY) arrive seul : il n’y avait rien à demander. Sur l’écran temps réel, un jeton qui n’arrive pas en cinq secondes mène à realtime:unavailable sans token:error avant lui.
La valeur du jeton n’est jamais portée par un événement.
measure — la mesure
| Événement | Charge utile | Quand |
|---|---|---|
measure:attempt | attempt | Une tentative commence, à l’ouverture de l’écran de mesure. Numérotée à partir de un. |
measure:start | — | L’acquisition démarre : les trente secondes de capture courent. |
measure:progress | percent | La progression a changé d’un point entier, de 0 — rapporté au début de la tentative — à 100. |
measure:captures-sent | totalCaptures, totalImages | La capture est finie : chaque capture est confiée à la connexion, suivie de la fin de mesure, et l’écran d’attente s’affiche. |
measure:result | result | Le résultat est arrivé et a été validé. |
measure:aborted | code | Arrêt délibéré : l’utilisateur a renoncé, ou votre hook a refusé. |
measure:failed | code | Panne : la mesure n’aboutira pas. |
La séparation entre aborted et failed existe pour votre supervision. Une annulation par l’utilisateur est un déroulement normal et ne doit pas apparaître dans vos tableaux de bord comme un incident.
attempt compte les tentatives d’une même visite de l’écran de mesure. Une deuxième suit l’utilisateur qui renonce à attendre (CANCELED_WHILE_PROCESSING), ce qui relance une mesure sur place. « Réessayer » sur l’écran de fin anormale, comme une nouvelle mesure depuis l’écran de résultat, rouvrent l’écran de mesure : le compte repart de un.
code est un motif d’échec, et il y en a quinze. Trois sont des arrêts, les autres des pannes.
| Code | Événement | Ce qui s’est passé |
|---|---|---|
CANCELED | measure:aborted | L’utilisateur a annulé la mesure. |
CANCELED_WHILE_PROCESSING | measure:aborted | L’utilisateur a renoncé à attendre le résultat. Le seul motif qui ne mène pas à un écran de fin anormale : le widget enchaîne sur une nouvelle mesure. |
REFUSED_BY_INTEGRATOR | measure:aborted | Votre hook beforeMeasureStart a refusé le départ. Rien n’est cassé, quelqu’un a dit non. |
UNDEFINED_ACCESS_TOKEN_PROXY | measure:failed | Aucun proxy.retrieveAccessToken n’a été déclaré. Une intégration ne peut pas mesurer sans lui. |
ACCESS_TOKEN_ERROR | measure:failed | Le jeton n’a pas pu être obtenu. token:error dit quelle partie a échoué. |
ACCESS_TOKEN_TIMEOUT | measure:failed | Le jeton a mis plus de cinq secondes à arriver. |
CREATE_MEASURE_OPTIONS_ERROR | measure:failed | Votre fonction createMeasureOptions a levé, ou a rendu une mesure que l’api refuserait. |
CREATE_MEASURE_OPTIONS_TIMEOUT | measure:failed | Cette fonction a mis plus de cinq secondes à répondre. |
VIDEO_SOURCE_ENDED | measure:failed | La caméra s’est arrêtée d’elle-même en pleine capture — débranchée, prise par une autre application, onglet suspendu. Jamais levé par un arrêt que le widget a demandé. |
INVALID_ENDED_MEASURE_DTO | measure:failed | Le résultat n’a pas passé la validation, il ne vous a donc pas été livré. |
WS_CONNECTION_ERROR | measure:failed | La connexion au serveur de mesure n’a pas tenu : un message a été renvoyé cinq fois sans être acquitté, ou le serveur a fermé la connexion sans résultat ni motif. |
WS_MESSAGE_REJECTED | measure:failed | Le serveur a refusé une capture, une image ou la fin de mesure. |
WS_SERVER_ERROR | measure:failed | Le serveur a mis fin à la mesure et en a donné le motif, que le widget journalise ; l’événement ne le porte pas. |
WS_RESULT_TIMEOUT | measure:failed | Aucun résultat dans les dix minutes qui suivent l’envoi de la fin de mesure. |
UNKNOWN | measure:failed | Ce que le widget n’a pas su nommer. Ne devrait pas arriver ; signalez-le. |
Les quatre codes WS_ nomment l’acheminement vers le serveur de mesure. Les trois sur lesquels vous pouvez agir seul sont UNDEFINED_ACCESS_TOKEN_PROXY, CREATE_MEASURE_OPTIONS_ERROR et CREATE_MEASURE_OPTIONS_TIMEOUT : tous trois viennent de votre intégration, non de l’utilisateur ni du réseau.
Un jeton refusé par le serveur se révèle tard
La poignée de main qui refuse un jeton — expiré, déjà consommé, frappé pour l’autre chaîne — ne donne au widget aucun motif sur lequel agir : il retente donc la connexion. La capture court tout de même ses trente secondes, l’écran d’attente suit, et la tentative se conclut parWS_RESULT_TIMEOUT dix minutes plus tard. Le signe à surveiller est transport:reconnecting qui se répète sans qu’aucun transport:connected ne l’ait précédé : c’est le plus souvent un jeton que le serveur ne prendra pas, plus rarement un serveur hors d’atteinte, et la façon dont votre point d’accès frappe le jeton est la première chose à vérifier.measure:result est émis une fois la charge utile reçue et validée. Entre l’arrivée du résultat par le réseau et cet événement, le widget en vérifie la forme. Un résultat qui manque ce contrôle produit measure:failed à la place — de sorte que vous ne recevez jamais un résultat que le widget lui-même n’afficherait pas.
transport — l’acheminement vers le serveur
| Événement | Charge utile | Quand |
|---|---|---|
transport:connected | — | La connexion est établie — de nouveau après chaque reconnexion. |
transport:reconnecting | attempt | Une tentative de connexion a échoué, la première comprise, et est renouvelée. Les tentatives ne sont pas limitées ; attempt les compte. |
transport:disconnected | reason | La connexion est fermée et ne sera pas retentée. |
transport:degraded | — | Le débit ne suit pas ; la mesure continue. |
transport:restored | — | Le débit est revenu. |
transport:disconnected n’arrive qu’une fois que rien ne sera retenté : quand le serveur ferme la connexion — c’est ainsi que se termine chaque mesure, une fois son résultat livré — ou quand le widget la ferme en quittant la mesure. Ce n’est donc pas une panne en soi ; c’est measure:failed qui en dit une. Pendant qu’une connexion se rétablit, vous recevez transport:reconnecting à la place — rapporter une reprise automatique comme une déconnexion ferait passer chaque bref à-coup pour une panne.
Ces cinq-là décrivent la connexion de la mesure de trente secondes. La chaîne temps réel a la sienne, dont la perte mène à realtime:unavailable.
realtime — la chaîne temps réel
| Événement | Charge utile | Quand |
|---|---|---|
realtime:started | — | La session mesure : détecteurs chargés, jeton consommé, transport ouvert, capture en route. |
realtime:variables | variables | Les grandeurs telles que le serveur les calcule, une fois par seconde. |
realtime:presence | face | Un visage est entré dans le champ, ou en est sorti. |
realtime:unavailable | — | La mesure temps réel n’a pas pu aboutir ; le widget enchaîne sur la mesure certifiée. |
Ces quatre-là ne valent que lorsque realtime est posé.
realtime:started est le seul événement qui dise que l’attente est finie : l’écran temps réel ne change pas entre le chargement et la mesure, donc screen:enter n’en rapporte rien. C’est le pendant de measure:start pour l’autre chaîne.
variables porte ce que le serveur calcule, tel quel — la charge est celle que décrivent les grandeurs de la chaîne temps réel. Rien n’arrive tant que la fenêtre est trop courte : l’absence veut dire « pas encore », non « rien à mesurer », et mature puis confident disent ensuite ce qui peut être affiché comme une mesure. L’écran du widget, lui, n’en montre aucune sous le seuil de qualité.
Le domaine est `realtime`, non `measure`
Les deux chaînes ne sont pas la même mesure. Une supervision qui filtremeasure:* suit la mesure certifiée — tentative, progression, résultat — et n’a pas à y voir arriver des valeurs au fil de l’eau qui n’ont ni tentative, ni progression, ni résultat.realtime:presence n’est émis qu’au changement. Le cadre de détection est recalculé à la cadence de capture ; ce qui vous est promis est l’entrée et la sortie du champ, non leur suivi — l’écran temps réel est celui du widget, calque de détection compris, et vous n’avez rien à dessiner par-dessus. L’absence de visage à l’ouverture de l’écran n’est pas rapportée : ce serait annoncer une sortie qui n’a pas eu lieu.
Quant à realtime:unavailable, le cas connu est Safari (26/27), dont l’implémentation WebTransport parle un draft du protocole que le serveur ne sert pas encore ; le parcours certifié passe par un WebSocket que ces navigateurs servent, et le widget y bascule plutôt que de laisser l’utilisateur devant un écran sans issue. Un jeton refusé, des détecteurs qui ne se chargent pas ou un lien qui tombe en pleine mesure mènent au même endroit : l’écran temps réel ne propose pas de reprise, le parcours certifié ne dépendant d’aucun de ces pas. Dans tous les cas la mesure que votre utilisateur est venu chercher reste à un écran.
ui — les couches superposées au parcours
| Événement | Charge utile |
|---|---|
ui:menu-opened, ui:menu-closed | — |
ui:document-opened, ui:document-closed | kind : notice, termsOfUse ou privacy |
ui:language-changed | locale, direction (ltr ou rtl) |
ui:language-changed est rapporté quand l’utilisateur choisit une autre langue dans le sélecteur du widget — non quand votre propre setOptions() change lang. Il porte la direction parce que c’est ce sur quoi vous pouvez avoir à agir : une mise en page hôte encadrant le widget peut avoir à se retourner elle aussi.
Garanties d’ordonnancement
Deux, et elles sont structurelles plutôt que fortuites.
widget:ready est toujours le premier événement d’un montage qui réussit. Le démarrage de l’application dessine son premier écran immédiatement, de sorte que ce qui rapporte pendant cette étape — le détecteur de visage, par exemple — rapporterait sinon avant que le widget ne se soit annoncé. Ces événements sont retenus et délivrés derrière widget:ready.
Rien ne suit widget:destroyed. Le démontage de l’application exécute tous les crochets de destruction, dont plusieurs émettent. Le canal se verrouille avant que cela n’arrive, de sorte que l’événement est véritablement le dernier.
Au-delà de ces deux garanties, une mesure rapporte son issue avant l’écran qui la montre : measure:failed, puis screen:enter pour /error ; transport:disconnected, measure:result, puis screen:enter pour /result.
Ce qui n’est délibérément pas émis
Quatre absences, chacune pour une raison qui mérite d’être connue avant d’aller les chercher.
Aucun événement par message ré-émis. Une mesure envoie quelque neuf cents captures, et ce qu’une coupure laisse sans accusé de réception repart une fois la connexion revenue. Rapporter chaque renvoi produirait une rafale d’événements sur un canal qui, dans une intégration mobile, se trouve derrière un pont WebView. transport:reconnecting et transport:degraded couvrent le cas à la place.
Aucun point des deux ondes en temps réel. Le pouls et la respiration sont produits par image mesurée, soit une trentaine de valeurs par seconde chacun sur ce même canal. Les deux ondes sont tracées par l’écran, qui les lit sur place ; realtime:variables porte une fois par seconde ce qui en est tiré. Si ce sont les signaux eux-mêmes que vous voulez, c’est Realtime Measure qui les expose, avec la vidéo et l’affichage à votre main.
Aucun événement de redimensionnement. La taille du conteneur est votre mise en page, que vous connaissez déjà, et ResizeObserver se déclenche à chaque image d’animation pendant un glissement.
Aucun événement « calcul en cours » distinct. La fin de la capture et le passage à l’écran d’attente se produisent dans le même tour synchrone. measure:captures-sent le dit une fois, et porte les totaux.
Migrer
Depuis le widget v1
| v1 | v2 |
|---|---|
transition (from → to) | screen:enter — déclaré en v1 mais jamais réellement émis |
video-stream-loading status: "start" | camera:requesting |
video-stream-loading status: "ready" | camera:ready, avec les réglages obtenus |
video-stream-loading status: "error", reason | camera:error, code |
start | placement:accepted |
record | measure:start |
end | measure:captures-sent, avec les totaux |
result | measure:result |
aborted | measure:aborted ou measure:failed, selon que l’arrêt était délibéré ; reasons devient code |
leave | — la v2 n’a pas de commande pour quitter le widget |
onEvent retournant false retenait l’étape qui le suivait | hooks.beforeScreenChange pour un écran, hooks.beforeMeasureStart pour le départ de la capture |
Correspondance des motifs caméra : denied → NOT_ALLOWED ; no-device → NOT_FOUND, ou NOT_READABLE sous Firefox, que la v1 y rangeait ; already-used → NOT_READABLE ou ABORT, que la v2 distingue ; not-supported → NOT_SUPPORTED. UNKNOWN est nouveau : la v1 n’avait pas de motif pour une exception qu’elle n’attendait pas.
Le contrat a changé sur un point : onEvent n’est plus attendu et son retour n’est plus lu. C’est ce qui garantit qu’un gestionnaire lent ou fautif ne peut pas emporter une mesure. La faculté de refuser n’a pas disparu — elle est passée dans les hooks, où elle est bornée par un délai de garde.
Depuis les premières intégrations v2
Les six premiers noms ont été remplacés par les noms à espace de nommage :
| Avant | Maintenant |
|---|---|
ready | widget:ready |
screen | screen:enter — porte aussi name |
progress | measure:progress |
result | measure:result |
abort | measure:aborted |
error | measure:failed |