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) etsrc/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
| Objet | Champs | Remarque |
|---|---|---|
| Camera | x, y, z, perspective, rotateX/Y/Z | z positif recule la camera. perspective est la distance de l'oeil au plan z = 0 |
| Focus | distance, fStop, focalLength, maxBlur | distance > focalLength, sinon la lentille n'a pas d'image. maxBlur = 0 coupe la profondeur de champ |
| Surface | id, parentId?, transform | transform : x, y, z, rotateX/Y/Z, scale, dans l'ordre CSS translate3d rotateX rotateY rotateZ scale |
| Motion | durationMs, 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.
sceneMsest le temps d'AUTEUR (celui des keyframes),playbackMsle temps mural.sceneMs = playbackMs * speed, etspeedvaut 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 amaxBlur. Le facteur de grandissementf / dest 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
computeFrame: la motion est evaluee au temps donne (fonction pure), fusionnee dans la scene, validee UNE fois, puisevaluateScenecalcule les matrices monde (chaine de parents, camera comprise) et un plan de profondeur par surface (profondeur au centre, gradient en x et y).classifyFocuschoisit le chemin :none,uniform(un seulblur()CSS, quand le plan est plat ou entierement au-dela du plafond), oubanded: 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 unStaticImageData, pas une URL). Les poids des niveaux forment une partition de l'unite.- 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.
- 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 paronDiagnosticset par les attributsdata-cinema-valid/data-cinema-issuesde 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,coverageetcapture-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) :CinemaSceneetCinemaSurfacemontes 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 surResizeObserveretresize, que les diagnostics (duplicate-id,uncovered-content) posent l'attribut ET appellent le callback (liste vide une fois corrige), queuseCinemaCaptureinstalle le pont, queseekest synchrone horsactjusqu'a l'image finale, leve ses trois codes, et que le demontage retire le pont.color-scheme: darkest 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 appelleseekimage par image, puis MP4), la composition RemotionUiShot, et le skill qui ecrit des scenes.platform-ops: les options de workflow (ou et quand l'export tourne).