Format de fil

Pour les intégrations qui diffusent depuis un serveur plutôt qu’un navigateur : le transport, les flux, les octets.

Alpha — à lire d'abord

Tout ce qui est sur cette page est alpha : cela peut changer sans période de dépréciation, et un changement peut vous obliger à réécrire. La librairie que documente le reste de cette section le cache précisément pour que la plupart des intégrations n’aient jamais à le lire.

Parlez-nous avant de bâtir dessus. Si vous pouvez faire tourner notre module dans un navigateur, faites-le plutôt.

Il y a un cas pour lequel cette page existe : la vidéo ne passe pas par un navigateur que vous maîtrisez. Une téléconsultation dont le serveur média détient déjà le flux, un fichier enregistré rejoué pour une validation, une application mobile avec sa propre chaîne de capture — ceux-là ne peuvent pas charger un module ES, et parlent au transport directement.

Le transport

WebTransport, c’est-à-dire HTTP/3 sur QUIC. Une session par mesure, ouverte contre le serveur d’analyse de l’environnement que vous mesurez — le même hôte que la librairie déduit d’elle-même, sur le port HTTPS standard :

https://api-webtransport.<hôte de votre environnement>/measures?accessTokenValue=<jeton>&visual=<…>

Quel hôte, cela dépend de votre environnement, et une intégration de serveur à serveur n’est pas de celles qu’on branche seul : demandez-nous l’origine de la vôtre, et dites-nous la forme de votre source par la même occasion.

Le jeton d’accès voyage dans l’adresse, sous le paramètre de requête accessTokenValue : un navigateur ne peut poser aucun en-tête sur une poignée de main WebTransport, et l’interface est la même pour tous. Il se frappe exactement comme pour la librairie, avec la permission measurement.realtime, et la poignée de main le consomme.

Un seul chemin, et une session qui déclare ce qu’elle enverra

Il n’y a qu’une adresse. Ce qu’une session enverra se déclare dans ses paramètres, un par modalité — ce qui est ce qui permettra d’ajouter une modalité sans ajouter d’adresse.

https://api-webtransport.<hôte de votre environnement>/measures
    ?accessTokenValue=<jeton>
    &visual=<ce que la caméra envoie>
    &format=<conteneur>          # vidéo seulement
visualCe que vous envoyezCe que le serveur fait
images-partsdes recadrages RGB — un visage, un torseles mesure tels quels
images-fulldes images RGB entièrestrouve le visage, recadre, mesure
video-partsune vidéo d’un recadragela découpe en images, les mesure telles quelles
video-fullune vidéo d’images entièresdécoupe, trouve le visage, recadre, mesure

format dit le conteneur — webm, mp4 ou h264 — et n’appartient qu’à une session vidéo : déclaré sur une session d’images, il est refusé, parce que rien ne le lirait.

voice est la modalité suivante. Elle est déjà dans le vocabulaire, et une session qui la déclare est refusée — rien ne mesure encore une voix, et servir une telle session sur sa seule moitié visuelle vous laisserait croire qu’une mesure a lieu là où il n’y en a aucune.

Tout est vérifié à la poignée de main, avant que rien ne soit écrit. Une modalité absente ou inconnue, un conteneur absent ou inconnu, un conteneur sur une session d’images, une session qui demande la détection quand le modèle n’est pas chargé : tout est refusé là, si bien qu’aucune ligne de mesure n’existe et qu’aucun jeton n’est dépensé pour une session qui n’aurait rien mesuré.

La poignée de main peut aussi répondre 503 quand le serveur est plein ou sous pression mémoire. Ce refus précède la lecture du jeton, donc rien n’est dépensé : c’est un réessayez dans un instant, jamais un problème de jeton.

Entrée : un flux par source

Vous ouvrez un flux unidirectionnel — le serveur ne fait que le lire —, écrivez ce que votre session n’a pas déjà dit, puis envoyez. Sur une session d’images, un message par image, bout à bout ; sur une session vidéo, les octets du conteneur tels quels.

