ArchitectureLe moteur cinema

Le moteur cinema

Pilier design-system. Code : src/lib/motion/cinema/ (pur : aucun React, aucun DOM) et src/components/patterns/cinema/ (la couche React, "use client"). Item : backlog/_archive/design-system/3031-moteur-cinema.md.

Pilier design-system. Code : src/lib/motion/cinema/ (pur : aucun React, aucun DOM) et src/components/patterns/cinema/ (la couche React, "use client"). Item : backlog/_archive/design-system/3031-moteur-cinema.md.

Pourquoi

Le studio Growth interne doit produire des videos du PRODUIT, pas des maquettes : une vraie carte du cockpit, un vrai graphe recharts, poses dans une scene 3D, filmes par une camera qui bouge, avec une profondeur de champ credible. Une capture d'ecran animee dans After Effects ment des qu'un composant change ; ici la video est rendue depuis le composant lui-meme, donc elle suit le code.

Inspire de Flute (MIT, Web Prodigies), reimplemente dans nos conventions : aucun fichier n'est copie.

Le modele

ObjetChampsRemarque
Camerax, y, z, perspective, rotateX/Y/Zz positif recule la camera. perspective est la distance de l'oeil au plan z = 0
Focusdistance, fStop, focalLength, maxBlurdistance > focalLength, sinon la lentille n'a pas d'image. maxBlur = 0 coupe la profondeur de champ
Surfaceid, parentId?, transformtransform : x, y, z, rotateX/Y/Z, scale, dans l'ordre CSS translate3d rotateX rotateY rotateZ scale
MotiondurationMs, speed, tracks[]une piste vise une surface (xyz, rotations, scale, opacity), la camera (xyz, rotations) ou le focus (ses quatre champs : rack focus)

La validation (zod v4, zod/v4) ne jette jamais : elle rend des issues a code stable (CinemaIssue.code). createKeepLastValid et la scene React continuent de dessiner la derniere scene valide pendant une edition invalide.

Les unites

  • Toute longueur est un pixel CSS : positions, perspective, distance de mise au point, focale, sigma du flou. Les angles sont en degres.
  • Deux horloges. sceneMs est le temps d'AUTEUR (celui des keyframes), playbackMs le temps mural. sceneMs = playbackMs * speed, et speed vaut 0.5 par defaut : une scene ecrite en 4 s se joue en 8 s.
  • Le flou : cercle de confusion de lentille mince exprime sur le plan de la surface, sigma = f / (N (D - f)) * |d - D| / 4, plafonne a maxBlur. Le facteur de grandissement f / d est retire parce que la perspective CSS retrecit deja les surfaces lointaines.

La regle de la feuille

Le flou et l'opacite ne s'appliquent QU'A la feuille visuelle d'une surface (sa prop content), jamais au groupe qui porte le transform. Un filter ou une opacity < 1 sur un element preserve-3d force le navigateur a aplatir tout le sous-arbre : chaque surface imbriquee perd sa profondeur. surface-styles.ts est le seul endroit qui ecrit ces deux styles, et son test refuse qu'ils atteignent le wrapper.

Consequence : toute UI visible se pose en content. Du texte ou une image laisses en vrac dans children (ou directement dans la scene) resteraient nets pendant que tout floute autour : le registre le signale en uncovered-content.

Comment une image est calculee

  1. computeFrame : la motion est evaluee au temps donne (fonction pure), fusionnee dans la scene, validee UNE fois, puis evaluateScene calcule les matrices monde (chaine de parents, camera comprise) et un plan de profondeur par surface (profondeur au centre, gradient en x et y).
  2. classifyFocus choisit le chemin : none, uniform (un seul blur() CSS, quand le plan est plat ou entierement au-dela du plafond), ou banded : un filtre SVG qui melange six niveaux de gaussienne selon deux rampes de profondeur generees en code (data URI SVG, pas de PNG importe : Next rend un StaticImageData, pas une URL). Les poids des niveaux forment une partition de l'unite.
  3. La region du filtre est elargie bien au-dela de 3 sigma (marge fixe plus un quart de la surface) : Flute coupait les ombres portees et les popovers.
  4. Le registre DOM mesure chaque surface (taille, decalage de son centre par rapport a son parent) par ResizeObserver, et remonte les diagnostics : id en double, surface sans aire, contenu non couvert, surface qui traverse le plan camera. Ils sortent par onDiagnostics et par les attributs data-cinema-valid / data-cinema-issues de la racine.

