Commit a7400787 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

La salle existait, on ne la voyait pas ; la caméra tournait, on ne se voyait pas

Deux défauts signalés à l'écran, de causes indépendantes, et tous deux invisibles
à la lecture du code.

── Le canal audio d'une cellule n'apparaissait nulle part

La salle d'une cellule est créée au déclenchement, juste après son salon, et son
événement ne porte pas « salon_id » — seule la voix ajoutée à un canal le
renseigne. Or le rail cherche la voix d'un salon par ce lien. Il ne la trouvait
jamais.

D'où l'observation qui a mis sur la piste : la cellule juridique affichait bien
son entrée vocale, parce que cette voix-là avait été ajoutée par « Gérer les
canaux », qui pose le lien. Les deux cellules ouvertes au déclenchement, non. La
salle existait pourtant, et l'on y entrait par « Rejoindre l'appel » — elle
n'était qu'introuvable dans la liste.

Le lien est déduit à la lecture plutôt qu'inscrit au journal. Une projection ne
doit rien contenir que l'événement ne dise ; et l'inscrire seulement pour les
crises à venir aurait laissé muettes les crises en cours, ou demandé de réécrire
une projection qu'un rejeu aurait aussitôt défaite. La déduction, elle, répare
une crise ouverte depuis vingt heures sans migration.

La visibilité ne bouge pas : le filtre d'accès porte toujours sur la colonne
réelle, jamais sur la valeur déduite. Une salle de cellule reste ouverte à toute
la crise, une salle de canal privé reste fermée aux non-membres.

── On ne se voyait pas soi-même

Trois causes empilées, et la première suffisait. La tuile locale n'affichait que
des initiales : aucune balise vidéo, donc rien à quoi attacher la piste.
Allumer sa caméra publiait bien le flux — les autres voyaient — sans qu'on se
voie. On ne savait donc pas si l'on était cadré, éclairé, ni même si la caméra
fonctionnait, ce qui est la première chose qu'on vérifie avant de parler à sa
direction.

Les deux autres auraient suffi à annuler le correctif : « LocalTrackPublished »
et « LocalTrackUnpublished » n'étaient pas écoutés, si bien qu'allumer la caméra
ne rafraîchissait rien ; et un navigateur refuse de lire d'office une vidéo qui
n'est pas muette, ce qui aurait laissé un rectangle noir.

Le retour est retourné en miroir. Non retourné, on porte la main à gauche et
elle part à droite, ce qui suffit à faire douter de la caméra.

Une garantie neuve, qui échouait avant le correctif : la salle d'une cellule se
rattache au salon de cette cellule.

Ce que je n'ai pas pu éprouver : le retour caméra lui-même. La suite tourne sans
navigateur et je n'avais pas de caméra. Les trois causes sont établies par
lecture ; c'est à l'écran que cela se constate.