Le plan des octets d'un flux d'imagesUn flux s'ouvre sur un octet qui dit ce qu'il porte — et seulement quand la session a annoncé des recadrages —, puis enchaîne un message par image : index, horodatage, drapeaux, largeur, longueur, et la charge elle-même.UNE FOIS, À L'OUVERTURE DU FLUXcontent1 ola partie qu'il porte — et seulement quandla session a annoncé des recadragesPUIS UN MESSAGE PAR IMAGEindex4 otime4 oflags1 owidth2 olength4 opayloadautant que le dit lengthaucun séparateur, aucun remplissage : un lecteur avance de la longueur annoncée
Un flux ne déclare qu’une chose, et seulement quand il y a un choix : la partie qu’il porte, lorsque la session a annoncé des recadrages. Une session qui a déjà tout dit s’ouvre directement sur ses messages — et rien ne les sépare, un lecteur avançant de la longueur que chacun a annoncée.
Champ d’en-têteOctetsValeur
content10 visage, 1 torse — images-parts et video-parts seulement
Champ de messageOctetsValeur
index4la capture à laquelle cette image appartient. Il peut sauter.
time4l’horodatage de capture, en millisecondes
flags1bit 0 : la charge est compressée en brotli. Les sept autres sont réservés.
width2la largeur de cette image, jusqu’à 1920
length4la longueur de la charge, jusqu’à 10 Mio

Cinq choses ne s’en devinent pas :

  • La hauteur ne voyage pas. Une charge RGB décompressée pèse width × height × 3, donc la hauteur se déduit de la longueur — et ce qui est gagné n’est pas un octet, c’est le droit de changer de taille d’une image à l’autre : un recadrage suit un visage qui bouge. Une longueur qui ne fait pas des lignes entières est refusée plutôt que devinée.
  • index est celui de la capture, et il saute. Une image sans visage n’est jamais envoyée, donc la suite a des trous. Ne renumérotez pas pour les refermer : le trou est ce qui dit qu’un moment n’a pas été mesuré.
  • time est l’horodatage de capture, pas celui de l’envoi. C’est lui dont la mesure tire une fréquence cardiaque. Le déduire d’un compteur d’images divise le pouls par la cadence que vous avez supposée, et l’erreur est silencieuse — le score de qualité ne l’attrapera pas.
  • La compression est par message. Le drapeau est relu à chaque image, si bien qu’un émetteur peut cesser de compresser sous charge sans rien renégocier.
  • Un flux par contenu. Un second flux du même contenu est refusé : il ouvrirait une seconde mesure de la même chose, et entrelacerait deux signaux sans qu’aucune erreur ne soit levée. Un flux d’un autre contenu est accepté — c’est ainsi qu’un visage et un torse voyagent ensemble.

Une session vidéo ne porte aucun message

Après son en-tête — vide, la session ayant tout déclaré —, une session vidéo prend les octets du conteneur, tels quels : aucun cadrage par image, le conteneur ayant déjà le sien. Le serveur découpe les images lui-même, et quatre de ses règles vous concernent :

  • mp4 veut dire mp4 fragmenté. Un mp4 ordinaire porte son index à la fin, si bien qu’un flux qui en porte un ne rend aucune image. Ce que produit l’enregistreur d’un navigateur est déjà fragmenté ; un fichier poussé depuis un disque, généralement pas.
  • La cadence est un plafond, pas une cible. Le serveur garde au plus 30 images par seconde et suit votre source en dessous — il ne recopie jamais une image pour atteindre une cadence. Les horodatages qu’il emploie sont ceux que votre conteneur porte.
  • Rien n’est redimensionné. Les images sont décodées à la taille de votre source, ce qui est aussi pourquoi aucune dimension n’est déclarée. Une source qui change de résolution en cours de route est refusée, et une image au-delà de 1920 × 1080 avec elle.
  • Les horodatages négatifs de tête sont recalés, les illisibles refusés. Un conteneur à liste d’édition — une capture iOS, typiquement — commence légitimement quelques images avant zéro : l’échelle est décalée pour que rien ne précède zéro, tous les écarts étant préservés — et les écarts sont la seule chose qu’une fréquence cardiaque lit. Une image dont l’horodatage ne se lit pas refuse la session plutôt que de deviner.

Sortie : un flux par nature

Le serveur ouvre les flux de retour, un par nature. Chacun commence par un octet qui le nomme, puis porte des messages JSON cadrés par leur longueur — quatre octets de longueur gros-boutiste, puis la charge.

OctetFluxCe qu’il porte
0hrl’onde de pouls : { index, time, value }
1brl’onde respiratoire : { index, time, value }
2variablesles grandeurs, une fois par seconde — la forme même que la librairie rapporte

Il n’y a aucun discriminant dans un message : le flux a dit sa nature une fois, à son début. C’est ce qui permet à un lecteur de ne s’abonner qu’aux grandeurs et de ne jamais décoder une onde qu’il ne dessine pas.