Tout est memoise : la motion est validee une fois par objet, chaque frame est calculee une fois, et le filtre d'une surface qui n'a pas bouge n'est pas reconstruit.

Le mouvement

Easings linear, easeInOut, cinematic (defaut : smootherstep quintique, src/lib/motion/cinematic.ts, vitesse et acceleration nulles aux deux bouts). Une piste tient sa premiere valeur avant sa premiere keyframe et sa derniere apres.

Par defaut chaque paire de keyframes est un segment, donc une courbe adoucie s'arrete a chaque keyframe. continuous: true fait de la piste UN seul mouvement : un easing global du premier au dernier keyframe, une spline cubique monotone a travers les valeurs. Le rail de camera accelere une fois, traverse les keyframes interieures sans s'arreter, et decelere une fois. Les keyframes interieures deviennent des points de passage : leur valeur est atteinte, a l'instant que decide l'easing global.

createCascade construit une entree en escalier (profondeurs decalees qui reviennent a z = 0 en quinconce) et refuse un escalier qui depasse la duree.

Le pont de capture

useCinemaCapture installe window.__BOOSTECOM_CINEMA__ : { version, durationMs, seek(ms) }. durationMs est en temps mural ; seek accepte tout ms de [0, durationMs], BORNE FINALE COMPRISE, et commit en flushSync : au retour, le DOM montre ms. frameTimes(durationMs, fps) donne les instants d'un export a n'importe quelle cadence, derniere image exacte incluse. Un seek sur une scene avec diagnostics leve une CinemaCaptureError codee (CINEMA_INVALID_SCENE, CINEMA_SEEK_OUT_OF_RANGE, CINEMA_BRIDGE_TAKEN).

La capture doit etre SOMBRE. La racine de la scene pose color-scheme: dark, et l'exporteur doit aussi lancer son navigateur en schema sombre : Flute exporte en clair par erreur.

Les tests

Deux etages, et la frontiere est l'environnement vitest :

  • node (le defaut du depot) : tout ce qui est pur. src/lib/motion/cinema/*.test.ts (matrices, focus, mouvement, scene) et, cote composants, surface-styles, coverage et capture-bridge, ecrits pour tourner sans DOM.
  • happy-dom, par fichier (// @vitest-environment happy-dom), src/components/patterns/cinema/cinema-dom.test.ts (design-system/3037) : CinemaScene et CinemaSurface montes par react-dom (createRoot + act, pas de @testing-library). Il prouve que les enfants sont rendus, que flou et opacite ne touchent que la feuille (jamais wrapper, groupe ni stage), que le registre mesure taille et decalage au centre du parent et se remet a jour sur ResizeObserver et resize, que les diagnostics (duplicate-id, uncovered-content) posent l'attribut ET appellent le callback (liste vide une fois corrige), que useCinemaCapture installe le pont, que seek est synchrone hors act jusqu'a l'image finale, leve ses trois codes, et que le demontage retire le pont. color-scheme: dark est verifie sur la racine.

happy-dom plutot que jsdom : plus leger, et aucun des deux n'a de moteur de layout, donc la chaine offset* que lit le registre est simulee depuis des attributs data-w/h/x/y, et ResizeObserver est un faux pilotable. Ce que ce test ne peut PAS prouver : le rendu reel du filtre SVG banded et l'aplatissement preserve-3d, qui demandent un navigateur. Playwright est dans le depot pour l'e2e, sans component testing : ce controle-la reviendra a la PR growth-web de l'exporteur, qui ouvre de toute facon un vrai navigateur sur la route de scene.

Ce que les PR suivantes ajoutent

  • app-shell : la route de scene (la page que l'exporteur ouvre) et la section du studio qui la compose.
  • growth-web : la capture video (navigateur headless qui appelle seek image par image, puis MP4), la composition Remotion UiShot, et le skill qui ecrit des scenes.
  • platform-ops : les options de workflow (ou et quand l'export tourne).