Co-Authored-By: Claude (RCA)
parent 46859113
Loading
Loading
Loading
Loading
+23 −1
Changes for apps/api/src/domaine/reunions.ts: 23 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -122,7 +122,29 @@ export async function salles(
  sql: Sql, criseId: string, compteId: string, celluleId?: string,
): Promise<Salle[]> {
  return sql<Salle[]>`
    select s.* from salle s
    select s.*,
           -- Le salon auquel cette salle appartient (EF-801).
           --
           -- La salle d'une cellule est créée au déclenchement, juste après le
           -- salon de la même cellule, et son événement ne porte pas le lien :
           -- « salon_id » n'est renseigné que par la voix ajoutée à un canal.
           -- Conséquence, invisible à la lecture du code et flagrante à
           -- l'écran : le rail cherche la voix d'un salon par ce lien, ne la
           -- trouvait jamais, et la salle de la cellule décisionnelle
           -- n'apparaissait nulle part — alors qu'elle existait et qu'on y
           -- entrait par « Rejoindre l'appel ».
           --
           -- Le lien est déduit ici plutôt qu'inscrit au journal, et c'est
           -- délibéré : une projection ne doit rien contenir que l'événement ne
           -- dise. L'inscrire pour les crises à venir aurait laissé les crises
           -- en cours muettes, ou demandé de réécrire une projection qu'un
           -- rejeu aurait aussitôt défaite.
           coalesce(s.salon_id,
             (select sa.id from salon sa
               where sa.crise_id = s.crise_id and sa.cellule_id = s.cellule_id
                 and sa.genre = 'cellule' and s.genre = 'cellule'
               limit 1))                                        as salon_id
      from salle s
     where s.crise_id = ${criseId} and s.fermee_at is null
       and (s.salon_id is null
            or exists (select 1 from salon sa
+17 −0
Changes for apps/api/src/verification/media.ts: 17 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -51,6 +51,23 @@ export async function verifierMedia(
    liste.length === 1 && liste[0]?.genre === "cellule", liste[0]?.libelle);
  const salle = liste[0]!.id;

  // Le lien avec le salon de la même cellule (EF-801).
  //
  // Le défaut ne se voyait pas dans le code et sautait aux yeux à l'écran : la
  // salle d'une cellule est créée juste après son salon, et son événement ne
  // porte pas « salon_id » — seule la voix ajoutée à un canal le renseigne. Le
  // rail cherche la voix d'un salon par ce lien, ne la trouvait jamais, et la
  // salle de la cellule décisionnelle n'apparaissait nulle part alors qu'elle
  // existait et qu'on y entrait par « Rejoindre l'appel ».
  const salonDeCellule = (await chat.salons(sql, crise.criseId, compteId))
    .find((sa) => sa.cellule_id === cellule && sa.genre === "cellule");
  r.verifie("EF-801", "la salle d'une cellule se rattache au salon de cette cellule",
    liste[0]?.salon_id === salonDeCellule?.id,
    `salle → ${liste[0]?.salon_id}, salon → ${salonDeCellule?.id}`);
  r.verifie("EF-801", "et elle reste ouverte à toute la crise, un salon de cellule n'étant "
    + "pas privé",
    (await reunions.salleAccessible(sql, crise.criseId, salle, compteId)) === true);

  await reunions.ouvrirSallePonctuelle(sql, crise.criseId, cellule,
    "point-direction", "Point inter-cellules", ctx);
  r.verifie("EF-801", "une salle ponctuelle s'ouvre à la demande",
+36 −3
Changes for apps/web/src/coque/PanneauVisio.tsx: 36 added lines, 3 removed lines.
Original line number Diff line number Diff line
import { useCallback, useEffect, useRef, useState } from "react";
import {
  Room, RoomEvent, Track, type RemoteTrack, type RemoteParticipant,
  Room, RoomEvent, Track,
  type LocalTrack, type RemoteTrack, type RemoteParticipant,
} from "livekit-client";
import { api, ErreurApi } from "../api.js";
import { initiales } from "../etat.js";
@@ -45,6 +46,16 @@ function PanneauVisio({
  const [etat, definirEtat] = useState<"connexion" | "connecte" | "echec">("connexion");
  const [erreur, definirErreur] = useState<string | null>(null);
  const [tuiles, definirTuiles] = useState<Tuile[]>([]);
  /**
   * Sa propre image (EF-803).
   *
   * Elle manquait, et le défaut ne se voyait pas dans le code : la tuile locale
   * n'affichait que des initiales, si bien qu'allumer sa caméra publiait bien la
   * piste — les autres voyaient — sans qu'on se voie soi-même. On ne savait donc
   * pas si l'on était cadré, éclairé, ni même si la caméra fonctionnait, ce qui
   * est la première chose qu'on vérifie avant de parler à sa direction.
   */
  const [pisteLocale, definirPisteLocale] = useState<LocalTrack | null>(null);
  const [micro, definirMicro] = useState(true);
  /** Ce que l'appel a perdu sans échouer : le micro, le plus souvent. */
  const [geneMicro, definirGeneMicro] = useState<string | null>(null);
@@ -89,6 +100,8 @@ function PanneauVisio({
        }
      }
      definirTuiles(liste);
      definirPisteLocale(
        salle.localParticipant.getTrackPublication(Track.Source.Camera)?.track ?? null);
    };

    salle.on(RoomEvent.ParticipantConnected, rafraichir)
@@ -97,6 +110,10 @@ function PanneauVisio({
      .on(RoomEvent.TrackUnsubscribed, rafraichir)
      .on(RoomEvent.TrackPublished, rafraichir)
      .on(RoomEvent.TrackUnpublished, rafraichir)
      // Ses propres pistes : sans ces deux-là, allumer sa caméra ne rafraîchit
      // rien et le retour n'apparaîtrait jamais, même la tuile en place.
      .on(RoomEvent.LocalTrackPublished, rafraichir)
      .on(RoomEvent.LocalTrackUnpublished, rafraichir)
      // Perdre un appel qu'on avait rejoint n'est pas la même chose que ne
      // jamais l'avoir joint. Le premier cas met en cause le chemin média —
      // UDP filtré, réseau qui bascule ; le second, l'accès au serveur. Les
@@ -209,7 +226,10 @@ function PanneauVisio({
      const cible = conteneurs.current.get(tuile.cle);
      if (cible && tuile.piste) tuile.piste.attach(cible.querySelector("video")!);
    }
  }, [tuiles]);
    const moi = conteneurs.current.get("local:camera");
    const video = moi?.querySelector("video");
    if (video && pisteLocale) pisteLocale.attach(video);
  }, [tuiles, pisteLocale]);

  async function quitter() {
    await salle.disconnect();
@@ -312,8 +332,21 @@ function PanneauVisio({
      ) : (
        <>
          <div className="tuiles">
            <div className="tuile">
            <div className="tuile" ref={(el) => { conteneurs.current.set("local:camera", el); }}>
              {pisteLocale ? (
                /*
                  Muette, et il faut l'être : sa propre piste renvoyée dans ses
                  haut-parleurs fait un larsen, et un navigateur refuse de lire
                  d'office une vidéo qui ne l'est pas — le retour resterait noir.

                  Retournée, aussi : on se regarde comme dans un miroir. Non
                  retournée, on porte la main à gauche et elle part à droite,
                  ce qui suffit à faire douter de la caméra.
                */
                <video autoPlay playsInline muted style={{ transform: "scaleX(-1)" }} />
              ) : (
                <span className="initiales">{initiales(salle.localParticipant.name ?? "Moi")}</span>
              )}
              <span className="nom">Vous{micro ? "" : " · micro coupé"}</span>
            </div>
            {tuiles.map((t) => (