Les deux signaux ont la même forme, et c’est délibéré : un lecteur qui sait dessiner l’un sait dessiner l’autre, et un signal ajouté plus tard ne lui apprendra rien de nouveau. Un point dit trois choses — où il se place dans son signal, quand il a été capturé, et ce qu’il vaut.

Ils n’arrivent pas au même rythme, parce qu’ils ne se lisent pas sur la même chose. Le pouls donne un point par image de visage mesurée ; la respiration, un point par image de torse mesurée — c’est le mouvement de la cage thoracique, là où la respiration a lieu. Les deux index sautent, et pour la même raison : une image dans laquelle le serveur n’a rien trouvé ne donne pas de point. Une session qui n’envoie pas de torse n’a donc aucune onde respiratoire à tracer, et sa fréquence respiratoire vient alors de la lente modulation de l’onde de pouls — variables dit laquelle des deux vous lisez.

La value d’un point respiratoire est un déplacement, pas un niveau : le mouvement vertical cumulé de la cage thoracique, en pixels du recadrage sur lequel il a été mesuré. Son origine est là où se trouvait la première image, et elle dérive lentement, si bien que seule la variation veut dire quelque chose. Calez un graphe sur le minimum et le maximum de ce qu’il montre, jamais sur zéro.

Une session vidéo, de bout en bout

Ce qui suit est le client entier, en une page. C’est du code de navigateur — WebTransport est une interface de navigateur — et une intégration serveur n’en diffère que d’une ligne : Node n’a pas de WebTransport à lui, et l’implémentation sur laquelle tourne notre serveur livre un client de même interface (import { WebTransport } from "@fails-components/webtransport").

L’envoi

Une session, un flux unidirectionnel, aucun en-tête — la session a tout déclaré dans son adresse —, puis votre vidéo telle qu’elle vient.

const origin = "https://api-webtransport.<votre hôte>"
const url = `${origin}/measures`
    + `?visual=video-full`                 // des images entières, dans un conteneur : le serveur cherche le visage
    + `&format=webm`                       // le conteneur — déclaré ici, et non sur le fil
    + `&accessTokenValue=${encodeURIComponent(token)}`

const session = new WebTransport(url)
await session.ready

const writer = (await session.createUnidirectionalStream()).getWriter()

// Aucun en-tête : une session qui envoie des images entières n'a plus rien à déclarer, et son conteneur
// est dans l'adresse. La taille des images est celle de votre source, que le serveur lit dans le flux.

// Directement les octets du conteneur. Rien ne les cadre : votre vidéo, octet pour octet.
const recorder = new MediaRecorder(camera, {
    mimeType: "video/webm;codecs=h264", videoBitsPerSecond: 2_500_000
})
recorder.ondataavailable = async event => {
    if (event.data.size === 0) return
    await writer.ready               // contre-pression : QUIC fait attendre
    await writer.write(new Uint8Array(await event.data.arrayBuffer()))
}
recorder.start(200)                  // un fragment toutes les 200 ms

La fin se fait en deux gestes, dans cet ordre. await writer.close() termine la vidéo — et la mesure n’est pas finie pour autant : des images sont encore dans le décodeur et leurs résultats arrivent encore. La session se ferme une fois que vous avez lu ce que vous étiez venu chercher.

La réception

Le serveur ouvre un flux par nature, à la première valeur de cette nature : l’onde de pouls en moins d’une seconde, l’onde respiratoire dès qu’une image de torse a été mesurée, les grandeurs vers cinq secondes. Acceptez donc les flux entrants pendant toute la session, et lisez chacun à côté des autres — une boucle qui attend la fin d’un flux ne voit jamais le suivant.

const KINDS = ["hr", "br", "variables"]

const incoming = session.incomingUnidirectionalStreams.getReader()
for (;;) {
    const { value, done } = await incoming.read()
    if (done) break
    readOutputStream(value, onMessage)   // pas attendu : les trois de front
}

Puis le lecteur lui-même. QUIC livre des morceaux, pas des messages : une frontière tombe au milieu d’un cadre aussi volontiers qu’ailleurs, et l’octet de nature peut arriver seul. D’où le tampon.

async function readOutputStream(stream, onMessage) {
    const reader = stream.getReader()
    let buffer = new Uint8Array(0)
    let kind = null

    for (;;) {
        const { value, done } = await reader.read()
        if (done) return

        const merged = new Uint8Array(buffer.length + value.length)
        merged.set(buffer, 0)
        merged.set(value, buffer.length)
        buffer = merged

        if (kind === null) {
            if (buffer.length < 1) continue
            kind = KINDS[buffer[0]] ?? `inconnu(${buffer[0]})`
            buffer = buffer.subarray(1)   // la nature se lit une fois, et une seule
        }

        for (;;) {
            if (buffer.length < 4) break
            const length = new DataView(buffer.buffer, buffer.byteOffset)
                .getUint32(0, false)
            if (buffer.length < 4 + length) break

            const payload = buffer.subarray(4, 4 + length)
            onMessage(kind, JSON.parse(new TextDecoder().decode(payload)))
            buffer = buffer.subarray(4 + length)
        }
    }
}

Ce qui redescend

function onMessage(kind, message) {
    if (kind === "hr")        chart.push(message.time, message.value)
    if (kind === "br")        breathing.push(message.time, message.value)
    if (kind === "variables") panel.show(message)
}

hr arrive à la cadence des images de visage mesurées, br à celle des images de torse mesurées, et variables une fois par seconde — rien du tout tant que la fenêtre est trop courte, si bien qu’un silence au début veut dire pas encore, jamais rien à mesurer.

Champ de variablesCe qu’il faut en faire
heartRate, breathingRateLes deux chiffres qu’un utilisateur attend.
breathingSource, breathingQuality, breathingWindowSD’où la fréquence respiratoire a été lue — motion du torse, bvp de l’onde de pouls —, avec quelle netteté, et sur combien de secondes. Les deux dernières valent null pour bvp.
stars, sqiLa qualité du signal, en cinq crans et en indice brut.
maturefalse tant que la fenêtre est trop courte. Dites « mesure en cours », pas un chiffre.
confidentfalse quand la qualité ne franchit pas le seuil. Montrez-le comme provisoire.
sdnn, rmssd, pnn50, sd1, sd2, lfHfLa variabilité, null quand la qualité n’a pas permis de la calculer.
heartRateIntervals, beats, rejectedLa fréquence par les intervalles, et combien ont été retenus et écartés.
elapsed, healthIndexLes secondes mesurées, et l’indice composite quand un âge a été fourni.

Un champ de variabilité à null veut dire aucune mesure — jamais zéro, qui serait la lecture d’un cœur parfaitement régulier.

hr et br portent chacun index, la capture à laquelle leur point appartient, et tous deux sautent : une image dans laquelle le serveur n’a rien mesuré ne donne pas de point. Dessinez ces trous comme des trous. Refermer la ligne dirait à un lecteur que le signal était continu alors qu’il ne l’était pas, et l’index est la seule chose qui dise le contraire.

Ne recalculez pas

Les points d’onde sont ceux du serveur, calculés sur le signal entier qu’il détient. En tirer votre propre fréquence cardiaque, sur la tranche que vous avez reçue, produirait une seconde valeur, différente et sans autorité derrière elle.

Cinq choses à ne pas manquer

  • Rien n’est déclaré de la taille, et rien n’est redimensionné. Les images sont décodées à la taille de votre source — jusqu’à 1920 × 1080, au-delà la session est refusée. Envoyer une source plus petite reste la façon la moins chère de dépenser moins, de votre côté comme du nôtre.
  • mp4 veut dire mp4 fragmenté. Un mp4 ordinaire porte son index à la fin, donc un flux qui en est fait ne rend aucune image. Un enregistreur de navigateur produit déjà des fragments ; un fichier poussé depuis un disque, en général non.
  • La cadence est un plafond. Trente images par seconde au plus, et la cadence de votre source en dessous. Rien n’est jamais dupliqué pour atteindre un chiffre : une source à 25 images est mesurée à 25.
  • Les horodatages viennent de votre conteneur, image par image, et ce sont eux dont se lit une fréquence cardiaque. Un conteneur écrit avec une base de temps inventée ou constante rend un pouls plausible et pourtant faux, que l’indice de qualité ne rattrapera pas.
  • Écrivez avec la contre-pression. await writer.ready avant chaque écriture. Sans elle, un lien lent devient de la mémoire dans votre propre processus, et la panne apparaît là où elle n’a pas commencé.

Ce qui n'est pas arrêté

Étant alpha, voici où cela bouge : la porte vidéo n’accepte pas d’horodatages par image qui lui soient propres, jpeg est un format d’image déclaré que rien ne décode encore, et composer un visage depuis une image entière que vous envoyez aussi n’est pas implémenté — une image entière est mesurée par la détection du serveur, ou pas du tout.